A search can now be saved with a schedule (every N hours, daily, or weekly at a given day and time). An internal scheduler runs it and keeps a snapshot of the results on a /data volume (JSON files, no new dependency). The new Watch tab lists the scheduled searches; the review page of each one shows, for any run of its history, the repositories never seen before and the ones that gained the most stars since the previous run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1164 lines
54 KiB
JavaScript
1164 lines
54 KiB
JavaScript
'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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 2.7 5.6 6.1.8-4.5 4.3 1.1 6.1L12 16.9l-5.4 2.9 1.1-6.1-4.5-4.3 6.1-.8z"/></svg>';
|
||
const ICON_FORK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><path d="M6 7v2a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V7M12 12v5"/></svg>';
|
||
const ICON_CLOCK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||
const ICON_EXT = '<svg viewBox="0 0 24 24" aria-hidden="true" width="14" height="14"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></svg>';
|
||
|
||
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 ? `<span class="badge new">${esc(t('badgeNew'))}</span>` : '')
|
||
+ (r.archived ? `<span class="badge archived">${esc(t('archived'))}</span>` : '')
|
||
+ (r.fork ? `<span class="badge">${esc(t('fork'))}</span>` : '');
|
||
const topics = r.topics.length
|
||
? `<div class="topics">${r.topics.map((x) => `<button type="button" class="topic" data-topic="${esc(x)}" title="${esc(t('filterTopic'))}">${esc(x)}</button>`).join('')}</div>`
|
||
: '';
|
||
const lang = r.language
|
||
? `<span class="lang" data-act="lang" title="${esc(t('filterLang'))}" style="cursor:pointer"><i class="lang-dot" style="--c:${LANGS[r.language] || 'var(--muted)'}"></i>${esc(r.language)}</span>`
|
||
: '<span></span>';
|
||
const delta = !review ? ''
|
||
: r.starsDelta ? `<span class="delta${r.starsDelta < 0 ? ' down' : ''}" title="${esc(t('deltaTitle'))}">${r.starsDelta > 0 ? '+' : ''}${esc(fmtCompact(r.starsDelta))} ★</span>`
|
||
: '<span></span>';
|
||
return `<div class="row${open ? ' open' : ''}${review && r.new ? ' is-new' : ''}" tabindex="0" data-i="${i}">
|
||
<img class="av" src="${esc(r.avatar)}${r.avatar.includes('?') ? '&' : '?'}s=72" alt="" loading="lazy">
|
||
<div class="main">
|
||
<div class="name"><a href="${esc(r.url)}" target="_blank" rel="noopener"><span class="owner">${esc(r.owner)}/</span>${esc(r.name)}</a>${badges}</div>
|
||
${r.description ? `<p class="desc">${esc(r.description)}</p>` : ''}
|
||
${topics}
|
||
</div>
|
||
<div class="meta">
|
||
${delta}${lang}
|
||
<span class="stars" title="${esc(fmtNum(r.stars))} ${esc(t('fStars'))}">${ICON_STAR}${fmtCompact(r.stars)}</span>
|
||
<span title="${esc(fmtNum(r.forks))} ${esc(t('fForks'))}">${ICON_FORK}${fmtCompact(r.forks)}</span>
|
||
<span title="${esc(t('fPushed'))} ${esc(fmtDate(r.pushedAt))}">${ICON_CLOCK}${esc(fmtAgo(r.pushedAt))}</span>
|
||
</div>
|
||
${open ? detailsHTML(r) : ''}
|
||
</div>`;
|
||
}
|
||
|
||
function detailsHTML(r) {
|
||
const clone = r.url + '.git';
|
||
return `<div class="details">
|
||
<dl>
|
||
<dt>${esc(t('fFull'))}</dt><dd>${esc(r.fullName)}</dd>
|
||
<dt>${esc(t('fStars'))}</dt><dd>${esc(fmtNum(r.stars))}</dd>
|
||
<dt>${esc(t('fForks'))}</dt><dd>${esc(fmtNum(r.forks))}</dd>
|
||
<dt>${esc(t('fIssues'))}</dt><dd>${esc(fmtNum(r.issues))}</dd>
|
||
<dt>${esc(t('fLicense'))}</dt><dd>${esc(r.license || t('noLicense'))}</dd>
|
||
<dt>${esc(t('fCreated'))}</dt><dd>${esc(fmtDate(r.createdAt))}</dd>
|
||
<dt>${esc(t('fPushed'))}</dt><dd>${esc(fmtDate(r.pushedAt))} (${esc(fmtAgo(r.pushedAt))})</dd>
|
||
<dt>${esc(t('fClone'))}</dt><dd>${esc(clone)}</dd>
|
||
</dl>
|
||
<div class="acts">
|
||
<a class="btn" href="${esc(r.url)}" target="_blank" rel="noopener">${ICON_EXT}${esc(t('openGh'))}</a>
|
||
${r.homepage ? `<a class="btn" href="${esc(r.homepage)}" target="_blank" rel="noopener">${ICON_EXT}${esc(t('homepage'))}</a>` : ''}
|
||
<button class="btn" type="button" data-copy="${esc(clone)}">${esc(t('copyClone'))}</button>
|
||
<button class="btn" type="button" data-copy="${esc(r.fullName)}">${esc(t('copyName'))}</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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/<id> review of one scheduled search (#saved/<id>/<run> 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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4v16l13-8z"/></svg>';
|
||
const ICON_EDIT = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16zM14 6l4 4"/></svg>';
|
||
const ICON_PAUSE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14M16 5v14"/></svg>';
|
||
const ICON_TRASH = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>';
|
||
const ICON_UP = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 15 7-7 7 7"/></svg>';
|
||
const ICON_SPIN = '<svg class="spin" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.2-8.6"/></svg>';
|
||
|
||
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 = `<span class="stat">${ICON_SPIN}${esc(t('running'))}</span>`;
|
||
else if (lr && lr.error) stats = `<span class="stat err" title="${esc(lr.error)}">${esc(t('statErr'))}</span>`;
|
||
else if (lr) {
|
||
stats = (lr.new ? `<span class="stat new">${esc(t('statNew', lr.new))}</span>` : '')
|
||
+ (lr.rising ? `<span class="stat up">${ICON_UP}${esc(t('statRising', lr.rising))}</span>` : '')
|
||
+ `<span class="stat">${esc(t('statCount', lr.count))}</span>`;
|
||
}
|
||
const when = [
|
||
`<span>${ICON_CLOCK}${esc(schedText(x.schedule))}</span>`,
|
||
x.enabled ? (x.nextRunAt ? `<span>${esc(t('nextRun', fmtAgo(x.nextRunAt)))}</span>` : '') : `<span>${esc(t('paused'))}</span>`,
|
||
`<span>${esc(x.lastRunAt ? t('lastRun', fmtAgo(x.lastRunAt)) : t('neverRun'))}</span>`,
|
||
].join('');
|
||
return `<div class="srow${x.enabled ? '' : ' off'}" tabindex="0" data-id="${esc(x.id)}">
|
||
<div class="smain">
|
||
<div class="sname">${esc(x.name)}</div>
|
||
<div class="sq">${esc(savedQuery(x))}</div>
|
||
<div class="ssched">${when}</div>
|
||
</div>
|
||
<div class="sstats">${stats}</div>
|
||
<div class="sacts">
|
||
<button class="icon-btn" type="button" data-act="run" title="${esc(t('runNow'))}" aria-label="${esc(t('runNow'))}">${ICON_PLAY}</button>
|
||
<button class="icon-btn" type="button" data-act="toggle" title="${esc(t(x.enabled ? 'pause' : 'resume'))}" aria-label="${esc(t(x.enabled ? 'pause' : 'resume'))}">${x.enabled ? ICON_PAUSE : ICON_CLOCK}</button>
|
||
<button class="icon-btn" type="button" data-act="edit" title="${esc(t('edit'))}" aria-label="${esc(t('edit'))}">${ICON_EDIT}</button>
|
||
<button class="icon-btn" type="button" data-act="delete" title="${esc(t('delete'))}" aria-label="${esc(t('delete'))}">${ICON_TRASH}</button>
|
||
</div>
|
||
</div>`;
|
||
}).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) => `<option value="${esc(r.id)}">${esc(r.error ? t('runOptionErr', fmtDateTime(r.at)) : t('runOption', { d: fmtDateTime(r.at), n: r.new }))}</option>`).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
|