Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71729f8aa6 | ||
|
|
02b51fed77 |
No files matched your search
@@ -5,3 +5,6 @@ GITHUB_TOKEN=
|
|||||||
CACHE_TTL=5m
|
CACHE_TTL=5m
|
||||||
AUTH_USER=
|
AUTH_USER=
|
||||||
AUTH_PASS=
|
AUTH_PASS=
|
||||||
|
# Time zone of the scheduled searches.
|
||||||
|
TZ=Europe/Paris
|
||||||
|
HISTORY_KEEP=100
|
||||||
@@ -28,3 +28,6 @@ go.work.sum
|
|||||||
# Binary
|
# Binary
|
||||||
/searchgit
|
/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 ----
|
# ---- Final image ----
|
||||||
FROM alpine:3.24.2
|
FROM alpine:3.24.2
|
||||||
RUN apk add --no-cache ca-certificates \
|
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
|
COPY --from=build /out/searchgit /usr/local/bin/searchgit
|
||||||
USER searchgit
|
USER searchgit
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
VOLUME /data
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
||||||
ENTRYPOINT ["/usr/local/bin/searchgit"]
|
ENTRYPOINT ["/usr/local/bin/searchgit"]
|
||||||
@@ -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
|
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
|
## Getting started
|
||||||
@@ -18,7 +19,7 @@ docker compose up -d --build
|
|||||||
|
|
||||||
Then open <http://localhost:8080>.
|
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
|
## 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.
|
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
|
## Configuration
|
||||||
|
|
||||||
| Variable | Default | Role |
|
| 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 |
|
| `CACHE_TTL` | `5m` | identical searches are served from memory for this long |
|
||||||
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP basic authentication (except `/healthz`) |
|
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP basic authentication (except `/healthz`) |
|
||||||
| `GITHUB_API` | `https://api.github.com` | API base URL (GitHub Enterprise) |
|
| `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
|
## 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=`
|
- `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`)
|
(`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`
|
- `GET /healthz`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -4,16 +4,20 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type API struct {
|
type API struct {
|
||||||
gh *GitHub
|
gh *GitHub
|
||||||
|
store *Store
|
||||||
|
sched *Scheduler
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) Routes(mux *http.ServeMux) {
|
func (a *API) Routes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /healthz", a.health)
|
mux.HandleFunc("GET /healthz", a.health)
|
||||||
mux.HandleFunc("GET /api/search", a.search)
|
mux.HandleFunc("GET /api/search", a.search)
|
||||||
mux.HandleFunc("GET /api/status", a.status)
|
mux.HandleFunc("GET /api/status", a.status)
|
||||||
|
a.savedRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
// codedError carries a stable code that the UI translates.
|
// 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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"token": a.gh.HasToken(),
|
"token": a.gh.HasToken(),
|
||||||
"rate": a.gh.Rate(),
|
"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) {
|
func (a *API) health(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,10 @@ services:
|
|||||||
CACHE_TTL: ${CACHE_TTL:-5m} # identical searches are served from memory
|
CACHE_TTL: ${CACHE_TTL:-5m} # identical searches are served from memory
|
||||||
AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication
|
AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication
|
||||||
AUTH_PASS: ${AUTH_PASS:-}
|
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:
|
||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata" // TZ works in the minimal Docker image
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed web
|
//go:embed web
|
||||||
@@ -33,6 +35,13 @@ func getenvDuration(key string, def time.Duration) time.Duration {
|
|||||||
return def
|
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() {
|
func main() {
|
||||||
httpAddr := getenv("HTTP_ADDR", ":8080")
|
httpAddr := getenv("HTTP_ADDR", ":8080")
|
||||||
token := os.Getenv("GITHUB_TOKEN")
|
token := os.Getenv("GITHUB_TOKEN")
|
||||||
@@ -45,8 +54,15 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
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()
|
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)))
|
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -65,7 +81,7 @@ func main() {
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
log.Printf("GITHUB_TOKEN not set: GitHub allows only 10 searches per minute")
|
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) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatalf("http: %v", err)
|
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)
|
||||||
|
}
|
||||||
+511
-22
@@ -68,6 +68,41 @@ const I18N = {
|
|||||||
syntaxHelp: 'The search field accepts GitHub qualifiers, combined with the filters above the list:',
|
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',
|
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',
|
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: {
|
fr: {
|
||||||
locale: 'fr-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 :',
|
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',
|
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',
|
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 '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(url) {
|
async function api(url, opts) {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url, opts);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let data = null;
|
let data = null;
|
||||||
try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
|
try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
|
||||||
@@ -191,8 +261,8 @@ function apiError(res, text, data) {
|
|||||||
|
|
||||||
function toast(msg) {
|
function toast(msg) {
|
||||||
const el = $('#toast');
|
const el = $('#toast');
|
||||||
const dlg = $('#settingsDlg');
|
const dlg = document.querySelector('dialog[open]');
|
||||||
(dlg.open ? dlg : document.body).append(el);
|
(dlg || document.body).append(el);
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
clearTimeout(toast.tm);
|
clearTimeout(toast.tm);
|
||||||
@@ -291,7 +361,11 @@ function syncURL(f) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filtersFromURL() {
|
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 = {};
|
const f = {};
|
||||||
for (const k of FILTERS) f[k] = src.get(k) || '';
|
for (const k of FILTERS) f[k] = src.get(k) || '';
|
||||||
for (const k of TOGGLES) f[k] = src.get(k) === '1';
|
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) {
|
function rowHTML(r, i) {
|
||||||
const open = state.open.has(r.fullName);
|
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>` : '');
|
+ (r.fork ? `<span class="badge">${esc(t('fork'))}</span>` : '');
|
||||||
const topics = r.topics.length
|
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>`
|
? `<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
|
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 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>';
|
: '<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">
|
<img class="av" src="${esc(r.avatar)}${r.avatar.includes('?') ? '&' : '?'}s=72" alt="" loading="lazy">
|
||||||
<div class="main">
|
<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>
|
<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}
|
${topics}
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
${lang}
|
${delta}${lang}
|
||||||
<span class="stars" title="${esc(fmtNum(r.stars))} ${esc(t('fStars'))}">${ICON_STAR}${fmtCompact(r.stars)}</span>
|
<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(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>
|
<span title="${esc(t('fPushed'))} ${esc(fmtDate(r.pushedAt))}">${ICON_CLOCK}${esc(fmtAgo(r.pushedAt))}</span>
|
||||||
@@ -449,6 +528,7 @@ async function loadStatus() {
|
|||||||
try {
|
try {
|
||||||
const s = await api('/api/status');
|
const s = await api('/api/status');
|
||||||
state.token = s.token;
|
state.token = s.token;
|
||||||
|
state.tz = s.tz || '';
|
||||||
if (s.rate && s.rate.limit) state.rate = s.rate;
|
if (s.rate && s.rate.limit) state.rate = s.rate;
|
||||||
} catch { state.token = null; }
|
} catch { state.token = null; }
|
||||||
renderStatus();
|
renderStatus();
|
||||||
@@ -456,38 +536,49 @@ async function loadStatus() {
|
|||||||
|
|
||||||
/* ================= List interactions ================= */
|
/* ================= 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');
|
const rowEl = ev.target.closest('.row');
|
||||||
if (!rowEl) return;
|
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]');
|
const topic = ev.target.closest('[data-topic]');
|
||||||
if (topic) {
|
if (topic) {
|
||||||
$('#topic').value = topic.dataset.topic;
|
filterFromList({ topic: topic.dataset.topic });
|
||||||
onFilterChange();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.target.closest('[data-act="lang"]')) {
|
if (ev.target.closest('[data-act="lang"]')) {
|
||||||
writeFilters({ ...readFilters(), language: r.language });
|
filterFromList({ language: r.language });
|
||||||
onFilterChange();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cp = ev.target.closest('[data-copy]');
|
const cp = ev.target.closest('[data-copy]');
|
||||||
if (cp) { copy(cp.dataset.copy); return; }
|
if (cp) { copy(cp.dataset.copy); return; }
|
||||||
if (ev.target.closest('a, .details')) return;
|
if (ev.target.closest('a, .details')) return;
|
||||||
toggleRow(rowEl, r);
|
toggleRow(listEl, rowEl, r);
|
||||||
});
|
});
|
||||||
|
listEl.addEventListener('keydown', (ev) => {
|
||||||
$('#list').addEventListener('keydown', (ev) => {
|
|
||||||
if (ev.key !== 'Enter' || !ev.target.classList.contains('row')) return;
|
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);
|
if (state.open.has(r.fullName)) state.open.delete(r.fullName);
|
||||||
else state.open.add(r.fullName);
|
else state.open.add(r.fullName);
|
||||||
const i = Number(rowEl.dataset.i);
|
const i = Number(rowEl.dataset.i);
|
||||||
rowEl.outerHTML = rowHTML(r, 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 ================= */
|
/* ================= Filter events ================= */
|
||||||
@@ -516,7 +607,7 @@ $('#resetBtn').addEventListener('click', () => {
|
|||||||
$('#moreBtn').addEventListener('click', () => search(true));
|
$('#moreBtn').addEventListener('click', () => search(true));
|
||||||
|
|
||||||
document.addEventListener('keydown', (ev) => {
|
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();
|
ev.preventDefault();
|
||||||
$('#q').focus();
|
$('#q').focus();
|
||||||
$('#q').select();
|
$('#q').select();
|
||||||
@@ -567,6 +658,8 @@ function setLang(next) {
|
|||||||
applyLang();
|
applyLang();
|
||||||
renderList();
|
renderList();
|
||||||
renderStatus();
|
renderStatus();
|
||||||
|
renderSaved();
|
||||||
|
renderReview();
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#langSwitch').addEventListener('click', (ev) => {
|
$('#langSwitch').addEventListener('click', (ev) => {
|
||||||
@@ -663,6 +756,400 @@ $('#settingsBtn').addEventListener('click', () => {
|
|||||||
$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close());
|
$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close());
|
||||||
$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.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 ================= */
|
/* ================= Startup ================= */
|
||||||
|
|
||||||
applyDensity(store.get('density', 'comfortable'));
|
applyDensity(store.get('density', 'comfortable'));
|
||||||
@@ -670,5 +1157,7 @@ applyLang();
|
|||||||
renderInterface();
|
renderInterface();
|
||||||
writeFilters(filtersFromURL());
|
writeFilters(filtersFromURL());
|
||||||
loadStatus();
|
loadStatus();
|
||||||
search();
|
route();
|
||||||
|
if (view === 'search') search();
|
||||||
|
else state.searchPending = true;
|
||||||
setInterval(() => { if (!document.hidden) renderStatus(); }, 5000); // reset countdown
|
setInterval(() => { if (!document.hidden) renderStatus(); }, 5000); // reset countdown
|
||||||
+137
-1
@@ -23,7 +23,19 @@
|
|||||||
<span>Searchgit</span>
|
<span>Searchgit</span>
|
||||||
</div>
|
</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>
|
<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">
|
<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">
|
<select id="in" class="chip-select" data-i18n-aria="inAria">
|
||||||
@@ -47,6 +59,7 @@
|
|||||||
<div class="progress" aria-hidden="true"></div>
|
<div class="progress" aria-hidden="true"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="viewSearch" class="view">
|
||||||
<section class="filters">
|
<section class="filters">
|
||||||
<select id="language" data-i18n-aria="languageAria"><option value="" data-i18n="langAll">All languages</option></select>
|
<select id="language" data-i18n-aria="languageAria"><option value="" data-i18n="langAll">All languages</option></select>
|
||||||
<select id="stars" data-i18n-aria="starsAria">
|
<select id="stars" data-i18n-aria="starsAria">
|
||||||
@@ -58,6 +71,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<select id="pushed" data-i18n-aria="pushedAria">
|
<select id="pushed" data-i18n-aria="pushedAria">
|
||||||
<option value="" data-i18n="pushedAll">Any activity</option>
|
<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="1m" data-i18n="pushed1m">Pushed < 1 month</option>
|
||||||
<option value="6m" data-i18n="pushed6m">Pushed < 6 months</option>
|
<option value="6m" data-i18n="pushed6m">Pushed < 6 months</option>
|
||||||
<option value="1y" data-i18n="pushed1y">Pushed < 1 year</option>
|
<option value="1y" data-i18n="pushed1y">Pushed < 1 year</option>
|
||||||
@@ -65,7 +79,10 @@
|
|||||||
</select>
|
</select>
|
||||||
<select id="created" data-i18n-aria="createdAria">
|
<select id="created" data-i18n-aria="createdAria">
|
||||||
<option value="" data-i18n="createdAll">Any creation date</option>
|
<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="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="1y" data-i18n="created1y">Created < 1 year</option>
|
||||||
<option value="5y" data-i18n="created5y">Created < 5 years</option>
|
<option value="5y" data-i18n="created5y">Created < 5 years</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -98,6 +115,10 @@
|
|||||||
<option value="updated" data-i18n="sortUpdated">Recently updated</option>
|
<option value="updated" data-i18n="sortUpdated">Recently updated</option>
|
||||||
<option value="help-wanted-issues" data-i18n="sortHelp">Help wanted issues</option>
|
<option value="help-wanted-issues" data-i18n="sortHelp">Help wanted issues</option>
|
||||||
</select>
|
</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">
|
<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>
|
<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>
|
<span class="lbl">CSV</span>
|
||||||
@@ -116,6 +137,70 @@
|
|||||||
<p><strong data-i18n="emptyTitle">No matching repositories.</strong></p>
|
<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>
|
<p class="muted" data-i18n="emptyHint">Loosen a filter or change the search words.</p>
|
||||||
</div>
|
</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">
|
<footer class="status">
|
||||||
<span id="conn" class="conn"></span>
|
<span id="conn" class="conn"></span>
|
||||||
@@ -218,6 +303,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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>
|
<div id="toast" class="toast" hidden></div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
+115
@@ -348,8 +348,105 @@ dialog.settings {
|
|||||||
.seg button:hover { color: var(--text); }
|
.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); }
|
.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 ---------- */
|
/* ---------- Medium screens: the meta columns go under the description ---------- */
|
||||||
@media (max-width: 1100px) {
|
@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"; }
|
.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; }
|
.meta { display: flex; flex-wrap: wrap; gap: 4px 16px; padding-top: 6px; }
|
||||||
}
|
}
|
||||||
@@ -374,6 +471,24 @@ dialog.settings {
|
|||||||
.status { padding: 6px 16px; }
|
.status { padding: 6px 16px; }
|
||||||
.gh-query { display: none; }
|
.gh-query { display: none; }
|
||||||
dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); }
|
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-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||||
.set-nav {
|
.set-nav {
|
||||||
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 6px;
|
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 6px;
|
||||||
|
|||||||
Reference in new issue
Block a user