'use strict';
/* ================= Translations ================= */
const I18N = {
en: {
locale: 'en-US',
searchAria: 'Search repositories',
searchPh: 'Search… log viewer user:grafana NOT electron (press /)',
inAria: 'Search in', inAll: 'Everywhere', inName: 'Name', inDesc: 'Description', inReadme: 'README',
settings: 'Settings', theme: 'Light / dark theme', close: 'Close',
languageAria: 'Language', langAll: 'All languages',
starsAria: 'Stars', starsAll: 'Any stars',
pushedAria: 'Last push', pushedAll: 'Any activity',
pushed1m: 'Pushed < 1 month', pushed6m: 'Pushed < 6 months', pushed1y: 'Pushed < 1 year', pushed2y: 'Pushed < 2 years',
createdAria: 'Creation date', createdAll: 'Any creation date',
created1m: 'Created < 1 month', created1y: 'Created < 1 year', created5y: 'Created < 5 years',
licenseAria: 'License', licenseAll: 'Any license',
topicPh: 'topic', topicAria: 'Topic',
goodFirst: 'Good first issues', goodFirstTitle: 'Only repositories with issues labeled "good first issue"',
withForks: 'Forks', withForksTitle: 'Include forks in the results',
withArchived: 'Archived', withArchivedTitle: 'Include archived (read-only) repositories',
reset: 'Reset filters',
sortAria: 'Sort', sortBest: 'Best match', sortStars: 'Most stars', sortForks: 'Most forks',
sortUpdated: 'Recently updated', sortHelp: 'Help wanted issues',
export: 'CSV', exportTitle: 'Download the loaded results as CSV',
exportNone: 'Nothing to export', exportDone: (f) => `Downloaded: ${f}`,
more: 'Load more', moreN: (n) => `Load ${n} more`,
emptyTitle: 'No matching repositories.',
emptyHint: 'Loosen a filter or change the search words.',
count: ({ shown, total }) => `${shown} of ${total} ${total === 1 ? 'repository' : 'repositories'}`,
countCapped: 'GitHub only returns the first 1,000 results: refine the search to see the others.',
fork: 'fork', archived: 'archived',
updated: (s) => `updated ${s}`,
openGh: 'Open on GitHub', homepage: 'Website', copyClone: 'Copy clone URL', copyName: 'Copy name', copied: 'Copied',
filterLang: 'Click to filter on this language', filterTopic: 'Click to filter on this topic',
fFull: 'repository', fCreated: 'created', fPushed: 'last push', fLicense: 'license', fIssues: 'open issues',
fForks: 'forks', fStars: 'stars', fClone: 'clone',
noLicense: 'none',
searchError: 'Search error: ',
unreachable: 'server unreachable',
connToken: 'GitHub · token', connNoToken: 'GitHub · no token',
quota: ({ r, l }) => `${r}/${l} searches left`,
quotaReset: (s) => `reset in ${s}s`,
ghQueryTitle: 'Query sent to GitHub: click to open it on github.com',
err_bad_param: 'Invalid filter value',
err_too_far: 'GitHub only returns the first 1,000 results',
err_rate_limited: 'GitHub search quota exhausted: wait a minute (or set GITHUB_TOKEN on the server)',
err_bad_query: 'GitHub rejected the query',
err_github_token: 'GitHub refused the token (GITHUB_TOKEN)',
err_github_unreachable: 'Cannot reach GitHub',
language: 'Language',
tabLocale: 'Localization', tabInterface: 'Interface',
themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark',
themeHelp: 'System follows the light/dark preference of your computer or phone.',
listTitle: 'Result list', density: 'Density', compact: 'Compact', comfortable: 'Comfortable',
perPage: 'Results per page',
tokenTitle: 'Access token',
tokenOn: 'A GitHub token is configured on the server.',
tokenOff: 'No GitHub token: searches are anonymous and limited to 10 per minute.',
tokenHelp: 'Set GITHUB_TOKEN on the server (a fine-grained token with no permission is enough for public repositories). It raises the quota to 30 searches per minute.',
quotaTitle: 'Search quota',
quotaUnknown: 'Unknown until the first search.',
quotaLine: ({ r, l, s }) => `${r} of ${l} searches left, reset in ${s} s.`,
quotaFull: ({ l }) => `${l} searches available.`,
quotaHelp: 'Identical searches are served from a 5 minute cache on the server and do not use the quota.',
syntaxTitle: 'Search syntax',
syntaxHelp: 'The search field accepts GitHub qualifiers, combined with the filters above the list:',
synUser: 'repositories of a user', synOrg: 'repositories of an organization', synTopics: 'several topics at once',
synPhrase: 'exact phrase', synNot: 'exclude a word', synSize: 'size in KB',
pushed1w: 'Pushed < 1 week', created1w: 'Created < 1 week', created3m: 'Created < 3 months', created6m: 'Created < 6 months',
viewsAria: 'Views', viewSearch: 'Search', viewSaved: 'Watch',
saveBtn: 'Schedule', saveBtnTitle: 'Run this search on a regular basis and review what changes',
savedTitle: 'Scheduled searches', savedNew: 'New from a search',
savedCount: (n) => `${n} ${n === 1 ? 'search' : 'searches'}`,
savedEmptyTitle: 'No scheduled search yet.',
savedEmptyHint: 'Set the filters in Search (for example: created < 1 week, most stars, a topic), then click Schedule.',
newTotal: (n) => `${n} new ${n === 1 ? 'repository' : 'repositories'} in the latest runs`,
schedHours: (n) => `Every ${n} h`, schedDay: (tm) => `Every day at ${tm}`, schedWeek: ({ d, tm }) => `Every ${d} at ${tm}`,
paused: 'paused', nextRun: (s) => `next ${s}`, lastRun: (s) => `last run ${s}`, neverRun: 'never run',
running: 'Running…', statNew: (n) => `${n} new`, statRising: (n) => `${n} rising`, statCount: (n) => `${n} repos`, statErr: 'error',
runNow: 'Run now', runNowTitle: 'Run the search now and keep a new snapshot',
runDone: ({ n, r }) => `Done: ${n} new, ${r} rising`, runFailed: 'Run failed: ',
edit: 'Edit', delete: 'Delete', cancel: 'Cancel', save: 'Save', back: 'Back to the list',
pause: 'Pause', resume: 'Resume',
confirmDelete: (n) => `Delete "${n}" and its history?`, deleted: 'Deleted',
openInSearch: 'Open in search', openInSearchTitle: 'Load these filters in the search view',
runAria: 'Run', revModeAria: 'Show',
modeNew: (n) => `New · ${n}`, modeRising: (n) => `Rising · ${n}`, modeAll: (n) => `All · ${n}`,
runOption: ({ d, n }) => `${d} · ${n} new`, runOptionErr: (d) => `${d} · error`,
revInfo: ({ d, total, q }) => `${d} · ${total} matching on GitHub · ${q}`,
revEmptyNew: 'Nothing new in this run.', revEmptyNewHint: 'Every repository was already in a previous run.',
revEmptyFirst: 'This is the first run.', revEmptyFirstHint: 'It is the baseline: the next runs will show what is new and what is rising. "All" shows this snapshot.',
revEmptyRising: 'No star gains since the previous run.', revEmptyRisingHint: 'Rising compares each repository with the previous snapshot.',
revEmptyAll: 'This run returned no repository.', revEmptyAllHint: '',
revNoRun: 'No run yet.', revNoRunHint: 'The first run is on its way, or click Run now.',
badgeNew: 'new', deltaTitle: 'stars gained since the previous run', rankTitle: 'position in the results',
saveNewTitle: 'Schedule this search', saveEditTitle: 'Edit the scheduled search',
svName: 'Name', svQuery: 'GitHub query', svEvery: 'Frequency', svWeekday: 'Day', svTime: 'Time', svHours: 'Hours',
everyWeek: 'Every week', everyDay: 'Every day', everyHours: 'Every N hours', hoursUnit: 'hours',
svMax: 'Results kept', svEnabled: 'Active', svEnabledHelp: 'Run on schedule',
svHintNew: (tz) => `The search runs once right away to set the baseline, then on schedule (server time zone: ${tz}).`,
svHintEdit: (tz) => `Server time zone: ${tz}. To change the filters, open the search, adjust it and schedule it again.`,
saved: 'Search scheduled', updated2: 'Saved',
err_not_found: 'Not found',
},
fr: {
locale: 'fr-FR',
searchAria: 'Rechercher des dépôts',
searchPh: 'Rechercher… log viewer user:grafana NOT electron (touche /)',
inAria: 'Chercher dans', inAll: 'Partout', inName: 'Nom', inDesc: 'Description', inReadme: 'README',
settings: 'Paramètres', theme: 'Thème clair / sombre', close: 'Fermer',
languageAria: 'Langage', langAll: 'Tous les langages',
starsAria: 'Étoiles', starsAll: 'Toutes étoiles',
pushedAria: 'Dernier push', pushedAll: 'Toute activité',
pushed1m: 'Push < 1 mois', pushed6m: 'Push < 6 mois', pushed1y: 'Push < 1 an', pushed2y: 'Push < 2 ans',
createdAria: 'Date de création', createdAll: 'Toute date de création',
created1m: 'Créé < 1 mois', created1y: 'Créé < 1 an', created5y: 'Créé < 5 ans',
licenseAria: 'Licence', licenseAll: 'Toute licence',
topicPh: 'topic', topicAria: 'Topic',
goodFirst: 'Good first issues', goodFirstTitle: 'Seulement les dépôts avec des issues « good first issue »',
withForks: 'Forks', withForksTitle: 'Inclure les forks dans les résultats',
withArchived: 'Archivés', withArchivedTitle: 'Inclure les dépôts archivés (lecture seule)',
reset: 'Réinitialiser les filtres',
sortAria: 'Tri', sortBest: 'Pertinence', sortStars: 'Plus d\'étoiles', sortForks: 'Plus de forks',
sortUpdated: 'Mis à jour récemment', sortHelp: 'Issues « help wanted »',
export: 'CSV', exportTitle: 'Télécharger les résultats chargés en CSV',
exportNone: 'Rien à exporter', exportDone: (f) => `Téléchargé : ${f}`,
more: 'Charger la suite', moreN: (n) => `Charger ${n} de plus`,
emptyTitle: 'Aucun dépôt ne correspond.',
emptyHint: 'Assouplissez un filtre ou changez les mots recherchés.',
count: ({ shown, total }) => `${shown} sur ${total} dépôt${total > 1 ? 's' : ''}`,
countCapped: 'GitHub ne renvoie que les 1 000 premiers résultats : affinez la recherche pour voir les autres.',
fork: 'fork', archived: 'archivé',
updated: (s) => `mis à jour ${s}`,
openGh: 'Ouvrir sur GitHub', homepage: 'Site web', copyClone: 'Copier l\'URL de clone', copyName: 'Copier le nom', copied: 'Copié',
filterLang: 'Cliquer pour filtrer sur ce langage', filterTopic: 'Cliquer pour filtrer sur ce topic',
fFull: 'dépôt', fCreated: 'créé', fPushed: 'dernier push', fLicense: 'licence', fIssues: 'issues ouvertes',
fForks: 'forks', fStars: 'étoiles', fClone: 'clone',
noLicense: 'aucune',
searchError: 'Erreur de recherche : ',
unreachable: 'serveur injoignable',
connToken: 'GitHub · jeton', connNoToken: 'GitHub · sans jeton',
quota: ({ r, l }) => `${r}/${l} recherches restantes`,
quotaReset: (s) => `remise à zéro dans ${s} s`,
ghQueryTitle: 'Requête envoyée à GitHub : cliquer pour l\'ouvrir sur github.com',
err_bad_param: 'Valeur de filtre invalide',
err_too_far: 'GitHub ne renvoie que les 1 000 premiers résultats',
err_rate_limited: 'Quota de recherche GitHub épuisé : attendez une minute (ou définissez GITHUB_TOKEN sur le serveur)',
err_bad_query: 'GitHub a refusé la requête',
err_github_token: 'GitHub a refusé le jeton (GITHUB_TOKEN)',
err_github_unreachable: 'GitHub est injoignable',
language: 'Langue',
tabLocale: 'Localisation', tabInterface: 'Interface',
themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre',
themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou téléphone.',
listTitle: 'Liste des résultats', density: 'Densité', compact: 'Compacte', comfortable: 'Confortable',
perPage: 'Résultats par page',
tokenTitle: 'Jeton d\'accès',
tokenOn: 'Un jeton GitHub est configuré sur le serveur.',
tokenOff: 'Pas de jeton GitHub : les recherches sont anonymes et limitées à 10 par minute.',
tokenHelp: 'Définissez GITHUB_TOKEN sur le serveur (un jeton « fine-grained » sans aucune permission suffit pour les dépôts publics). Le quota passe à 30 recherches par minute.',
quotaTitle: 'Quota de recherche',
quotaUnknown: 'Inconnu avant la première recherche.',
quotaLine: ({ r, l, s }) => `${r} recherches restantes sur ${l}, remise à zéro dans ${s} s.`,
quotaFull: ({ l }) => `${l} recherches disponibles.`,
quotaHelp: 'Les recherches identiques sont servies par un cache de 5 minutes sur le serveur et ne consomment pas de quota.',
syntaxTitle: 'Syntaxe de recherche',
syntaxHelp: 'Le champ de recherche accepte les qualificatifs GitHub, combinés aux filtres au-dessus de la liste :',
synUser: 'dépôts d\'un utilisateur', synOrg: 'dépôts d\'une organisation', synTopics: 'plusieurs topics à la fois',
synPhrase: 'expression exacte', synNot: 'exclure un mot', synSize: 'taille en Ko',
pushed1w: 'Push < 1 semaine', created1w: 'Créé < 1 semaine', created3m: 'Créé < 3 mois', created6m: 'Créé < 6 mois',
viewsAria: 'Vues', viewSearch: 'Recherche', viewSaved: 'Veille',
saveBtn: 'Programmer', saveBtnTitle: 'Lancer cette recherche régulièrement et suivre ce qui change',
savedTitle: 'Recherches programmées', savedNew: 'Nouvelle depuis une recherche',
savedCount: (n) => `${n} recherche${n > 1 ? 's' : ''}`,
savedEmptyTitle: 'Aucune recherche programmée.',
savedEmptyHint: 'Réglez les filtres dans Recherche (par exemple : créé < 1 semaine, plus d\'étoiles, un topic), puis cliquez sur Programmer.',
newTotal: (n) => `${n} nouveau${n > 1 ? 'x' : ''} dépôt${n > 1 ? 's' : ''} dans les derniers passages`,
schedHours: (n) => `Toutes les ${n} h`, schedDay: (tm) => `Chaque jour à ${tm}`, schedWeek: ({ d, tm }) => `Chaque ${d} à ${tm}`,
paused: 'en pause', nextRun: (s) => `prochain passage ${s}`, lastRun: (s) => `dernier passage ${s}`, neverRun: 'jamais exécutée',
running: 'En cours…', statNew: (n) => `${n} nouveau${n > 1 ? 'x' : ''}`, statRising: (n) => `${n} en hausse`, statCount: (n) => `${n} dépôts`, statErr: 'erreur',
runNow: 'Exécuter', runNowTitle: 'Lancer la recherche maintenant et garder un nouvel instantané',
runDone: ({ n, r }) => `Terminé : ${n} nouveau${n > 1 ? 'x' : ''}, ${r} en hausse`, runFailed: 'Échec du passage : ',
edit: 'Modifier', delete: 'Supprimer', cancel: 'Annuler', save: 'Enregistrer', back: 'Retour à la liste',
pause: 'Mettre en pause', resume: 'Réactiver',
confirmDelete: (n) => `Supprimer « ${n} » et son historique ?`, deleted: 'Supprimée',
openInSearch: 'Ouvrir dans la recherche', openInSearchTitle: 'Charger ces filtres dans la vue Recherche',
runAria: 'Passage', revModeAria: 'Afficher',
modeNew: (n) => `Nouveautés · ${n}`, modeRising: (n) => `Progressions · ${n}`, modeAll: (n) => `Tous · ${n}`,
runOption: ({ d, n }) => `${d} · ${n} nouveau${n > 1 ? 'x' : ''}`, runOptionErr: (d) => `${d} · erreur`,
revInfo: ({ d, total, q }) => `${d} · ${total} résultats sur GitHub · ${q}`,
revEmptyNew: 'Rien de nouveau dans ce passage.', revEmptyNewHint: 'Tous les dépôts étaient déjà présents dans un passage précédent.',
revEmptyFirst: 'C\'est le premier passage.', revEmptyFirstHint: 'Il sert de référence : les suivants montreront les nouveautés et les progressions. « Tous » affiche cet instantané.',
revEmptyRising: 'Aucun gain d\'étoiles depuis le passage précédent.', revEmptyRisingHint: 'Progressions compare chaque dépôt avec l\'instantané précédent.',
revEmptyAll: 'Ce passage n\'a renvoyé aucun dépôt.', revEmptyAllHint: '',
revNoRun: 'Pas encore de passage.', revNoRunHint: 'Le premier passage est en cours, ou cliquez sur Exécuter.',
badgeNew: 'nouveau', deltaTitle: 'étoiles gagnées depuis le passage précédent', rankTitle: 'position dans les résultats',
saveNewTitle: 'Programmer cette recherche', saveEditTitle: 'Modifier la recherche programmée',
svName: 'Nom', svQuery: 'Requête GitHub', svEvery: 'Fréquence', svWeekday: 'Jour', svTime: 'Heure', svHours: 'Heures',
everyWeek: 'Chaque semaine', everyDay: 'Chaque jour', everyHours: 'Toutes les N heures', hoursUnit: 'heures',
svMax: 'Résultats gardés', svEnabled: 'Active', svEnabledHelp: 'Exécuter selon la fréquence',
svHintNew: (tz) => `La recherche est lancée une première fois tout de suite pour servir de référence, puis selon la fréquence (fuseau du serveur : ${tz}).`,
svHintEdit: (tz) => `Fuseau du serveur : ${tz}. Pour changer les filtres, ouvrez la recherche, ajustez-la et programmez-la à nouveau.`,
saved: 'Recherche programmée', updated2: 'Enregistré',
err_not_found: 'Introuvable',
},
};
/* ================= Utilities ================= */
const $ = (sel) => document.querySelector(sel);
const store = {
get(k, def) { try { const v = localStorage.getItem('searchgit.' + k); return v === null ? def : v; } catch { return def; } },
set(k, v) { try { localStorage.setItem('searchgit.' + k, v); } catch { /* storage unavailable */ } },
};
const defaultLang = (navigator.language || '').toLowerCase().startsWith('fr') ? 'fr' : 'en';
let lang = I18N[store.get('lang', defaultLang)] ? store.get('lang', defaultLang) : 'en';
function t(key, arg) {
const v = I18N[lang][key] ?? I18N.en[key] ?? key;
return typeof v === 'function' ? v(arg) : v;
}
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
const debounce = (fn, ms) => { let tm; return (...a) => { clearTimeout(tm); tm = setTimeout(() => fn(...a), ms); }; };
const fmtNum = (n) => Number(n || 0).toLocaleString(t('locale'));
const fmtCompact = (n) => new Intl.NumberFormat(t('locale'), { notation: 'compact', maximumFractionDigits: 1 }).format(n || 0);
const fmtDate = (s) => new Date(s).toLocaleDateString(t('locale'), { year: 'numeric', month: 'short', day: 'numeric' });
function fmtAgo(s) {
const sec = (new Date(s) - Date.now()) / 1000;
const rtf = new Intl.RelativeTimeFormat(t('locale'), { numeric: 'auto' });
const steps = [[60, 'second'], [3600, 'minute'], [86400, 'hour'], [2592000, 'day'], [31536000, 'month'], [Infinity, 'year']];
const div = { second: 1, minute: 60, hour: 3600, day: 86400, month: 2592000, year: 31536000 };
for (const [limit, unit] of steps) {
if (Math.abs(sec) < limit) return rtf.format(Math.round(sec / div[unit]), unit);
}
return '';
}
async function api(url, opts) {
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
if (!res.ok) throw apiError(res, text, data);
return data;
}
// Known error codes are translated; otherwise the server message is shown.
function apiError(res, text, data) {
let msg = (data && data.error) || text || res.statusText;
if (data && data.code && I18N[lang]['err_' + data.code]) {
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
}
return new Error(msg);
}
function toast(msg) {
const el = $('#toast');
const dlg = document.querySelector('dialog[open]');
(dlg || document.body).append(el);
el.textContent = msg;
el.hidden = false;
clearTimeout(toast.tm);
toast.tm = setTimeout(() => { el.hidden = true; }, 1800);
}
async function copy(text) {
try { await navigator.clipboard.writeText(text); toast(t('copied')); } catch { /* clipboard refused */ }
}
/* ================= Languages (GitHub linguist colors) ================= */
const LANGS = {
'Go': '#00ADD8', 'Rust': '#dea584', 'Python': '#3572A5', 'JavaScript': '#f1e05a', 'TypeScript': '#3178c6',
'Java': '#b07219', 'Kotlin': '#A97BFF', 'C': '#555555', 'C++': '#f34b7d', 'C#': '#178600',
'PHP': '#4F5D95', 'Ruby': '#701516', 'Swift': '#F05138', 'Dart': '#00B4AB', 'Scala': '#c22d40',
'Elixir': '#6e4a7e', 'Haskell': '#5e5086', 'Lua': '#000080', 'Zig': '#ec915c', 'Nim': '#ffc200',
'Shell': '#89e051', 'PowerShell': '#012456', 'HTML': '#e34c26', 'CSS': '#663399', 'Vue': '#41b883',
'Svelte': '#ff3e00', 'Jupyter Notebook': '#DA5B0B', 'R': '#198CE7', 'Julia': '#a270ba', 'OCaml': '#ef7a08',
'Clojure': '#db5855', 'Erlang': '#B83998', 'Perl': '#0298c3', 'Nix': '#7e7eff', 'HCL': '#844FBA',
'Dockerfile': '#384d54', 'Vim Script': '#199f4b', 'Emacs Lisp': '#c065db',
};
{
const sel = $('#language');
for (const name of Object.keys(LANGS).sort((a, b) => a.localeCompare(b))) sel.add(new Option(name, name));
}
/* ================= State and filters ================= */
const FILTERS = ['q', 'in', 'language', 'stars', 'pushed', 'created', 'license', 'topic', 'sort'];
const TOGGLES = ['goodfirst', 'forks', 'archived'];
const state = {
items: [],
total: 0,
page: 1,
perPage: Number(store.get('perPage', '30')) || 30,
reqId: 0,
open: new Set(), // fullName of expanded rows
token: null,
rate: null,
query: '',
};
function readFilters() {
const f = {};
for (const k of FILTERS) f[k] = $('#' + k).value.trim();
for (const k of TOGGLES) f[k] = $('#' + k).getAttribute('aria-pressed') === 'true';
return f;
}
function writeFilters(f) {
for (const k of FILTERS) {
const el = $('#' + k);
let v = f[k] || '';
if (el.tagName === 'SELECT' && v && ![...el.options].some((o) => o.value === v)) {
el.add(new Option(v, v)); // e.g. a language missing from the list
}
el.value = v;
}
for (const k of TOGGLES) $('#' + k).setAttribute('aria-pressed', String(!!f[k]));
markSet();
}
// Highlight the filters that are not at their default value.
function markSet() {
let any = false;
for (const k of FILTERS) {
if (k === 'q' || k === 'sort') continue;
const el = $('#' + k);
const on = !!el.value;
el.classList.toggle('set', on);
any ||= on;
}
any ||= TOGGLES.some((k) => $('#' + k).getAttribute('aria-pressed') === 'true');
$('#resetBtn').hidden = !any;
}
function params(f, page) {
const p = new URLSearchParams();
for (const k of FILTERS) if (f[k]) p.set(k, f[k]);
for (const k of TOGGLES) if (f[k]) p.set(k, '1');
if (page) {
p.set('page', page);
p.set('per_page', state.perPage);
}
return p;
}
// Filters live in the URL so a search can be bookmarked or shared.
function syncURL(f) {
const qs = params(f).toString();
history.replaceState(null, '', qs ? '?' + qs : location.pathname);
store.set('filters', qs);
}
function filtersFromURL() {
return filtersFromParams(location.search || store.get('filters', ''));
}
function filtersFromParams(qs) {
const src = new URLSearchParams(qs);
const f = {};
for (const k of FILTERS) f[k] = src.get(k) || '';
for (const k of TOGGLES) f[k] = src.get(k) === '1';
return f;
}
/* ================= Search ================= */
async function search(more = false) {
const f = readFilters();
if (!more) {
syncURL(f);
state.page = 1;
}
const page = more ? state.page + 1 : 1;
const id = ++state.reqId;
document.body.classList.add('busy');
$('#moreBtn').disabled = true;
try {
const res = await api('/api/search?' + params(f, page));
if (id !== state.reqId) return;
setError('');
state.page = page;
state.total = res.total;
state.query = res.query;
if (res.rate && res.rate.limit) state.rate = res.rate;
if (more) {
const known = new Set(state.items.map((r) => r.fullName));
state.items.push(...res.items.filter((r) => !known.has(r.fullName)));
} else {
state.items = res.items;
state.open.clear();
window.scrollTo({ top: 0 });
}
renderList();
renderStatus();
} catch (e) {
if (id !== state.reqId) return;
setError(t('searchError') + e.message);
if (!more) { state.items = []; state.total = 0; renderList(); }
} finally {
if (id === state.reqId) document.body.classList.remove('busy');
$('#moreBtn').disabled = false;
}
}
function setError(msg) {
$('#error').textContent = msg;
$('#error').hidden = !msg;
}
/* ================= Rendering ================= */
const ICON_STAR = '';
const ICON_FORK = '';
const ICON_CLOCK = '';
const ICON_EXT = '';
function rowHTML(r, i) {
const open = state.open.has(r.fullName);
const review = r.rank !== undefined; // snapshot of a scheduled search
const badges = (review && r.new ? `${esc(t('badgeNew'))}` : '')
+ (r.archived ? `${esc(t('archived'))}` : '')
+ (r.fork ? `${esc(t('fork'))}` : '');
const topics = r.topics.length
? `
${r.topics.map((x) => ``).join('')}
`
: '';
const lang = r.language
? `${esc(r.language)}`
: '';
const delta = !review ? ''
: r.starsDelta ? `${r.starsDelta > 0 ? '+' : ''}${esc(fmtCompact(r.starsDelta))} ★`
: '';
return `
${r.description ? `
${esc(r.description)}
` : ''}
${topics}
${delta}${lang}
${ICON_STAR}${fmtCompact(r.stars)}
${ICON_FORK}${fmtCompact(r.forks)}
${ICON_CLOCK}${esc(fmtAgo(r.pushedAt))}
${open ? detailsHTML(r) : ''}
`;
}
function detailsHTML(r) {
const clone = r.url + '.git';
return `
- ${esc(t('fFull'))}
- ${esc(r.fullName)}
- ${esc(t('fStars'))}
- ${esc(fmtNum(r.stars))}
- ${esc(t('fForks'))}
- ${esc(fmtNum(r.forks))}
- ${esc(t('fIssues'))}
- ${esc(fmtNum(r.issues))}
- ${esc(t('fLicense'))}
- ${esc(r.license || t('noLicense'))}
- ${esc(t('fCreated'))}
- ${esc(fmtDate(r.createdAt))}
- ${esc(t('fPushed'))}
- ${esc(fmtDate(r.pushedAt))} (${esc(fmtAgo(r.pushedAt))})
- ${esc(t('fClone'))}
- ${esc(clone)}
`;
}
function renderList() {
$('#list').innerHTML = state.items.map(rowHTML).join('');
$('#empty').hidden = state.items.length > 0 || !$('#error').hidden;
const reachable = Math.min(state.total, 1000);
const left = reachable - state.items.length;
$('#more').hidden = left <= 0;
$('#moreBtn').textContent = t('moreN', fmtNum(Math.min(left, state.perPage)));
const c = $('#count');
c.textContent = state.items.length || state.total ? t('count', { shown: fmtNum(state.items.length), total: fmtNum(state.total) }) : '';
c.title = state.total > 1000 ? t('countCapped') : '';
}
// Last known quota; once the reset time has passed, GitHub has refilled it.
function currentRate() {
const r = state.rate;
if (!r || !r.limit) return null;
const s = Math.round((new Date(r.reset) - Date.now()) / 1000);
return s > 0 ? { ...r, s } : { ...r, remaining: r.limit, s: 0 };
}
function renderStatus() {
const conn = $('#conn');
if (state.token === null) {
conn.className = 'conn ko';
conn.textContent = t('unreachable');
} else {
const r = currentRate();
let txt = t(state.token ? 'connToken' : 'connNoToken');
let cls = state.token ? 'ok' : 'warn';
if (r) {
txt += ' · ' + t('quota', { r: r.remaining, l: r.limit });
if (r.remaining === 0) { cls = 'ko'; txt += ' · ' + t('quotaReset', r.s); }
}
conn.className = 'conn ' + cls;
conn.textContent = txt;
}
const a = $('#ghQuery');
a.hidden = !state.query;
if (state.query) {
a.textContent = state.query;
a.title = t('ghQueryTitle');
a.href = 'https://github.com/search?type=repositories&q=' + encodeURIComponent(state.query);
}
renderGitHubSettings();
}
async function loadStatus() {
try {
const s = await api('/api/status');
state.token = s.token;
state.tz = s.tz || '';
if (s.rate && s.rate.limit) state.rate = s.rate;
} catch { state.token = null; }
renderStatus();
}
/* ================= List interactions ================= */
// The search list and the review list share the same rows; items() returns
// the array the data-i indexes refer to.
function listEvents(listEl, items) {
listEl.addEventListener('click', (ev) => {
const rowEl = ev.target.closest('.row');
if (!rowEl) return;
const r = items()[Number(rowEl.dataset.i)];
const topic = ev.target.closest('[data-topic]');
if (topic) {
filterFromList({ topic: topic.dataset.topic });
return;
}
if (ev.target.closest('[data-act="lang"]')) {
filterFromList({ language: r.language });
return;
}
const cp = ev.target.closest('[data-copy]');
if (cp) { copy(cp.dataset.copy); return; }
if (ev.target.closest('a, .details')) return;
toggleRow(listEl, rowEl, r);
});
listEl.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' || !ev.target.classList.contains('row')) return;
toggleRow(listEl, ev.target, items()[Number(ev.target.dataset.i)]);
});
}
listEvents($('#list'), () => state.items);
// A click on a topic or a language filters the search (from the review too).
function filterFromList(change) {
const base = view === 'search' ? readFilters() : review.search ? filtersFromParams(review.search.params) : {};
writeFilters({ ...base, ...change });
state.searchPending = false;
if (view !== 'search') location.hash = '';
onFilterChange();
}
function toggleRow(listEl, rowEl, r) {
if (state.open.has(r.fullName)) state.open.delete(r.fullName);
else state.open.add(r.fullName);
const i = Number(rowEl.dataset.i);
rowEl.outerHTML = rowHTML(r, i);
listEl.querySelector(`.row[data-i="${i}"]`).focus({ preventScroll: true });
}
/* ================= Filter events ================= */
const onFilterChange = () => { markSet(); search(); };
const onTyping = debounce(onFilterChange, 500); // spare the GitHub quota while typing
for (const k of FILTERS) {
const el = $('#' + k);
el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', el.tagName === 'SELECT' ? onFilterChange : onTyping);
}
for (const k of TOGGLES) {
$('#' + k).addEventListener('click', (ev) => {
const b = ev.currentTarget;
b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));
onFilterChange();
});
}
$('#searchForm').addEventListener('submit', (ev) => { ev.preventDefault(); onFilterChange(); });
$('#topic').addEventListener('keydown', (ev) => { if (ev.key === 'Enter') onFilterChange(); });
$('#resetBtn').addEventListener('click', () => {
const f = readFilters();
writeFilters({ q: f.q, sort: f.sort });
onFilterChange();
});
$('#moreBtn').addEventListener('click', () => search(true));
document.addEventListener('keydown', (ev) => {
if (ev.key === '/' && view === 'search' && !ev.target.closest('input, select, textarea') && !document.querySelector('dialog[open]')) {
ev.preventDefault();
$('#q').focus();
$('#q').select();
}
});
/* ================= CSV export ================= */
$('#exportBtn').addEventListener('click', () => {
if (!state.items.length) { toast(t('exportNone')); return; }
// French Excel expects semicolons.
const sep = lang === 'fr' ? ';' : ',';
const cell = (v) => {
const s = String(v ?? '');
return /["\n\r;,]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const head = ['repository', 'url', 'description', 'language', 'stars', 'forks', 'open_issues', 'license', 'topics', 'archived', 'fork', 'created', 'pushed'];
const lines = [head.join(sep)];
for (const r of state.items) {
lines.push([r.fullName, r.url, r.description, r.language, r.stars, r.forks, r.issues, r.license,
r.topics.join(' '), r.archived, r.fork, r.createdAt, r.pushedAt].map(cell).join(sep));
}
const name = 'searchgit-' + new Date().toISOString().slice(0, 10) + '.csv';
const blob = new Blob(['' + lines.join('\r\n') + '\r\n'], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
toast(t('exportDone', name));
});
/* ================= Language ================= */
function applyLang() {
document.documentElement.lang = lang;
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh);
for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang));
}
function setLang(next) {
if (next === lang || !I18N[next]) return;
lang = next;
store.set('lang', lang);
applyLang();
renderList();
renderStatus();
renderSaved();
renderReview();
}
$('#langSwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-lang]');
if (b) setLang(b.dataset.lang);
});
/* ================= Theme ================= */
function effectiveTheme() {
const th = document.documentElement.dataset.theme;
if (th) return th;
return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// mode: 'system' (follow the OS), 'light' or 'dark'.
function setTheme(mode) {
if (mode === 'light' || mode === 'dark') document.documentElement.dataset.theme = mode;
else { delete document.documentElement.dataset.theme; mode = 'system'; }
store.set('theme', mode);
renderInterface();
}
$('#themeBtn').addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark'));
$('#themeSwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-theme-opt]');
if (b) setTheme(b.dataset.themeOpt);
});
/* ================= Interface settings ================= */
function applyDensity(d) {
if (d !== 'compact') d = 'comfortable';
document.documentElement.dataset.density = d;
store.set('density', d);
}
function renderInterface() {
const mode = document.documentElement.dataset.theme || 'system';
for (const b of document.querySelectorAll('#themeSwitch [data-theme-opt]')) b.setAttribute('aria-checked', String(b.dataset.themeOpt === mode));
for (const b of document.querySelectorAll('#densitySwitch [data-density]')) b.setAttribute('aria-checked', String(b.dataset.density === document.documentElement.dataset.density));
for (const b of document.querySelectorAll('#perPageSwitch [data-per-page]')) b.setAttribute('aria-checked', String(Number(b.dataset.perPage) === state.perPage));
}
$('#densitySwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-density]');
if (!b) return;
applyDensity(b.dataset.density);
renderInterface();
});
$('#perPageSwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-per-page]');
if (!b) return;
state.perPage = Number(b.dataset.perPage);
store.set('perPage', String(state.perPage));
renderInterface();
search();
});
function renderGitHubSettings() {
const tok = $('#tokenStatus');
tok.className = 'state-line ' + (state.token ? 'good' : 'warn');
tok.textContent = t(state.token ? 'tokenOn' : 'tokenOff');
const q = $('#quotaStatus');
const r = currentRate();
if (r) {
q.className = 'state-line ' + (r.remaining > 0 ? 'good' : 'bad');
q.textContent = t(r.s ? 'quotaLine' : 'quotaFull', { r: r.remaining, l: r.limit, s: r.s });
} else {
q.className = 'state-line';
q.textContent = t('quotaUnknown');
}
}
/* ================= Settings dialog ================= */
function showSettingsTab(name) {
const tabs = [...document.querySelectorAll('#setNav [data-tab]')];
if (!tabs.some((b) => b.dataset.tab === name)) name = 'locale';
for (const b of tabs) b.setAttribute('aria-selected', String(b.dataset.tab === name));
for (const p of document.querySelectorAll('.set-panel')) p.hidden = p.dataset.panel !== name;
store.set('settingsTab', name);
}
$('#setNav').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-tab]');
if (b) showSettingsTab(b.dataset.tab);
});
$('#settingsBtn').addEventListener('click', () => {
renderInterface();
loadStatus();
showSettingsTab(store.get('settingsTab', 'locale'));
$('#settingsDlg').showModal();
});
$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close());
$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); });
/* ================= Views ================= */
// # search
// #saved scheduled searches
// #saved/ review of one scheduled search (#saved// for an older run)
let view = 'search';
function route() {
const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean);
if (parts[0] === 'saved' && parts[1]) {
view = 'review';
openReview(decodeURIComponent(parts[1]), parts[2] ? decodeURIComponent(parts[2]) : 'latest');
} else if (parts[0] === 'saved') {
view = 'saved';
loadSaved();
} else {
view = 'search';
if (state.searchPending) { state.searchPending = false; search(); }
}
document.body.dataset.view = view;
$('#viewSearch').hidden = view !== 'search';
$('#viewSaved').hidden = view !== 'saved';
$('#viewReview').hidden = view !== 'review';
for (const a of document.querySelectorAll('#views [data-view]')) {
const cur = a.dataset.view === (view === 'review' ? 'saved' : view);
if (cur) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
}
window.scrollTo({ top: 0 });
}
window.addEventListener('hashchange', route);
/* ================= Scheduled searches ================= */
const ICON_PLAY = '';
const ICON_EDIT = '';
const ICON_PAUSE = '';
const ICON_TRASH = '';
const ICON_UP = '';
const ICON_SPIN = '';
const saved = { list: [], loaded: false, running: new Set() };
const pad2 = (n) => String(n).padStart(2, '0');
const dayName = (d) => new Intl.DateTimeFormat(t('locale'), { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, 0, 1 + d)));
const fmtDateTime = (s) => new Date(s).toLocaleString(t('locale'), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
function schedText(sc) {
const tm = pad2(sc.hour) + ':' + pad2(sc.minute);
if (sc.every === 'hours') return t('schedHours', sc.hours);
if (sc.every === 'day') return t('schedDay', tm);
return t('schedWeek', { d: dayName(sc.weekday), tm });
}
// Readable GitHub query of saved parameters (computed by the server on each run).
function savedQuery(x) {
return (x.lastRun && x.lastRun.query) || new URLSearchParams(x.params).toString().replace(/&/g, ' ');
}
async function loadSaved() {
try {
saved.list = await api('/api/saved');
saved.loaded = true;
$('#savedError').hidden = true;
} catch (e) {
$('#savedError').textContent = e.message;
$('#savedError').hidden = false;
}
renderSaved();
}
function renderSaved() {
const n = saved.list.reduce((a, x) => a + (x.enabled && x.lastRun && !x.lastRun.error ? x.lastRun.new : 0), 0);
const pill = $('#newTotal');
pill.hidden = !n;
pill.textContent = n;
pill.title = n ? t('newTotal', n) : '';
if (!saved.loaded) return;
$('#savedCount').textContent = saved.list.length ? t('savedCount', saved.list.length) : '';
$('#savedEmpty').hidden = saved.list.length > 0 || !$('#savedError').hidden;
$('#savedList').innerHTML = saved.list.map((x) => {
const lr = x.lastRun;
let stats = '';
if (saved.running.has(x.id)) stats = `${ICON_SPIN}${esc(t('running'))}`;
else if (lr && lr.error) stats = `${esc(t('statErr'))}`;
else if (lr) {
stats = (lr.new ? `${esc(t('statNew', lr.new))}` : '')
+ (lr.rising ? `${ICON_UP}${esc(t('statRising', lr.rising))}` : '')
+ `${esc(t('statCount', lr.count))}`;
}
const when = [
`${ICON_CLOCK}${esc(schedText(x.schedule))}`,
x.enabled ? (x.nextRunAt ? `${esc(t('nextRun', fmtAgo(x.nextRunAt)))}` : '') : `${esc(t('paused'))}`,
`${esc(x.lastRunAt ? t('lastRun', fmtAgo(x.lastRunAt)) : t('neverRun'))}`,
].join('');
return `
${esc(x.name)}
${esc(savedQuery(x))}
${when}
${stats}
`;
}).join('');
}
$('#savedList').addEventListener('click', (ev) => {
const row = ev.target.closest('.srow');
if (!row) return;
const x = saved.list.find((s) => s.id === row.dataset.id);
const act = ev.target.closest('[data-act]');
if (!act) { location.hash = '#saved/' + encodeURIComponent(x.id); return; }
if (act.dataset.act === 'run') runSaved(x.id);
else if (act.dataset.act === 'edit') openSaveDlg(x);
else if (act.dataset.act === 'delete') deleteSaved(x);
else if (act.dataset.act === 'toggle') putSaved(x, { enabled: !x.enabled });
});
$('#savedList').addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && ev.target.classList.contains('srow')) location.hash = '#saved/' + encodeURIComponent(ev.target.dataset.id);
});
async function runSaved(id) {
saved.running.add(id);
renderSaved();
$('#revRunBtn').disabled = true;
try {
const info = await api('/api/saved/' + encodeURIComponent(id) + '/run', { method: 'POST' });
toast(info.error ? t('runFailed') + info.error : t('runDone', { n: info.new, r: info.rising }));
} catch (e) {
toast(t('runFailed') + e.message);
} finally {
saved.running.delete(id);
$('#revRunBtn').disabled = false;
}
await loadSaved();
if (view === 'review' && review.id === id) openReview(id, 'latest');
}
async function putSaved(x, change) {
const body = { name: x.name, params: x.params, schedule: x.schedule, maxResults: x.maxResults, enabled: x.enabled, ...change };
try {
await api('/api/saved/' + encodeURIComponent(x.id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
toast(t('updated2'));
} catch (e) {
toast(e.message);
}
await loadSaved();
if (view === 'review') renderReviewHead();
}
async function deleteSaved(x) {
if (!confirm(t('confirmDelete', x.name))) return false;
try {
await fetch('/api/saved/' + encodeURIComponent(x.id), { method: 'DELETE' });
toast(t('deleted'));
} catch (e) {
toast(e.message);
return false;
}
await loadSaved();
return true;
}
/* ---------- Review of one scheduled search ---------- */
const review = { id: '', search: null, runs: [], run: null, mode: '', items: [], req: 0 };
listEvents($('#revList'), () => review.items);
async function openReview(id, runID) {
const req = ++review.req;
if (review.id !== id) { review.mode = ''; review.run = null; review.runs = []; review.search = null; }
review.id = id;
document.body.classList.add('busy');
try {
if (!saved.loaded) await loadSaved();
const [x, runs] = await Promise.all([
api('/api/saved/' + encodeURIComponent(id)),
api('/api/saved/' + encodeURIComponent(id) + '/runs'),
]);
let run = null;
if (runs.length) {
try { run = await api('/api/saved/' + encodeURIComponent(id) + '/runs/' + encodeURIComponent(runID)); } catch { /* pruned run */ }
}
if (req !== review.req) return;
Object.assign(review, { search: x, runs, run });
if (!review.mode || runID === 'latest') {
review.mode = run && run.new ? 'new' : run && run.rising ? 'rising' : 'all';
}
$('#revError').hidden = true;
} catch (e) {
if (req !== review.req) return;
$('#revError').textContent = e.message;
$('#revError').hidden = false;
} finally {
if (req === review.req) document.body.classList.remove('busy');
}
state.open.clear();
renderReview();
}
function renderReviewHead() {
const x = saved.list.find((s) => s.id === review.id) || review.search;
if (!x) return;
review.search = x;
$('#revName').textContent = x.name;
$('#revSched').textContent = schedText(x.schedule) + (x.enabled
? (x.nextRunAt ? ' · ' + t('nextRun', fmtDateTime(x.nextRunAt)) : '')
: ' · ' + t('paused'));
}
function renderReview() {
if (view !== 'review') return;
renderReviewHead();
const run = review.run;
const sel = $('#runSel');
sel.innerHTML = review.runs.map((r) => ``).join('');
sel.hidden = !review.runs.length;
if (run) sel.value = run.id;
const items = run ? run.items : [];
const lists = {
new: items.filter((r) => r.new),
rising: items.filter((r) => r.starsDelta > 0).sort((a, b) => b.starsDelta - a.starsDelta || b.stars - a.stars),
all: items,
};
for (const b of document.querySelectorAll('#revMode [data-mode]')) {
const m = b.dataset.mode;
b.textContent = t('mode' + m[0].toUpperCase() + m.slice(1), fmtNum(lists[m].length));
b.setAttribute('aria-checked', String(m === review.mode));
}
$('#revMode').hidden = !run || !!run.error;
review.items = run && !run.error ? lists[review.mode] || [] : [];
const info = $('#revInfo');
info.textContent = run && !run.error ? t('revInfo', { d: fmtDateTime(run.at), total: fmtNum(run.total), q: run.query }) : '';
if (run && run.error) {
$('#revError').textContent = t('runFailed') + run.error;
$('#revError').hidden = false;
} else if (run) {
$('#revError').hidden = true;
}
$('#revList').classList.add('review');
$('#revList').innerHTML = review.items.map(rowHTML).join('');
const empty = !review.items.length && !(run && run.error);
$('#revEmpty').hidden = !empty;
if (empty) {
const first = review.runs.filter((r) => !r.error).length <= 1;
const key = !run ? 'revNoRun'
: review.mode === 'new' ? (first ? 'revEmptyFirst' : 'revEmptyNew')
: review.mode === 'rising' ? (first ? 'revEmptyFirst' : 'revEmptyRising') : 'revEmptyAll';
$('#revEmptyTitle').textContent = t(key);
$('#revEmptyHint').textContent = t(key + 'Hint');
}
}
$('#revMode').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-mode]');
if (!b) return;
review.mode = b.dataset.mode;
state.open.clear();
renderReview();
});
$('#runSel').addEventListener('change', () => {
const latest = review.runs[0] && review.runs[0].id === $('#runSel').value;
location.hash = '#saved/' + encodeURIComponent(review.id) + (latest ? '' : '/' + encodeURIComponent($('#runSel').value));
});
$('#revRunBtn').addEventListener('click', () => runSaved(review.id));
$('#revEditBtn').addEventListener('click', () => review.search && openSaveDlg(review.search));
$('#revOpenBtn').addEventListener('click', () => {
if (!review.search) return;
writeFilters(filtersFromParams(review.search.params));
state.searchPending = false;
location.hash = '';
onFilterChange();
});
/* ---------- Create / edit dialog ---------- */
const dlgState = { edit: null, params: '', max: 50 };
{
const sel = $('#svWeekday');
for (const d of [1, 2, 3, 4, 5, 6, 0]) sel.add(new Option('', String(d)));
}
function renderSaveDlgLabels() {
for (const o of $('#svWeekday').options) o.textContent = dayName(Number(o.value));
for (const b of document.querySelectorAll('#svMax [data-max]')) b.setAttribute('aria-checked', String(Number(b.dataset.max) === dlgState.max));
const every = $('#svEvery').value;
$('#svWeekday').hidden = every !== 'week';
$('#svTime').hidden = every === 'hours';
$('#svHoursWrap').hidden = every !== 'hours';
}
// A readable default name: the search words, else the topic or the language.
function defaultName(f) {
const bits = [f.q, f.topic && 'topic:' + f.topic, f.language].filter(Boolean);
return bits.join(' · ') || t('savedTitle');
}
function openSaveDlg(x) {
dlgState.edit = x || null;
let sc = { every: 'week', weekday: 1, hour: 8, minute: 0, hours: 24 };
if (x) {
dlgState.params = x.params;
dlgState.max = x.maxResults;
sc = { ...sc, ...x.schedule };
$('#svName').value = x.name;
$('#svQuery').textContent = savedQuery(x);
$('#svEnabled').checked = x.enabled;
} else {
const f = readFilters();
dlgState.params = params(f).toString();
dlgState.max = 50;
$('#svName').value = defaultName(f);
$('#svQuery').textContent = state.query || dlgState.params;
$('#svEnabled').checked = true;
}
$('#svEvery').value = sc.every;
$('#svWeekday').value = String(sc.weekday || 0);
$('#svTime').value = pad2(sc.hour) + ':' + pad2(sc.minute);
$('#svHours').value = sc.hours || 24;
$('#saveTitle').textContent = t(x ? 'saveEditTitle' : 'saveNewTitle');
$('#svHint').textContent = t(x ? 'svHintEdit' : 'svHintNew', state.tz || 'UTC');
$('#svDelete').hidden = !x;
renderSaveDlgLabels();
$('#saveDlg').showModal();
$('#svName').select();
}
$('#svEvery').addEventListener('change', renderSaveDlgLabels);
$('#svMax').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-max]');
if (!b) return;
dlgState.max = Number(b.dataset.max);
renderSaveDlgLabels();
});
$('#closeSave').addEventListener('click', () => $('#saveDlg').close());
$('#svCancel').addEventListener('click', () => $('#saveDlg').close());
$('#saveDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); });
$('#svDelete').addEventListener('click', async () => {
if (dlgState.edit && await deleteSaved(dlgState.edit)) {
$('#saveDlg').close();
if (view === 'review') location.hash = '#saved';
}
});
$('#saveForm').addEventListener('submit', async (ev) => {
ev.preventDefault();
const [h, m] = ($('#svTime').value || '08:00').split(':').map(Number);
const body = {
name: $('#svName').value.trim(),
params: dlgState.params,
maxResults: dlgState.max,
enabled: $('#svEnabled').checked,
schedule: { every: $('#svEvery').value, weekday: Number($('#svWeekday').value), hour: h || 0, minute: m || 0, hours: Number($('#svHours').value) || 24 },
};
const x = dlgState.edit;
$('#svSubmit').disabled = true;
try {
const res = await api(x ? '/api/saved/' + encodeURIComponent(x.id) : '/api/saved', {
method: x ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
$('#saveDlg').close();
toast(t(x ? 'updated2' : 'saved'));
if (x) {
await loadSaved();
if (view === 'review') renderReview();
} else {
saved.running.add(res.id); // the server runs it once right away
location.hash = '#saved';
setTimeout(async () => { saved.running.delete(res.id); await loadSaved(); }, 4000);
}
} catch (e) {
toast(e.message);
} finally {
$('#svSubmit').disabled = false;
}
});
$('#saveBtn').addEventListener('click', () => openSaveDlg());
// Keep the list fresh (next run countdown, runs made by the scheduler).
setInterval(() => {
if (document.hidden) return;
if (view === 'saved') loadSaved();
}, 30000);
loadSaved();
/* ================= Startup ================= */
applyDensity(store.get('density', 'comfortable'));
applyLang();
renderInterface();
writeFilters(filtersFromURL());
loadStatus();
route();
if (view === 'search') search();
else state.searchPending = true;
setInterval(() => { if (!document.hidden) renderStatus(); }, 5000); // reset countdown