Recherches programmées et revue hebdomadaire #2

Merged
claude Bot merged 2 commits from veille into main 2026-10-01 19:01:14 +02:00
16 changed files with 1733 additions and 32 deletions

No files matched your search

+3
View File
@@ -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
+3
View File
@@ -28,3 +28,6 @@ go.work.sum
# Binary
/searchgit
# Local data (scheduled searches)
/data/
+4 -1
View File
@@ -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"]
+30 -3
View File
@@ -6,7 +6,8 @@ Search GitHub repositories with criteria and browse the results in a clean web i
```
browser ──/api/search──▶ searchgit (Go) ──REST──▶ api.github.com/search/repositories
└── 5 min cache
──/api/saved───▶ ├── 5 min cache
└── scheduler ──▶ /data (JSON snapshots)
```
## Getting started
@@ -18,7 +19,7 @@ docker compose up -d --build
Then open <http://localhost:8080>.
Without Docker: `go run .` (Go 1.23 or newer), then open <http://localhost:8080>.
Without Docker: `go run .` (Go 1.24 or newer, data in `./data`), then open <http://localhost:8080>.
## Criteria
@@ -42,6 +43,24 @@ shows the exact query sent to GitHub (click it to open the same search on github
GitHub returns at most the first 1,000 results of a search.
## Scheduled searches (weekly review)
**Schedule** saves the current filters as a search that the server runs on its own:
every week (day and time), every day, or every N hours, in the server time zone (`TZ`).
Each run keeps a snapshot of the first 30, 50 or 100 results.
The **Watch** tab lists the scheduled searches with their last run, and a review page
per search shows, for any run of its history:
- **New**: repositories never returned by a previous run of this search;
- **Rising**: repositories that gained the most stars since the previous run;
- **All**: the whole snapshot, with the star gain of each repository.
A search runs once as soon as it is created: that first run is the baseline the next
ones are compared with. A run missed while the server was stopped is made at startup.
For a weekly "gems" review, a good start is: a topic or a language, *created < 1 month*
(or *< 1 week*), sorted by *most stars*.
## Configuration
| Variable | Default | Role |
@@ -51,12 +70,20 @@ GitHub returns at most the first 1,000 results of a search.
| `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) |
| `DATA_DIR` | `data` (`/data` in Docker) | scheduled searches and their snapshots (JSON files) |
| `TZ` | `Europe/Paris` in compose | time zone of the schedules |
| `HISTORY_KEEP` | `100` | runs kept per scheduled search (0 = all) |
## 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
- `GET /api/status`: token configured, last known search quota, server time zone
- `GET|POST /api/saved`, `GET|PUT|DELETE /api/saved/{id}`: scheduled searches
(`{"name", "params", "maxResults", "enabled", "schedule": {"every": "week|day|hours", "weekday", "hour", "minute", "hours"}}`,
`params` being the `/api/search` query string)
- `POST /api/saved/{id}/run`: run now
- `GET /api/saved/{id}/runs`, `GET /api/saved/{id}/runs/{run|latest}`: history and snapshots
- `GET /healthz`
## Development
+14
View File
@@ -4,16 +4,20 @@ import (
"encoding/json"
"errors"
"net/http"
"time"
)
type API struct {
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"))
}
+7
View File
@@ -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:
+1 -1
View File
@@ -1,3 +1,3 @@
module searchgit
go 1.23
go 1.24
+18 -2
View File
@@ -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
View File
@@ -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)
}
+124
View File
@@ -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
View File
@@ -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)
}
+100
View File
@@ -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")
}
}
+301
View File
@@ -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)
}
+513 -24
View File
@@ -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) => {
// 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 = state.items[Number(rowEl.dataset.i)];
const r = items()[Number(rowEl.dataset.i)];
const topic = ev.target.closest('[data-topic]');
if (topic) {
$('#topic').value = topic.dataset.topic;
onFilterChange();
filterFromList({ topic: topic.dataset.topic });
return;
}
if (ev.target.closest('[data-act="lang"]')) {
writeFilters({ ...readFilters(), language: r.language });
onFilterChange();
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(rowEl, r);
});
$('#list').addEventListener('keydown', (ev) => {
toggleRow(listEl, rowEl, r);
});
listEl.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' || !ev.target.classList.contains('row')) return;
toggleRow(ev.target, state.items[Number(ev.target.dataset.i)]);
});
toggleRow(listEl, ev.target, items()[Number(ev.target.dataset.i)]);
});
}
listEvents($('#list'), () => state.items);
function toggleRow(rowEl, r) {
// A click on a topic or a language filters the search (from the review too).
function filterFromList(change) {
const base = view === 'search' ? readFilters() : review.search ? filtersFromParams(review.search.params) : {};
writeFilters({ ...base, ...change });
state.searchPending = false;
if (view !== 'search') location.hash = '';
onFilterChange();
}
function toggleRow(listEl, rowEl, r) {
if (state.open.has(r.fullName)) state.open.delete(r.fullName);
else state.open.add(r.fullName);
const i = Number(rowEl.dataset.i);
rowEl.outerHTML = rowHTML(r, i);
$(`#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
View File
@@ -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 &lt; 1 week</option>
<option value="1m" data-i18n="pushed1m">Pushed &lt; 1 month</option>
<option value="6m" data-i18n="pushed6m">Pushed &lt; 6 months</option>
<option value="1y" data-i18n="pushed1y">Pushed &lt; 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 &lt; 1 week</option>
<option value="1m" data-i18n="created1m">Created &lt; 1 month</option>
<option value="3m" data-i18n="created3m">Created &lt; 3 months</option>
<option value="6m" data-i18n="created6m">Created &lt; 6 months</option>
<option value="1y" data-i18n="created1y">Created &lt; 1 year</option>
<option value="5y" data-i18n="created5y">Created &lt; 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
View File
@@ -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;