Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f91becb6eb | ||
|
|
2bdc09b5e0 | ||
|
|
541a730696 | ||
|
|
479e8a5903 | ||
|
|
5b2c369663 | ||
|
|
71729f8aa6 | ||
|
|
02b51fed77 |
No files matched your search
@@ -5,3 +5,6 @@ GITHUB_TOKEN=
|
||||
CACHE_TTL=5m
|
||||
AUTH_USER=
|
||||
AUTH_PASS=
|
||||
# Time zone of the scheduled searches.
|
||||
TZ=Europe/Paris
|
||||
HISTORY_KEEP=100
|
||||
@@ -28,3 +28,6 @@ go.work.sum
|
||||
# Binary
|
||||
/searchgit
|
||||
|
||||
|
||||
# Local data (scheduled searches)
|
||||
/data/
|
||||
+4
-1
@@ -11,9 +11,12 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/searchgit .
|
||||
# ---- Final image ----
|
||||
FROM alpine:3.24.2
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& adduser -D -H -u 10001 searchgit
|
||||
&& adduser -D -H -u 10001 searchgit \
|
||||
&& mkdir /data && chown searchgit:searchgit /data
|
||||
COPY --from=build /out/searchgit /usr/local/bin/searchgit
|
||||
USER searchgit
|
||||
ENV DATA_DIR=/data
|
||||
VOLUME /data
|
||||
EXPOSE 8080/tcp
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/searchgit"]
|
||||
@@ -1,70 +1,137 @@
|
||||
# Searchgit
|
||||
|
||||
Search GitHub repositories with criteria and browse the results in a clean web interface
|
||||
(light/dark theme, responsive, English/French UI). The interface reuses the look of
|
||||
[Logstream](https://gitea.vlab.bzh/cedric/logstream).
|
||||
Recherche de dépôts GitHub selon des critères, avec une interface web claire pour parcourir les
|
||||
résultats (thème clair/sombre, responsive, interface en anglais ou en français). L'interface reprend
|
||||
le style de [Logstream](https://gitea.vlab.bzh/cedric/logstream).
|
||||
|
||||
```
|
||||
browser ──/api/search──▶ searchgit (Go) ──REST──▶ api.github.com/search/repositories
|
||||
└── 5 min cache
|
||||
```
|
||||
## Architecture
|
||||
|
||||
## Getting started
|
||||

|
||||
|
||||
Le navigateur charge l'interface embarquée dans le binaire Go, puis appelle l'API `/api/*` du
|
||||
serveur. Le serveur interroge l'API GitHub Search à travers un cache mémoire de 5 minutes ; le
|
||||
planificateur relance les recherches programmées et enregistre leurs instantanés en JSON dans le
|
||||
volume `searchgit-data`. Le schéma se modifie dans
|
||||
[`docs/architecture.excalidraw`](docs/architecture.excalidraw) (excalidraw.com, menu Ouvrir).
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
```bash
|
||||
cp .env.example .env # optional: set GITHUB_TOKEN
|
||||
cp .env.example .env # facultatif : renseigner GITHUB_TOKEN
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Then open <http://localhost:8080>.
|
||||
Ouvrir ensuite <http://localhost:8080>.
|
||||
|
||||
Without Docker: `go run .` (Go 1.23 or newer), then open <http://localhost:8080>.
|
||||
Sans Docker : `go run .` (Go 1.24 ou plus récent, données dans `./data`), puis ouvrir
|
||||
<http://localhost:8080>.
|
||||
|
||||
## Criteria
|
||||
## Déploiement avec Docker Compose
|
||||
|
||||
| Filter | GitHub qualifier |
|
||||
Le fichier `docker-compose.yml` construit l'image à partir du `Dockerfile` et lance un seul
|
||||
conteneur `searchgit` :
|
||||
|
||||
- le port publié est `HTTP_PORT` (8080 par défaut), redirigé vers le port 8080 du conteneur ;
|
||||
- la configuration est lue dans `.env` (copie de `.env.example`) ;
|
||||
- les recherches programmées et leur historique sont stockés dans le volume nommé
|
||||
`searchgit-data`, monté sur `/data` : ils survivent aux redémarrages et aux mises à jour ;
|
||||
- le conteneur redémarre automatiquement (`restart: unless-stopped`).
|
||||
|
||||
Exemple de `.env` pour une instance exposée sur le port 8686 avec authentification :
|
||||
|
||||
```bash
|
||||
HTTP_PORT=8686
|
||||
GITHUB_TOKEN=github_pat_...
|
||||
AUTH_USER=admin
|
||||
AUTH_PASS=un-mot-de-passe
|
||||
TZ=Europe/Paris
|
||||
```
|
||||
|
||||
Mettre à jour une instance :
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Suivre les journaux : `docker compose logs -f searchgit`. L'état du service est exposé sur
|
||||
`/healthz` (sans authentification), utilisable pour une sonde de supervision.
|
||||
|
||||
## Critères
|
||||
|
||||
| Filtre | Qualificateur GitHub |
|
||||
|---|---|
|
||||
| Search field | free text, plus any GitHub qualifier (`user:`, `org:`, `NOT`, `"exact phrase"`, `size:`…) |
|
||||
| Everywhere / Name / Description / README | `in:name`, `in:description`, `in:readme` |
|
||||
| Language | `language:Go` (click a language in the list to filter on it) |
|
||||
| Stars | `stars:>=100` |
|
||||
| Activity | `pushed:>=YYYY-MM-DD` (1 month to 2 years) |
|
||||
| Creation date | `created:>=YYYY-MM-DD` |
|
||||
| License | `license:mit`… |
|
||||
| Topic | `topic:cli` (click a topic in the list to filter on it) |
|
||||
| Champ de recherche | texte libre, plus n'importe quel qualificateur GitHub (`user:`, `org:`, `NOT`, `"phrase exacte"`, `size:`…) |
|
||||
| Partout / Nom / Description / README | `in:name`, `in:description`, `in:readme` |
|
||||
| Langage | `language:Go` (cliquer sur un langage dans la liste pour filtrer dessus) |
|
||||
| Étoiles | `stars:>=100` |
|
||||
| Activité | `pushed:>=AAAA-MM-JJ` (de 1 semaine à 2 ans) |
|
||||
| Date de création | `created:>=AAAA-MM-JJ` |
|
||||
| Licence | `license:mit`… |
|
||||
| Topic | `topic:cli` (cliquer sur un topic dans la liste pour filtrer dessus) |
|
||||
| Good first issues | `good-first-issues:>0` |
|
||||
| Forks / Archived | forks and archived repositories are hidden unless enabled |
|
||||
| Sort | best match, stars, forks, recently updated, help wanted issues |
|
||||
| Forks / Archivés | les forks et les dépôts archivés sont masqués sauf s'ils sont activés |
|
||||
| Tri | pertinence, plus d'étoiles, plus de forks, mis à jour récemment, issues « help wanted » |
|
||||
|
||||
The filters are kept in the page URL, so a search can be bookmarked or shared. The footer
|
||||
shows the exact query sent to GitHub (click it to open the same search on github.com).
|
||||
**CSV** downloads the loaded results (semicolon separated when the UI is in French, for Excel).
|
||||
Les filtres sont conservés dans l'URL de la page : une recherche peut donc être ajoutée aux favoris
|
||||
ou partagée. Le pied de page affiche la requête exacte envoyée à GitHub (un clic ouvre la même
|
||||
recherche sur github.com). **CSV** télécharge les résultats chargés (séparateur point-virgule
|
||||
quand l'interface est en français, pour Excel).
|
||||
|
||||
GitHub returns at most the first 1,000 results of a search.
|
||||
GitHub ne renvoie au maximum que les 1 000 premiers résultats d'une recherche.
|
||||
|
||||
## Veille (recherches programmées)
|
||||
|
||||
**Programmer** enregistre les filtres en cours comme une recherche que le serveur exécute seul :
|
||||
chaque semaine (jour et heure), chaque jour, ou toutes les N heures, dans le fuseau horaire du
|
||||
serveur (`TZ`). Chaque passage conserve un instantané des 30, 50 ou 100 premiers résultats.
|
||||
|
||||
L'onglet **Veille** liste les recherches programmées avec leur dernier passage. Une page de revue
|
||||
par recherche affiche, pour n'importe quel passage de son historique :
|
||||
|
||||
- **Nouveautés** : les dépôts jamais renvoyés par un passage précédent de cette recherche ;
|
||||
- **Progressions** : les dépôts qui ont gagné le plus d'étoiles depuis le passage précédent ;
|
||||
- **Tous** : l'instantané complet, avec le gain d'étoiles de chaque dépôt.
|
||||
|
||||
Une recherche est exécutée une première fois dès sa création : ce premier passage sert de
|
||||
référence pour les suivants. Un passage manqué pendant l'arrêt du serveur est rattrapé au
|
||||
démarrage. Pour une revue hebdomadaire des « pépites », un bon point de départ : un topic ou un
|
||||
langage, *Créé < 1 mois* (ou *Créé < 1 semaine*), trié par *Plus d'étoiles*.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Role |
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `HTTP_ADDR` | `:8080` | listening address |
|
||||
| `GITHUB_TOKEN` | empty | GitHub token; raises the search quota from 10 to 30 requests per minute |
|
||||
| `CACHE_TTL` | `5m` | identical searches are served from memory for this long |
|
||||
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP basic authentication (except `/healthz`) |
|
||||
| `GITHUB_API` | `https://api.github.com` | API base URL (GitHub Enterprise) |
|
||||
| `HTTP_ADDR` | `:8080` | adresse d'écoute |
|
||||
| `HTTP_PORT` | `8080` | port publié par Docker Compose |
|
||||
| `GITHUB_TOKEN` | vide | jeton GitHub ; fait passer le quota de recherche de 10 à 30 requêtes par minute |
|
||||
| `CACHE_TTL` | `5m` | durée pendant laquelle une recherche identique est servie depuis la mémoire |
|
||||
| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP basique (sauf `/healthz`) ; vide = désactivée |
|
||||
| `GITHUB_API` | `https://api.github.com` | URL de base de l'API (GitHub Enterprise) |
|
||||
| `DATA_DIR` | `data` (`/data` sous Docker) | recherches programmées et leurs instantanés (fichiers JSON) |
|
||||
| `TZ` | `Europe/Paris` dans compose | fuseau horaire des programmations |
|
||||
| `HISTORY_KEEP` | `100` | passages conservés par recherche programmée (0 = tous) |
|
||||
|
||||
Un jeton *fine-grained* sans aucune permission suffit pour les dépôts publics.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/search?q=&in=&language=&stars=&maxstars=&minforks=&topic=&license=&pushed=&created=&goodfirst=1&forks=1&archived=1&sort=&order=&page=&per_page=`
|
||||
(`pushed` / `created`: `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `5y`)
|
||||
- `GET /api/status`: token configured and last known search quota
|
||||
(`pushed` / `created` : `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `5y`)
|
||||
- `GET /api/status` : jeton configuré, dernier quota de recherche connu, fuseau horaire du serveur
|
||||
- `GET|POST /api/saved`, `GET|PUT|DELETE /api/saved/{id}` : recherches programmées
|
||||
(`{"name", "params", "maxResults", "enabled", "schedule": {"every": "week|day|hours", "weekday", "hour", "minute", "hours"}}`,
|
||||
`params` étant la chaîne de requête de `/api/search`)
|
||||
- `POST /api/saved/{id}/run` : exécuter maintenant
|
||||
- `GET /api/saved/{id}/runs`, `GET /api/saved/{id}/runs/{run|latest}` : historique et instantanés
|
||||
- `GET /healthz`
|
||||
|
||||
## Development
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run .
|
||||
```
|
||||
|
||||
The web interface is plain HTML, CSS and JavaScript in `web/`, embedded in the binary
|
||||
(no build step).
|
||||
L'interface web est en HTML, CSS et JavaScript simples dans `web/`, embarqués dans le binaire
|
||||
(aucune étape de build).
|
||||
@@ -4,16 +4,20 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
gh *GitHub
|
||||
gh *GitHub
|
||||
store *Store
|
||||
sched *Scheduler
|
||||
}
|
||||
|
||||
func (a *API) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /healthz", a.health)
|
||||
mux.HandleFunc("GET /api/search", a.search)
|
||||
mux.HandleFunc("GET /api/status", a.status)
|
||||
a.savedRoutes(mux)
|
||||
}
|
||||
|
||||
// codedError carries a stable code that the UI translates.
|
||||
@@ -61,9 +65,19 @@ func (a *API) status(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"token": a.gh.HasToken(),
|
||||
"rate": a.gh.Rate(),
|
||||
"tz": zoneName(),
|
||||
})
|
||||
}
|
||||
|
||||
// zoneName is the server time zone used by the schedules (TZ).
|
||||
func zoneName() string {
|
||||
if name := time.Local.String(); name != "Local" {
|
||||
return name
|
||||
}
|
||||
name, _ := time.Now().Zone()
|
||||
return name
|
||||
}
|
||||
|
||||
func (a *API) health(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
@@ -10,3 +10,10 @@ services:
|
||||
CACHE_TTL: ${CACHE_TTL:-5m} # identical searches are served from memory
|
||||
AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication
|
||||
AUTH_PASS: ${AUTH_PASS:-}
|
||||
TZ: ${TZ:-Europe/Paris} # time zone of the scheduled searches
|
||||
HISTORY_KEEP: ${HISTORY_KEEP:-100} # runs kept per scheduled search
|
||||
volumes:
|
||||
- searchgit-data:/data # scheduled searches and their history
|
||||
|
||||
volumes:
|
||||
searchgit-data:
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -12,8 +12,10 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
_ "time/tzdata" // TZ works in the minimal Docker image
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
@@ -33,6 +35,13 @@ func getenvDuration(key string, def time.Duration) time.Duration {
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvInt(key string, def int) int {
|
||||
if n, err := strconv.Atoi(os.Getenv(key)); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
httpAddr := getenv("HTTP_ADDR", ":8080")
|
||||
token := os.Getenv("GITHUB_TOKEN")
|
||||
@@ -45,8 +54,15 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
store, err := OpenStore(getenv("DATA_DIR", "data"), getenvInt("HISTORY_KEEP", 100))
|
||||
if err != nil {
|
||||
log.Fatalf("data: %v", err)
|
||||
}
|
||||
sched := NewScheduler(store, gh)
|
||||
sched.Start(ctx)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
(&API{gh: gh}).Routes(mux)
|
||||
(&API{gh: gh, store: store, sched: sched}).Routes(mux)
|
||||
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
||||
|
||||
srv := &http.Server{
|
||||
@@ -65,7 +81,7 @@ func main() {
|
||||
if token == "" {
|
||||
log.Printf("GITHUB_TOKEN not set: GitHub allows only 10 searches per minute")
|
||||
}
|
||||
log.Printf("web UI on %s", httpAddr)
|
||||
log.Printf("web UI on %s, data in %s, time zone %s", httpAddr, getenv("DATA_DIR", "data"), time.Local)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http: %v", err)
|
||||
}
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Saved search routes:
|
||||
//
|
||||
// GET /api/saved list
|
||||
// POST /api/saved create (and run it once in the background)
|
||||
// GET /api/saved/{id}
|
||||
// PUT /api/saved/{id} update
|
||||
// DELETE /api/saved/{id}
|
||||
// POST /api/saved/{id}/run run now
|
||||
// GET /api/saved/{id}/runs history (summaries)
|
||||
// GET /api/saved/{id}/runs/{run} one snapshot ("latest" accepted)
|
||||
func (a *API) savedRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/saved", a.savedList)
|
||||
mux.HandleFunc("POST /api/saved", a.savedCreate)
|
||||
mux.HandleFunc("GET /api/saved/{id}", a.savedGet)
|
||||
mux.HandleFunc("PUT /api/saved/{id}", a.savedUpdate)
|
||||
mux.HandleFunc("DELETE /api/saved/{id}", a.savedDelete)
|
||||
mux.HandleFunc("POST /api/saved/{id}/run", a.savedRun)
|
||||
mux.HandleFunc("GET /api/saved/{id}/runs", a.savedRuns)
|
||||
mux.HandleFunc("GET /api/saved/{id}/runs/{run}", a.savedRunGet)
|
||||
}
|
||||
|
||||
// savedInput is what the UI sends on create and update.
|
||||
type savedInput struct {
|
||||
Name string `json:"name"`
|
||||
Params string `json:"params"`
|
||||
Schedule Schedule `json:"schedule"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (in *savedInput) validate() error {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" || len(in.Name) > 120 {
|
||||
return &codedError{code: "bad_param", msg: "invalid name", detail: "name"}
|
||||
}
|
||||
v, err := url.ParseQuery(in.Params)
|
||||
if err != nil {
|
||||
return &codedError{code: "bad_param", msg: "invalid parameters", detail: "params"}
|
||||
}
|
||||
v.Del("page")
|
||||
v.Del("per_page")
|
||||
in.Params = v.Encode()
|
||||
if _, err := ParseCriteria(v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch in.MaxResults {
|
||||
case 0:
|
||||
in.MaxResults = 50
|
||||
case 30, 50, 100:
|
||||
default:
|
||||
return &codedError{code: "bad_param", msg: "invalid maxResults", detail: "maxResults"}
|
||||
}
|
||||
if !in.Schedule.Valid() {
|
||||
return &codedError{code: "bad_param", msg: "invalid schedule", detail: "schedule"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *API) savedList(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.store.List())
|
||||
}
|
||||
|
||||
func (a *API) savedGet(w http.ResponseWriter, r *http.Request) {
|
||||
x, err := a.store.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, x)
|
||||
}
|
||||
|
||||
func readInput(r *http.Request) (savedInput, error) {
|
||||
var in savedInput
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<10))
|
||||
if err := dec.Decode(&in); err != nil {
|
||||
return in, &codedError{code: "bad_param", msg: "invalid JSON", detail: err.Error()}
|
||||
}
|
||||
return in, in.validate()
|
||||
}
|
||||
|
||||
func (a *API) savedCreate(w http.ResponseWriter, r *http.Request) {
|
||||
in, err := readInput(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
x := SavedSearch{Name: in.Name, Params: in.Params, Schedule: in.Schedule, MaxResults: in.MaxResults, Enabled: true}
|
||||
if in.Enabled != nil {
|
||||
x.Enabled = *in.Enabled
|
||||
}
|
||||
x.NextRunAt = x.Schedule.Next(time.Now(), time.Time{})
|
||||
if !x.Enabled {
|
||||
x.NextRunAt = time.Time{}
|
||||
}
|
||||
x, err = a.store.Create(x)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
// First run right away: it gives a baseline to compare the next ones with.
|
||||
go func(id string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
if _, err := a.sched.Run(ctx, id, "manual"); err != nil {
|
||||
log.Printf("first run of saved search %s: %v", id, err)
|
||||
}
|
||||
}(x.ID)
|
||||
writeJSON(w, http.StatusCreated, x)
|
||||
}
|
||||
|
||||
func (a *API) savedUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
in, err := readInput(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
x, err := a.store.Update(r.PathValue("id"), func(v *SavedSearch) {
|
||||
v.Name, v.Params, v.Schedule, v.MaxResults = in.Name, in.Params, in.Schedule, in.MaxResults
|
||||
if in.Enabled != nil {
|
||||
v.Enabled = *in.Enabled
|
||||
}
|
||||
v.NextRunAt = time.Time{}
|
||||
if v.Enabled {
|
||||
v.NextRunAt = v.Schedule.Next(time.Now(), v.LastRunAt)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, x)
|
||||
}
|
||||
|
||||
func (a *API) savedDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.store.Delete(r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) savedRun(w http.ResponseWriter, r *http.Request) {
|
||||
run, err := a.sched.Run(r.Context(), r.PathValue("id"), "manual")
|
||||
if err != nil && run.ID == "" {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
// A failed GitHub search is still a recorded run: the error is in run.error.
|
||||
writeJSON(w, http.StatusOK, run.RunInfo)
|
||||
}
|
||||
|
||||
func (a *API) savedRuns(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := a.store.Get(id); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
runs, err := a.store.Runs(id)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func (a *API) savedRunGet(w http.ResponseWriter, r *http.Request) {
|
||||
run, err := a.store.Run(r.PathValue("id"), r.PathValue("run"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, run)
|
||||
}
|
||||
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
var ce *codedError
|
||||
if errors.As(err, &ce) {
|
||||
switch ce.code {
|
||||
case "not_found":
|
||||
status = http.StatusNotFound
|
||||
case "bad_param":
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
writeErr(w, status, err)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestAPI(t *testing.T) (*http.ServeMux, *API) {
|
||||
t.Helper()
|
||||
gh, _ := newFake(http.StatusOK, searchBody)
|
||||
store, err := OpenStore(t.TempDir(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &API{gh: gh, store: store, sched: NewScheduler(store, gh)}
|
||||
mux := http.NewServeMux()
|
||||
a.Routes(mux)
|
||||
return mux, a
|
||||
}
|
||||
|
||||
func call(t *testing.T, mux *http.ServeMux, method, path, body string, out any) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if out != nil && rec.Code < 300 {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
|
||||
t.Fatalf("%s %s: %v (%s)", method, path, err, rec.Body)
|
||||
}
|
||||
}
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
func TestSavedSearchAPI(t *testing.T) {
|
||||
mux, a := newTestAPI(t)
|
||||
|
||||
for _, bad := range []string{
|
||||
`{"name": "", "params": "q=x", "schedule": {"every": "week", "weekday": 1, "hour": 8}}`,
|
||||
`{"name": "x", "params": "sort=nope", "schedule": {"every": "week", "weekday": 1, "hour": 8}}`,
|
||||
`{"name": "x", "params": "q=x", "schedule": {"every": "month"}}`,
|
||||
`{"name": "x", "params": "q=x", "maxResults": 7, "schedule": {"every": "day", "hour": 8}}`,
|
||||
} {
|
||||
if code := call(t, mux, "POST", "/api/saved", bad, nil); code != http.StatusBadRequest {
|
||||
t.Errorf("%s: got %d, want 400", bad, code)
|
||||
}
|
||||
}
|
||||
|
||||
var x SavedSearch
|
||||
code := call(t, mux, "POST", "/api/saved",
|
||||
`{"name": "Go syslog", "params": "q=syslog&language=Go&page=3", "schedule": {"every": "week", "weekday": 1, "hour": 8}}`, &x)
|
||||
if code != http.StatusCreated || x.ID == "" || x.MaxResults != 50 || !x.Enabled || x.NextRunAt.IsZero() {
|
||||
t.Fatalf("create: %d %+v", code, x)
|
||||
}
|
||||
if strings.Contains(x.Params, "page") {
|
||||
t.Errorf("page should be dropped from params: %s", x.Params)
|
||||
}
|
||||
|
||||
// The first run starts in the background; run once more by hand.
|
||||
var info RunInfo
|
||||
if code := call(t, mux, "POST", "/api/saved/"+x.ID+"/run", "", &info); code != http.StatusOK || info.Error != "" || info.Count != 1 {
|
||||
t.Fatalf("run: %d %+v", code, info)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var runs []RunInfo
|
||||
for time.Now().Before(deadline) {
|
||||
call(t, mux, "GET", "/api/saved/"+x.ID+"/runs", "", &runs)
|
||||
if len(runs) == 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if len(runs) != 2 {
|
||||
t.Fatalf("want 2 runs (background + manual), got %d", len(runs))
|
||||
}
|
||||
var run Run
|
||||
if code := call(t, mux, "GET", "/api/saved/"+x.ID+"/runs/latest", "", &run); code != http.StatusOK || len(run.Items) != 1 || run.Items[0].FullName != "mcuadros/go-syslog" {
|
||||
t.Fatalf("latest run: %d %+v", code, run)
|
||||
}
|
||||
if d := run.Items[0].StarsDelta; d == nil || *d != 0 || run.Items[0].New {
|
||||
t.Errorf("second run: repository should be known with a 0 delta: %+v", run.Items[0])
|
||||
}
|
||||
|
||||
var upd SavedSearch
|
||||
code = call(t, mux, "PUT", "/api/saved/"+x.ID,
|
||||
`{"name": "Renamed", "params": "q=syslog", "maxResults": 100, "enabled": false, "schedule": {"every": "hours", "hours": 6}}`, &upd)
|
||||
if code != http.StatusOK || upd.Name != "Renamed" || upd.Enabled || !upd.NextRunAt.IsZero() || upd.LastRun == nil {
|
||||
t.Fatalf("update: %d %+v", code, upd)
|
||||
}
|
||||
|
||||
if code := call(t, mux, "GET", "/api/saved/"+x.ID+"/runs/../../x", "", nil); code == http.StatusOK {
|
||||
t.Error("path traversal should not succeed")
|
||||
}
|
||||
if code := call(t, mux, "DELETE", "/api/saved/"+x.ID, "", nil); code != http.StatusNoContent {
|
||||
t.Errorf("delete: %d", code)
|
||||
}
|
||||
if code := call(t, mux, "GET", "/api/saved/"+x.ID, "", nil); code != http.StatusNotFound {
|
||||
t.Errorf("get after delete: %d", code)
|
||||
}
|
||||
_ = a
|
||||
}
|
||||
|
||||
func TestSchedulerRunsDueSearches(t *testing.T) {
|
||||
_, a := newTestAPI(t)
|
||||
x, _ := a.store.Create(SavedSearch{Name: "due", Params: "q=syslog", MaxResults: 30, Enabled: true,
|
||||
Schedule: Schedule{Every: "day", Hour: 8}, NextRunAt: time.Now().Add(-time.Minute)})
|
||||
a.store.Create(SavedSearch{Name: "paused", Params: "q=syslog", MaxResults: 30,
|
||||
Schedule: Schedule{Every: "day", Hour: 8}, NextRunAt: time.Now().Add(-time.Minute)})
|
||||
a.sched.runDue(context.Background())
|
||||
|
||||
got, _ := a.store.Get(x.ID)
|
||||
if got.LastRun == nil || !got.NextRunAt.After(time.Now()) {
|
||||
t.Errorf("due search should have run and be planned again: %+v", got)
|
||||
}
|
||||
for _, s := range a.store.List() {
|
||||
if s.Name == "paused" && s.LastRun != nil {
|
||||
t.Error("a paused search must not run")
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Schedule says when a saved search runs, in the server time zone (TZ).
|
||||
type Schedule struct {
|
||||
Every string `json:"every"` // "hours", "day" or "week"
|
||||
Hours int `json:"hours,omitempty"` // every: hours (1..168)
|
||||
Weekday int `json:"weekday,omitempty"` // every: week, 0 = Sunday
|
||||
Hour int `json:"hour"` // every: day / week
|
||||
Minute int `json:"minute"`
|
||||
}
|
||||
|
||||
func (sc Schedule) Valid() bool {
|
||||
switch sc.Every {
|
||||
case "hours":
|
||||
return sc.Hours >= 1 && sc.Hours <= 168
|
||||
case "day", "week":
|
||||
return sc.Hour >= 0 && sc.Hour < 24 && sc.Minute >= 0 && sc.Minute < 60 &&
|
||||
sc.Weekday >= 0 && sc.Weekday < 7
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Next returns the first run time strictly after "after". last is the time of
|
||||
// the last run, used by the "hours" schedule.
|
||||
func (sc Schedule) Next(after, last time.Time) time.Time {
|
||||
switch sc.Every {
|
||||
case "hours":
|
||||
every := time.Duration(sc.Hours) * time.Hour
|
||||
if last.IsZero() || last.Add(every).Before(after) {
|
||||
return after.Add(time.Minute)
|
||||
}
|
||||
return last.Add(every)
|
||||
case "day", "week":
|
||||
t := time.Date(after.Year(), after.Month(), after.Day(), sc.Hour, sc.Minute, 0, 0, after.Location())
|
||||
for !t.After(after) || (sc.Every == "week" && int(t.Weekday()) != sc.Weekday) {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
}
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Scheduler runs the saved searches when they are due.
|
||||
type Scheduler struct {
|
||||
store *Store
|
||||
gh *GitHub
|
||||
runMu sync.Mutex // one run at a time, to spare the GitHub quota
|
||||
}
|
||||
|
||||
func NewScheduler(store *Store, gh *GitHub) *Scheduler {
|
||||
return &Scheduler{store: store, gh: gh}
|
||||
}
|
||||
|
||||
// Start checks every 30 seconds for due searches until ctx is done.
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
now := time.Now()
|
||||
for _, x := range s.store.List() { // plan searches that have no next run yet
|
||||
if x.Enabled && x.NextRunAt.IsZero() {
|
||||
_, _ = s.store.Update(x.ID, func(v *SavedSearch) { v.NextRunAt = v.Schedule.Next(now, v.LastRunAt) })
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
tick := time.NewTicker(30 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
s.runDue(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) runDue(ctx context.Context) {
|
||||
for _, x := range s.store.List() {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if x.Enabled && !x.NextRunAt.IsZero() && !time.Now().Before(x.NextRunAt) {
|
||||
if _, err := s.Run(ctx, x.ID, "schedule"); err != nil {
|
||||
log.Printf("saved search %q: %v", x.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes a saved search now and stores the snapshot.
|
||||
func (s *Scheduler) Run(ctx context.Context, id, trigger string) (Run, error) {
|
||||
s.runMu.Lock()
|
||||
defer s.runMu.Unlock()
|
||||
x, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
start := time.Now()
|
||||
info := RunInfo{ID: start.UTC().Format("20060102T150405Z"), At: start, Trigger: trigger}
|
||||
|
||||
var repos []Repo
|
||||
c, err := criteriaFor(x)
|
||||
if err == nil {
|
||||
info.Query = c.Query(start)
|
||||
var res SearchResult
|
||||
if res, err = s.gh.Search(ctx, c); err == nil {
|
||||
info.Total = res.Total
|
||||
repos = res.Items
|
||||
}
|
||||
}
|
||||
info.TookMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
info.Error = err.Error()
|
||||
}
|
||||
run, serr := s.store.AddRun(id, info, repos)
|
||||
if serr != nil {
|
||||
return Run{}, serr
|
||||
}
|
||||
|
||||
_, uerr := s.store.Update(id, func(v *SavedSearch) {
|
||||
v.LastRunAt = start
|
||||
v.LastError = info.Error
|
||||
ri := run.RunInfo
|
||||
v.LastRun = &ri
|
||||
next := v.Schedule.Next(time.Now(), start)
|
||||
if err != nil {
|
||||
var ce *codedError
|
||||
if errors.As(err, &ce) && ce.code == "rate_limited" {
|
||||
// Quota exhausted: try again in a few minutes rather than next week.
|
||||
next = time.Now().Add(3 * time.Minute)
|
||||
}
|
||||
}
|
||||
v.NextRunAt = next
|
||||
})
|
||||
if uerr != nil {
|
||||
return run, uerr
|
||||
}
|
||||
if err != nil {
|
||||
return run, err
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
// criteriaFor parses the stored parameters; a saved search always reads the
|
||||
// first page, sorted as saved.
|
||||
func criteriaFor(x SavedSearch) (Criteria, error) {
|
||||
v, err := url.ParseQuery(x.Params)
|
||||
if err != nil {
|
||||
return Criteria{}, &codedError{code: "bad_param", msg: "invalid parameters", detail: err.Error()}
|
||||
}
|
||||
v.Del("page")
|
||||
v.Set("per_page", strconv.Itoa(x.MaxResults))
|
||||
return ParseCriteria(v)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScheduleNext(t *testing.T) {
|
||||
loc := time.FixedZone("CEST", 2*3600)
|
||||
// Wednesday 2026-09-30 10:00
|
||||
now := time.Date(2026, 9, 30, 10, 0, 0, 0, loc)
|
||||
cases := []struct {
|
||||
name string
|
||||
sc Schedule
|
||||
last time.Time
|
||||
want time.Time
|
||||
}{
|
||||
{"weekly monday 8:00", Schedule{Every: "week", Weekday: 1, Hour: 8}, time.Time{}, time.Date(2026, 10, 5, 8, 0, 0, 0, loc)},
|
||||
{"weekly today later", Schedule{Every: "week", Weekday: 3, Hour: 18, Minute: 30}, time.Time{}, time.Date(2026, 9, 30, 18, 30, 0, 0, loc)},
|
||||
{"weekly today passed", Schedule{Every: "week", Weekday: 3, Hour: 9}, time.Time{}, time.Date(2026, 10, 7, 9, 0, 0, 0, loc)},
|
||||
{"daily passed", Schedule{Every: "day", Hour: 7}, time.Time{}, time.Date(2026, 10, 1, 7, 0, 0, 0, loc)},
|
||||
{"hours first", Schedule{Every: "hours", Hours: 6}, time.Time{}, now.Add(time.Minute)},
|
||||
{"hours after last", Schedule{Every: "hours", Hours: 6}, now.Add(-2 * time.Hour), now.Add(4 * time.Hour)},
|
||||
{"hours late", Schedule{Every: "hours", Hours: 6}, now.Add(-7 * time.Hour), now.Add(time.Minute)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.sc.Next(now, c.last); !got.Equal(c.want) {
|
||||
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleValid(t *testing.T) {
|
||||
for _, sc := range []Schedule{{Every: "hours"}, {Every: "hours", Hours: 200}, {Every: "week", Weekday: 7}, {Every: "day", Hour: 24}, {Every: "month"}} {
|
||||
if sc.Valid() {
|
||||
t.Errorf("%+v should be invalid", sc)
|
||||
}
|
||||
}
|
||||
if !(Schedule{Every: "week", Weekday: 1, Hour: 8}).Valid() {
|
||||
t.Error("weekly schedule should be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRuns(t *testing.T) {
|
||||
s, err := OpenStore(t.TempDir(), 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
x, err := s.Create(SavedSearch{Name: "go", Params: "language=Go", Schedule: Schedule{Every: "day"}, MaxResults: 30, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t0 := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
|
||||
add := func(id string, at time.Time, repos ...Repo) Run {
|
||||
t.Helper()
|
||||
r, err := s.AddRun(x.ID, RunInfo{ID: id, At: at}, repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
r1 := add("r1", t0, Repo{FullName: "a/a", Stars: 10}, Repo{FullName: "b/b", Stars: 5})
|
||||
if r1.New != 0 || r1.Items[0].New || r1.Items[0].StarsDelta != nil {
|
||||
t.Errorf("first run must be a baseline: %+v", r1)
|
||||
}
|
||||
|
||||
r2 := add("r2", t0.Add(24*time.Hour), Repo{FullName: "a/a", Stars: 25}, Repo{FullName: "c/c", Stars: 3})
|
||||
if r2.New != 1 || !r2.Items[1].New || r2.Items[0].New {
|
||||
t.Errorf("c/c should be the only new repository: %+v", r2.Items)
|
||||
}
|
||||
if d := r2.Items[0].StarsDelta; d == nil || *d != 15 || r2.Rising != 1 {
|
||||
t.Errorf("a/a should have gained 15 stars: %+v", r2.Items[0])
|
||||
}
|
||||
|
||||
// A failed run is recorded but does not break the comparison.
|
||||
s.AddRun(x.ID, RunInfo{ID: "r3", At: t0.Add(48 * time.Hour), Error: "boom"}, nil)
|
||||
r4 := add("r4", t0.Add(72*time.Hour), Repo{FullName: "b/b", Stars: 6}, Repo{FullName: "c/c", Stars: 4})
|
||||
if r4.New != 0 {
|
||||
t.Errorf("b/b was seen in the first run, not new: %+v", r4.Items)
|
||||
}
|
||||
|
||||
idx, _ := s.Runs(x.ID)
|
||||
if len(idx) != 2 || idx[0].ID != "r4" {
|
||||
t.Errorf("history should keep the 2 newest runs, got %+v", idx)
|
||||
}
|
||||
if _, err := s.Run(x.ID, "r1"); err == nil {
|
||||
t.Error("r1 should have been pruned")
|
||||
}
|
||||
latest, err := s.Run(x.ID, "latest")
|
||||
if err != nil || latest.ID != "r4" {
|
||||
t.Errorf("latest = %v, %v", latest.ID, err)
|
||||
}
|
||||
if err := s.Delete(x.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Get(x.ID); err == nil {
|
||||
t.Error("search should be deleted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SavedSearch is a search that the scheduler runs on a regular basis.
|
||||
type SavedSearch struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Params string `json:"params"` // same query string as /api/search (without page)
|
||||
Schedule Schedule `json:"schedule"`
|
||||
MaxResults int `json:"maxResults"` // 30, 50 or 100 (one GitHub page)
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastRunAt time.Time `json:"lastRunAt,omitzero"`
|
||||
NextRunAt time.Time `json:"nextRunAt,omitzero"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
LastRun *RunInfo `json:"lastRun,omitempty"`
|
||||
}
|
||||
|
||||
// RunInfo summarizes one execution of a saved search.
|
||||
type RunInfo struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
Query string `json:"query"`
|
||||
Total int `json:"total"`
|
||||
Count int `json:"count"` // repositories kept in the snapshot
|
||||
New int `json:"new"` // never seen in a previous run
|
||||
Rising int `json:"rising"` // gained stars since the previous run
|
||||
Error string `json:"error,omitempty"`
|
||||
TookMs int64 `json:"tookMs"`
|
||||
Trigger string `json:"trigger"` // "schedule" or "manual"
|
||||
}
|
||||
|
||||
// RunItem is one repository of a snapshot, compared with the previous run.
|
||||
type RunItem struct {
|
||||
Repo
|
||||
New bool `json:"new"`
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
StarsDelta *int `json:"starsDelta,omitempty"` // nil when absent from the previous run
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
|
||||
// Run is a full snapshot.
|
||||
type Run struct {
|
||||
RunInfo
|
||||
Items []RunItem `json:"items"`
|
||||
}
|
||||
|
||||
// Store keeps saved searches and their runs as JSON files:
|
||||
//
|
||||
// <dir>/searches.json
|
||||
// <dir>/runs/<search id>/index.json run summaries, newest first
|
||||
// <dir>/runs/<search id>/seen.json repository -> first time seen
|
||||
// <dir>/runs/<search id>/<run id>.json
|
||||
type Store struct {
|
||||
dir string
|
||||
keep int // runs kept per search (0 = all)
|
||||
|
||||
mu sync.Mutex
|
||||
searches []*SavedSearch
|
||||
}
|
||||
|
||||
var idRe = regexp.MustCompile(`^[a-zA-Z0-9-]{1,40}$`)
|
||||
|
||||
var errNotFound = &codedError{code: "not_found", msg: "not found"}
|
||||
|
||||
func OpenStore(dir string, keep int) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "runs"), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{dir: dir, keep: keep}
|
||||
if err := readJSON(filepath.Join(dir, "searches.json"), &s.searches); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 6)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// List returns copies of the saved searches, sorted by name.
|
||||
func (s *Store) List() []SavedSearch {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]SavedSearch, 0, len(s.searches))
|
||||
for _, x := range s.searches {
|
||||
out = append(out, *x)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (SavedSearch, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if x := s.find(id); x != nil {
|
||||
return *x, nil
|
||||
}
|
||||
return SavedSearch{}, errNotFound
|
||||
}
|
||||
|
||||
func (s *Store) find(id string) *SavedSearch {
|
||||
for _, x := range s.searches {
|
||||
if x.ID == id {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(x SavedSearch) (SavedSearch, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
x.ID = newID()
|
||||
x.CreatedAt = time.Now()
|
||||
s.searches = append(s.searches, &x)
|
||||
return x, s.saveLocked()
|
||||
}
|
||||
|
||||
// Update changes what the user can edit and returns the new value.
|
||||
func (s *Store) Update(id string, fn func(*SavedSearch)) (SavedSearch, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
x := s.find(id)
|
||||
if x == nil {
|
||||
return SavedSearch{}, errNotFound
|
||||
}
|
||||
fn(x)
|
||||
return *x, s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, x := range s.searches {
|
||||
if x.ID == id {
|
||||
s.searches = append(s.searches[:i], s.searches[i+1:]...)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(s.runDir(id))
|
||||
}
|
||||
}
|
||||
return errNotFound
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
return writeFileJSON(filepath.Join(s.dir, "searches.json"), s.searches)
|
||||
}
|
||||
|
||||
func (s *Store) runDir(id string) string { return filepath.Join(s.dir, "runs", id) }
|
||||
|
||||
// Runs returns the run summaries of a search, newest first.
|
||||
func (s *Store) Runs(id string) ([]RunInfo, error) {
|
||||
if !idRe.MatchString(id) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
var idx []RunInfo
|
||||
err := readJSON(filepath.Join(s.runDir(id), "index.json"), &idx)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []RunInfo{}, nil
|
||||
}
|
||||
return idx, err
|
||||
}
|
||||
|
||||
// Run reads one snapshot ("latest" for the most recent one).
|
||||
func (s *Store) Run(id, runID string) (Run, error) {
|
||||
if runID == "latest" {
|
||||
idx, err := s.Runs(id)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if len(idx) == 0 {
|
||||
return Run{}, errNotFound
|
||||
}
|
||||
runID = idx[0].ID
|
||||
}
|
||||
if !idRe.MatchString(id) || !idRe.MatchString(runID) {
|
||||
return Run{}, errNotFound
|
||||
}
|
||||
var r Run
|
||||
err := readJSON(filepath.Join(s.runDir(id), runID+".json"), &r)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Run{}, errNotFound
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// AddRun compares the repositories with the previous run, saves the snapshot
|
||||
// and updates the search. A failed run (info.Error set) is only recorded in
|
||||
// the history.
|
||||
func (s *Store) AddRun(id string, info RunInfo, repos []Repo) (Run, error) {
|
||||
dir := s.runDir(id)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
idx, err := s.Runs(id)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
run := Run{RunInfo: info, Items: []RunItem{}}
|
||||
|
||||
if info.Error == "" {
|
||||
seen := map[string]time.Time{}
|
||||
if err := readJSON(filepath.Join(dir, "seen.json"), &seen); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return Run{}, err
|
||||
}
|
||||
prevStars := map[string]int{}
|
||||
for _, p := range idx { // previous successful run
|
||||
if p.Error != "" {
|
||||
continue
|
||||
}
|
||||
if prev, err := s.Run(id, p.ID); err == nil {
|
||||
for _, it := range prev.Items {
|
||||
prevStars[it.FullName] = it.Stars
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
baseline := len(seen) == 0 // the first successful run has nothing "new"
|
||||
for i, r := range repos {
|
||||
it := RunItem{Repo: r, Rank: i + 1}
|
||||
if first, ok := seen[r.FullName]; ok {
|
||||
it.FirstSeen = first
|
||||
} else {
|
||||
it.New = !baseline
|
||||
it.FirstSeen = info.At
|
||||
seen[r.FullName] = info.At
|
||||
if it.New {
|
||||
run.New++
|
||||
}
|
||||
}
|
||||
if p, ok := prevStars[r.FullName]; ok {
|
||||
d := r.Stars - p
|
||||
it.StarsDelta = &d
|
||||
if d > 0 {
|
||||
run.Rising++
|
||||
}
|
||||
}
|
||||
run.Items = append(run.Items, it)
|
||||
}
|
||||
run.Count = len(run.Items)
|
||||
if err := writeFileJSON(filepath.Join(dir, "seen.json"), seen); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeFileJSON(filepath.Join(dir, run.ID+".json"), run); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
idx = append([]RunInfo{run.RunInfo}, idx...)
|
||||
if s.keep > 0 && len(idx) > s.keep {
|
||||
for _, old := range idx[s.keep:] {
|
||||
_ = os.Remove(filepath.Join(dir, old.ID+".json"))
|
||||
}
|
||||
idx = idx[:s.keep]
|
||||
}
|
||||
if err := writeFileJSON(filepath.Join(dir, "index.json"), idx); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func readJSON(path string, v any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(data, v); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFileJSON replaces the file atomically.
|
||||
func writeFileJSON(path string, v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
+525
-36
@@ -68,6 +68,41 @@ const I18N = {
|
||||
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',
|
||||
@@ -134,6 +169,41 @@ const I18N = {
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -171,8 +241,8 @@ function fmtAgo(s) {
|
||||
return '';
|
||||
}
|
||||
|
||||
async function api(url) {
|
||||
const res = await fetch(url);
|
||||
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 */ }
|
||||
@@ -191,8 +261,8 @@ function apiError(res, text, data) {
|
||||
|
||||
function toast(msg) {
|
||||
const el = $('#toast');
|
||||
const dlg = $('#settingsDlg');
|
||||
(dlg.open ? dlg : document.body).append(el);
|
||||
const dlg = document.querySelector('dialog[open]');
|
||||
(dlg || document.body).append(el);
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
clearTimeout(toast.tm);
|
||||
@@ -291,7 +361,11 @@ function syncURL(f) {
|
||||
}
|
||||
|
||||
function filtersFromURL() {
|
||||
const src = location.search ? new URLSearchParams(location.search) : new URLSearchParams(store.get('filters', ''));
|
||||
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';
|
||||
@@ -352,7 +426,9 @@ const ICON_EXT = '<svg viewBox="0 0 24 24" aria-hidden="true" width="14" height=
|
||||
|
||||
function rowHTML(r, i) {
|
||||
const open = state.open.has(r.fullName);
|
||||
const badges = (r.archived ? `<span class="badge archived">${esc(t('archived'))}</span>` : '')
|
||||
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>`
|
||||
@@ -360,7 +436,10 @@ function rowHTML(r, i) {
|
||||
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>';
|
||||
return `<div class="row${open ? ' open' : ''}" tabindex="0" data-i="${i}">
|
||||
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>
|
||||
@@ -368,7 +447,7 @@ function rowHTML(r, i) {
|
||||
${topics}
|
||||
</div>
|
||||
<div class="meta">
|
||||
${lang}
|
||||
${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>
|
||||
@@ -449,6 +528,7 @@ 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();
|
||||
@@ -456,38 +536,49 @@ async function loadStatus() {
|
||||
|
||||
/* ================= List interactions ================= */
|
||||
|
||||
$('#list').addEventListener('click', (ev) => {
|
||||
const rowEl = ev.target.closest('.row');
|
||||
if (!rowEl) return;
|
||||
const r = state.items[Number(rowEl.dataset.i)];
|
||||
const topic = ev.target.closest('[data-topic]');
|
||||
if (topic) {
|
||||
$('#topic').value = topic.dataset.topic;
|
||||
onFilterChange();
|
||||
return;
|
||||
}
|
||||
if (ev.target.closest('[data-act="lang"]')) {
|
||||
writeFilters({ ...readFilters(), language: r.language });
|
||||
onFilterChange();
|
||||
return;
|
||||
}
|
||||
const cp = ev.target.closest('[data-copy]');
|
||||
if (cp) { copy(cp.dataset.copy); return; }
|
||||
if (ev.target.closest('a, .details')) return;
|
||||
toggleRow(rowEl, r);
|
||||
});
|
||||
// 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);
|
||||
|
||||
$('#list').addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Enter' || !ev.target.classList.contains('row')) return;
|
||||
toggleRow(ev.target, state.items[Number(ev.target.dataset.i)]);
|
||||
});
|
||||
// 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(rowEl, r) {
|
||||
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);
|
||||
$(`#list .row[data-i="${i}"]`).focus({ preventScroll: true });
|
||||
listEl.querySelector(`.row[data-i="${i}"]`).focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/* ================= Filter events ================= */
|
||||
@@ -516,7 +607,7 @@ $('#resetBtn').addEventListener('click', () => {
|
||||
$('#moreBtn').addEventListener('click', () => search(true));
|
||||
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === '/' && !ev.target.closest('input, select, textarea') && !$('#settingsDlg').open) {
|
||||
if (ev.key === '/' && view === 'search' && !ev.target.closest('input, select, textarea') && !document.querySelector('dialog[open]')) {
|
||||
ev.preventDefault();
|
||||
$('#q').focus();
|
||||
$('#q').select();
|
||||
@@ -567,6 +658,8 @@ function setLang(next) {
|
||||
applyLang();
|
||||
renderList();
|
||||
renderStatus();
|
||||
renderSaved();
|
||||
renderReview();
|
||||
}
|
||||
|
||||
$('#langSwitch').addEventListener('click', (ev) => {
|
||||
@@ -663,6 +756,400 @@ $('#settingsBtn').addEventListener('click', () => {
|
||||
$('#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'));
|
||||
@@ -670,5 +1157,7 @@ applyLang();
|
||||
renderInterface();
|
||||
writeFilters(filtersFromURL());
|
||||
loadStatus();
|
||||
search();
|
||||
route();
|
||||
if (view === 'search') search();
|
||||
else state.searchPending = true;
|
||||
setInterval(() => { if (!document.hidden) renderStatus(); }, 5000); // reset countdown
|
||||
+137
-1
@@ -23,7 +23,19 @@
|
||||
<span>Searchgit</span>
|
||||
</div>
|
||||
|
||||
<form id="searchForm" class="search" role="search">
|
||||
<nav id="views" class="views" data-i18n-aria="viewsAria">
|
||||
<a href="#" data-view="search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
<span data-i18n="viewSearch">Search</span>
|
||||
</a>
|
||||
<a href="#saved" data-view="saved">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9 2h6"/></svg>
|
||||
<span data-i18n="viewSaved">Watch</span>
|
||||
<span id="newTotal" class="pill" hidden></span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<form id="searchForm" class="search view-search" role="search">
|
||||
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
<input id="q" type="search" autocomplete="off" spellcheck="false" data-i18n-aria="searchAria" data-i18n-ph="searchPh">
|
||||
<select id="in" class="chip-select" data-i18n-aria="inAria">
|
||||
@@ -47,6 +59,7 @@
|
||||
<div class="progress" aria-hidden="true"></div>
|
||||
</header>
|
||||
|
||||
<div id="viewSearch" class="view">
|
||||
<section class="filters">
|
||||
<select id="language" data-i18n-aria="languageAria"><option value="" data-i18n="langAll">All languages</option></select>
|
||||
<select id="stars" data-i18n-aria="starsAria">
|
||||
@@ -58,6 +71,7 @@
|
||||
</select>
|
||||
<select id="pushed" data-i18n-aria="pushedAria">
|
||||
<option value="" data-i18n="pushedAll">Any activity</option>
|
||||
<option value="1w" data-i18n="pushed1w">Pushed < 1 week</option>
|
||||
<option value="1m" data-i18n="pushed1m">Pushed < 1 month</option>
|
||||
<option value="6m" data-i18n="pushed6m">Pushed < 6 months</option>
|
||||
<option value="1y" data-i18n="pushed1y">Pushed < 1 year</option>
|
||||
@@ -65,7 +79,10 @@
|
||||
</select>
|
||||
<select id="created" data-i18n-aria="createdAria">
|
||||
<option value="" data-i18n="createdAll">Any creation date</option>
|
||||
<option value="1w" data-i18n="created1w">Created < 1 week</option>
|
||||
<option value="1m" data-i18n="created1m">Created < 1 month</option>
|
||||
<option value="3m" data-i18n="created3m">Created < 3 months</option>
|
||||
<option value="6m" data-i18n="created6m">Created < 6 months</option>
|
||||
<option value="1y" data-i18n="created1y">Created < 1 year</option>
|
||||
<option value="5y" data-i18n="created5y">Created < 5 years</option>
|
||||
</select>
|
||||
@@ -98,6 +115,10 @@
|
||||
<option value="updated" data-i18n="sortUpdated">Recently updated</option>
|
||||
<option value="help-wanted-issues" data-i18n="sortHelp">Help wanted issues</option>
|
||||
</select>
|
||||
<button id="saveBtn" class="btn tool" type="button" data-i18n-title="saveBtnTitle">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9 2h6"/></svg>
|
||||
<span class="lbl" data-i18n="saveBtn">Schedule</span>
|
||||
</button>
|
||||
<button id="exportBtn" class="btn tool" type="button" data-i18n-title="exportTitle">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 17v3h16v-3"/></svg>
|
||||
<span class="lbl">CSV</span>
|
||||
@@ -116,6 +137,70 @@
|
||||
<p><strong data-i18n="emptyTitle">No matching repositories.</strong></p>
|
||||
<p class="muted" data-i18n="emptyHint">Loosen a filter or change the search words.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved (scheduled) searches -->
|
||||
<div id="viewSaved" class="view" hidden>
|
||||
<section class="filters">
|
||||
<h1 class="view-title" data-i18n="savedTitle">Scheduled searches</h1>
|
||||
<div class="end">
|
||||
<span id="savedCount" class="muted small"></span>
|
||||
<a href="#" class="btn tool">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="lbl" data-i18n="savedNew">New from a search</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<div id="savedError" class="error-banner" hidden></div>
|
||||
<main id="savedList" class="list"></main>
|
||||
<div id="savedEmpty" class="empty" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9 2h6"/></svg>
|
||||
<p><strong data-i18n="savedEmptyTitle">No scheduled search yet.</strong></p>
|
||||
<p class="muted" data-i18n="savedEmptyHint"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review of one saved search -->
|
||||
<div id="viewReview" class="view" hidden>
|
||||
<section class="filters review-head">
|
||||
<a href="#saved" class="icon-btn" data-i18n-title="back" data-i18n-aria="back">
|
||||
<svg viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</a>
|
||||
<div class="review-title">
|
||||
<h1 id="revName" class="view-title"></h1>
|
||||
<div id="revSched" class="muted small"></div>
|
||||
</div>
|
||||
<div class="end">
|
||||
<select id="runSel" data-i18n-aria="runAria"></select>
|
||||
<button id="revRunBtn" class="btn tool" type="button" data-i18n-title="runNowTitle">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4v16l13-8z"/></svg>
|
||||
<span class="lbl" data-i18n="runNow">Run now</span>
|
||||
</button>
|
||||
<button id="revOpenBtn" class="btn tool" type="button" data-i18n-title="openInSearchTitle">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
<span class="lbl" data-i18n="openInSearch">Open in search</span>
|
||||
</button>
|
||||
<button id="revEditBtn" class="icon-btn" type="button" data-i18n-title="edit" data-i18n-aria="edit">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16zM14 6l4 4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="filters review-bar">
|
||||
<div id="revMode" class="seg" role="radiogroup" data-i18n-aria="revModeAria">
|
||||
<button type="button" role="radio" data-mode="new"></button>
|
||||
<button type="button" role="radio" data-mode="rising"></button>
|
||||
<button type="button" role="radio" data-mode="all"></button>
|
||||
</div>
|
||||
<span id="revInfo" class="muted small"></span>
|
||||
</section>
|
||||
<div id="revError" class="error-banner" hidden></div>
|
||||
<main id="revList" class="list"></main>
|
||||
<div id="revEmpty" class="empty" hidden>
|
||||
<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>
|
||||
<p><strong id="revEmptyTitle"></strong></p>
|
||||
<p id="revEmptyHint" class="muted"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="status">
|
||||
<span id="conn" class="conn"></span>
|
||||
@@ -218,6 +303,57 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Create / edit a scheduled search -->
|
||||
<dialog id="saveDlg" class="save-dlg" aria-labelledby="saveTitle">
|
||||
<form id="saveForm" class="dlg" method="dialog">
|
||||
<header>
|
||||
<h2 id="saveTitle"></h2>
|
||||
<button id="closeSave" class="icon-btn" type="button" data-i18n-aria="close">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="save-body">
|
||||
<label class="lbl" for="svName" data-i18n="svName">Name</label>
|
||||
<input id="svName" class="text-input" type="text" maxlength="120" required autocomplete="off">
|
||||
|
||||
<span class="lbl" data-i18n="svQuery">GitHub query</span>
|
||||
<code id="svQuery" class="query-box"></code>
|
||||
|
||||
<label class="lbl" for="svEvery" data-i18n="svEvery">Frequency</label>
|
||||
<div class="sched-row">
|
||||
<select id="svEvery" class="text-input">
|
||||
<option value="week" data-i18n="everyWeek">Every week</option>
|
||||
<option value="day" data-i18n="everyDay">Every day</option>
|
||||
<option value="hours" data-i18n="everyHours">Every N hours</option>
|
||||
</select>
|
||||
<select id="svWeekday" class="text-input" data-i18n-aria="svWeekday"></select>
|
||||
<input id="svTime" class="text-input" type="time" value="08:00" required data-i18n-aria="svTime">
|
||||
<span id="svHoursWrap" class="hours-wrap">
|
||||
<input id="svHours" class="text-input" type="number" min="1" max="168" value="24" data-i18n-aria="svHours">
|
||||
<span class="muted small" data-i18n="hoursUnit">hours</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="lbl" data-i18n="svMax">Results kept</span>
|
||||
<div id="svMax" class="seg" role="radiogroup" data-i18n-aria="svMax">
|
||||
<button type="button" role="radio" data-max="30">30</button>
|
||||
<button type="button" role="radio" data-max="50">50</button>
|
||||
<button type="button" role="radio" data-max="100">100</button>
|
||||
</div>
|
||||
|
||||
<span class="lbl" data-i18n="svEnabled">Active</span>
|
||||
<label class="check"><input id="svEnabled" type="checkbox" checked> <span data-i18n="svEnabledHelp">Run on schedule</span></label>
|
||||
</div>
|
||||
<p id="svHint" class="muted small save-hint"></p>
|
||||
<footer class="dlg-foot">
|
||||
<button id="svDelete" class="btn danger" type="button" data-i18n="delete" hidden>Delete</button>
|
||||
<span class="grow"></span>
|
||||
<button id="svCancel" class="btn" type="button" data-i18n="cancel">Cancel</button>
|
||||
<button id="svSubmit" class="btn primary" type="submit" data-i18n="save">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
+115
@@ -348,8 +348,105 @@ dialog.settings {
|
||||
.seg button:hover { color: var(--text); }
|
||||
.seg button[aria-checked="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
|
||||
|
||||
|
||||
/* ---------- Views (search / scheduled searches) ---------- */
|
||||
.views { display: inline-flex; gap: 2px; padding: 3px; background: var(--panel-2); border-radius: 10px; flex: none; }
|
||||
.views a {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 30px; padding: 0 12px; border-radius: 7px;
|
||||
color: var(--muted); font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.views a:hover { color: var(--text); }
|
||||
.views a[aria-current="page"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
|
||||
.views svg { width: 16px; height: 16px; }
|
||||
.pill {
|
||||
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
|
||||
background: var(--accent); color: #fff; font-size: 11px; font-weight: 700;
|
||||
display: inline-grid; place-items: center; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
body:not([data-view="search"]) .view-search { visibility: hidden; }
|
||||
body:not([data-view="search"]) .gh-query { display: none; }
|
||||
|
||||
.view-title { margin: 0; font-size: 16px; font-weight: 650; letter-spacing: -.01em; overflow-wrap: anywhere; }
|
||||
.review-head { flex-wrap: nowrap; }
|
||||
.review-title { min-width: 0; }
|
||||
.review-head .end { flex-wrap: wrap; justify-content: flex-end; }
|
||||
.review-bar { padding-top: 0; }
|
||||
#runSel { max-width: 260px; }
|
||||
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn.primary:hover { filter: brightness(1.08); background: var(--accent); }
|
||||
.btn.danger { color: var(--err); }
|
||||
.btn.danger:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
|
||||
|
||||
/* Saved search rows */
|
||||
.srow {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px 18px; align-items: center;
|
||||
padding: var(--row-pad) 12px var(--row-pad) 16px; border-bottom: 1px solid var(--border); cursor: pointer;
|
||||
}
|
||||
.srow:last-child { border-bottom: 0; }
|
||||
.srow:hover { background: var(--row-hover); }
|
||||
.srow:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.srow.off { opacity: .6; }
|
||||
.srow .sname { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; font-weight: 650; }
|
||||
.srow .sq { margin-top: 2px; font: 12px var(--mono); color: var(--muted); overflow-wrap: anywhere; }
|
||||
.srow .ssched { margin-top: 4px; font-size: 12.5px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 2px 14px; }
|
||||
.srow .ssched span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.srow .ssched svg { width: 14px; height: 14px; }
|
||||
.srow .sstats { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.srow .sacts { display: flex; gap: 2px; }
|
||||
.srow .sacts .icon-btn { width: 32px; height: 32px; }
|
||||
.srow .sacts .icon-btn svg { width: 17px; height: 17px; }
|
||||
.stat {
|
||||
display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border-radius: 999px;
|
||||
font-size: 12px; font-weight: 650; background: var(--panel-2); color: var(--muted); white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat.new { background: var(--accent-soft); color: var(--accent); }
|
||||
.stat.up { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); }
|
||||
.stat.err { background: color-mix(in srgb, var(--err) 12%, transparent); color: var(--err); }
|
||||
.stat svg { width: 12px; height: 12px; }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Review annotations */
|
||||
.badge.new { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: var(--accent-soft); }
|
||||
.meta .delta { color: var(--ok); font-weight: 650; }
|
||||
.meta .delta.down { color: var(--err); }
|
||||
.row.is-new { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.review .meta { grid-template-columns: 4.5rem 7.5rem 4.5rem 4rem 6.5rem; }
|
||||
|
||||
/* Save dialog */
|
||||
dialog.save-dlg { width: min(560px, calc(100vw - 32px)); }
|
||||
.save-body {
|
||||
display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px 16px; align-items: center;
|
||||
padding: 18px 22px 4px;
|
||||
}
|
||||
.text-input {
|
||||
height: 34px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--panel); font-size: 13px; outline: 0; min-width: 0;
|
||||
}
|
||||
.text-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
#svName { width: 100%; }
|
||||
#svHours { width: 5.5rem; }
|
||||
.sched-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.hours-wrap { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.query-box {
|
||||
display: block; padding: 7px 10px; border-radius: 8px; background: var(--panel-2); border: 1px solid var(--border);
|
||||
font: 12px/1.5 var(--mono); color: var(--muted); overflow-wrap: anywhere;
|
||||
}
|
||||
.check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.check input { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
.save-hint { margin: 10px 22px 0; }
|
||||
.save-hint:empty { display: none; }
|
||||
.dlg-foot { display: flex; align-items: center; gap: 8px; padding: 16px 22px 18px; }
|
||||
.grow { flex: 1; }
|
||||
|
||||
/* ---------- Medium screens: the meta columns go under the description ---------- */
|
||||
@media (max-width: 1100px) {
|
||||
.srow { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.srow .sstats { grid-column: 1; justify-content: flex-start; }
|
||||
.srow .sacts { grid-row: 1 / span 2; grid-column: 2; }
|
||||
.row { grid-template-columns: 36px minmax(0, 1fr); grid-template-areas: "av main" "av meta" "av details"; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 4px 16px; padding-top: 6px; }
|
||||
}
|
||||
@@ -374,6 +471,24 @@ dialog.settings {
|
||||
.status { padding: 6px 16px; }
|
||||
.gh-query { display: none; }
|
||||
dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); }
|
||||
.views { order: 2; margin-left: auto; }
|
||||
.views a { padding: 0 9px; }
|
||||
.views a span:not(.pill) { display: none; }
|
||||
.actions { order: 2; margin-left: 0; }
|
||||
body:not([data-view="search"]) .view-search { display: none; }
|
||||
.review-head { flex-wrap: wrap; }
|
||||
.review-title { flex: 1; }
|
||||
.review-head .end { flex: 1 1 100%; justify-content: flex-start; }
|
||||
.review-head .end .lbl { display: none; }
|
||||
.review-head .end { flex-wrap: nowrap; }
|
||||
#runSel { flex: 1 1 0; min-width: 0; max-width: none; }
|
||||
.review-bar .seg { flex: 1 1 100%; }
|
||||
.review-bar .seg button { flex: 1; padding: 0 6px; font-size: 12px; }
|
||||
.srow { padding-left: 12px; padding-right: 8px; }
|
||||
.save-body { grid-template-columns: 1fr; gap: 6px; padding: 14px 16px 4px; }
|
||||
.save-body .lbl { margin-top: 6px; }
|
||||
.save-hint { margin: 10px 16px 0; }
|
||||
.dlg-foot { padding: 14px 16px 16px; }
|
||||
.set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.set-nav {
|
||||
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 6px;
|
||||
|
||||
Reference in new issue
Block a user