diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b8fed6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go . +RUN CGO_ENABLED=0 go build -o /todo-glance . + +FROM alpine:3.21 +COPY --from=build /todo-glance /todo-glance +VOLUME /data +ENV TODO_DB_PATH=/data/todos.db +EXPOSE 8081 +ENTRYPOINT ["/todo-glance"] diff --git a/README.md b/README.md index e69de29..663c666 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,110 @@ +# Todo-Glance + +A server-backed CRUD todo widget for [Glance](https://github.com/glanceapp/glance). Stores tasks in SQLite instead of browser localStorage, so your todos persist across devices and sessions. + +Integrates as a Glance Extension widget with native theming via Glance CSS variables. + +## Stack + +- **Go** — single `main.go`, no frameworks +- **SQLite** via [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) — pure Go, no CGO required +- **Vanilla JS** — embedded in the Go binary, no build step + +## Quick Start + +### Docker Compose (recommended) + +```bash +docker compose up --build +``` + +- Glance dashboard: `http://localhost:8080` +- Todo API: `http://localhost:8081` + +The included `glance.yml` has the widget pre-configured. Edit it to add your other Glance widgets. + +### Standalone + +```bash +go build -o todo-glance . +./todo-glance +``` + +Then add the widget to your existing Glance config: + +```yaml +- type: extension + url: http://todo-glance:8081/ + allow-potentially-dangerous-html: true + cache: 1s +``` + +Replace `todo-glance` with the hostname or IP where the server is reachable from Glance. + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `TODO_PORT` | `8081` | Server listen port | +| `TODO_DB_PATH` | `./todos.db` | SQLite database file path | +| `TODO_EXTERNAL_URL` | *(from Host header)* | URL browsers use to reach the API. Required when the browser-facing URL differs from the internal one (e.g. behind a reverse proxy). | + +## API + +| Method | Path | Description | +|---|---|---| +| `GET` | `/` | Extension widget HTML (returns `Widget-Title` and `Widget-Content-Type` headers) | +| `GET` | `/api/todos` | List all todos | +| `POST` | `/api/todos` | Create a todo — `{"text": "...", "priority": 0-3, "tags": [...]}` | +| `PUT` | `/api/todos/{id}` | Update a todo — `{"text": "...", "done": true, "priority": 0-3, "tags": [...]}` | +| `DELETE` | `/api/todos/{id}` | Delete a todo — returns 204 | + +Priority levels: `0` = none, `1` = low, `2` = medium, `3` = high. Todos are sorted by: incomplete first, then highest priority, then newest. + +### Examples + +```bash +# Create with priority and tags +curl -X POST -H 'Content-Type: application/json' \ + -d '{"text":"Fix login bug","priority":3,"tags":["work","urgent"]}' \ + http://localhost:8081/api/todos + +# Create simple +curl -X POST -H 'Content-Type: application/json' \ + -d '{"text":"Buy milk"}' http://localhost:8081/api/todos + +# List (sorted by priority) +curl http://localhost:8081/api/todos + +# Mark done +curl -X PUT -H 'Content-Type: application/json' \ + -d '{"done":true}' http://localhost:8081/api/todos/1 + +# Change priority +curl -X PUT -H 'Content-Type: application/json' \ + -d '{"priority":2}' http://localhost:8081/api/todos/1 + +# Update tags +curl -X PUT -H 'Content-Type: application/json' \ + -d '{"tags":["work","reviewed"]}' http://localhost:8081/api/todos/1 + +# Delete +curl -X DELETE http://localhost:8081/api/todos/1 +``` + +## Architecture + +``` +Browser (Glance page) + │ + │ GET / Glance fetches widget HTML from todo-glance + │ ─────────────► (internal Docker network) + │ + │ API calls JS in the widget calls /api/* endpoints + │ ─────────────► (browser hits TODO_EXTERNAL_URL / localhost:8081) + │ + ▼ +todo-glance ──► SQLite file (/data/todos.db in Docker) +``` + +The HTML is served to Glance over the internal network. Once rendered in the browser, the embedded JS makes API calls directly to the todo-glance server using the external URL. CORS headers are set on all `/api/` endpoints to allow this cross-origin access. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..195a1c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + glance: + image: glanceapp/glance + ports: + - "8080:8080" + volumes: + - ./glance.yml:/app/glance.yml + depends_on: + - todo-glance + + todo-glance: + build: . + ports: + - "8081:8081" + volumes: + - todo-data:/data + environment: + TODO_EXTERNAL_URL: "http://localhost:8081" + +volumes: + todo-data: diff --git a/glance.yml b/glance.yml new file mode 100644 index 0000000..ffad188 --- /dev/null +++ b/glance.yml @@ -0,0 +1,9 @@ +pages: + - name: Home + columns: + - size: full + widgets: + - type: extension + url: http://todo-glance:8081/ + allow-potentially-dangerous-html: true + cache: 1s diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7a8019c --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module todo-glance + +go 1.25.5 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.37.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5cf3e13 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..35b92a8 --- /dev/null +++ b/main.go @@ -0,0 +1,603 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +type Todo struct { + ID int64 `json:"id"` + Text string `json:"text"` + Done bool `json:"done"` + Priority int `json:"priority"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"created_at"` +} + +var db *sql.DB + +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func parseTags(s string) []string { + if s == "" { + return []string{} + } + seen := map[string]bool{} + var tags []string + for _, t := range strings.Split(s, ",") { + t = strings.TrimSpace(t) + if t != "" && !seen[t] { + seen[t] = true + tags = append(tags, t) + } + } + return tags +} + +func joinTags(tags []string) string { + var cleaned []string + seen := map[string]bool{} + for _, t := range tags { + t = strings.TrimSpace(t) + if t != "" && !seen[t] { + seen[t] = true + cleaned = append(cleaned, t) + } + } + return strings.Join(cleaned, ",") +} + +func scanTodo(row interface{ Scan(...any) error }) (Todo, error) { + var t Todo + var done int + var tags string + err := row.Scan(&t.ID, &t.Text, &done, &t.Priority, &tags, &t.CreatedAt) + if err != nil { + return t, err + } + t.Done = done == 1 + t.Tags = parseTags(tags) + return t, nil +} + +const selectCols = "id, text, done, priority, tags, created_at" + +func initDB(path string) { + var err error + db, err = sql.Open("sqlite", path) + if err != nil { + log.Fatal(err) + } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + priority INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`) + if err != nil { + log.Fatal(err) + } + // migrate older schemas + db.Exec("ALTER TABLE todos ADD COLUMN priority INTEGER NOT NULL DEFAULT 0") + db.Exec("ALTER TABLE todos ADD COLUMN tags TEXT NOT NULL DEFAULT ''") +} + +func cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func handleWidget(w http.ResponseWriter, r *http.Request) { + baseURL := env("TODO_EXTERNAL_URL", "") + if baseURL == "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + baseURL = scheme + "://" + r.Host + } + baseURL = strings.TrimRight(baseURL, "/") + + w.Header().Set("Widget-Title", "Todos") + w.Header().Set("Widget-Content-Type", "html") + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, widgetHTML, baseURL) +} + +func handleListTodos(w http.ResponseWriter, r *http.Request) { + rows, err := db.Query("SELECT " + selectCols + " FROM todos ORDER BY done ASC, priority DESC, created_at DESC") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + todos := []Todo{} + for rows.Next() { + t, err := scanTodo(rows) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + todos = append(todos, t) + } + writeJSON(w, http.StatusOK, todos) +} + +func handleCreateTodo(w http.ResponseWriter, r *http.Request) { + var input struct { + Text string `json:"text"` + Priority *int `json:"priority"` + Tags []string `json:"tags"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + input.Text = strings.TrimSpace(input.Text) + if input.Text == "" { + http.Error(w, "text is required", http.StatusBadRequest) + return + } + + priority := 0 + if input.Priority != nil { + priority = clampPriority(*input.Priority) + } + + res, err := db.Exec( + "INSERT INTO todos (text, priority, tags) VALUES (?, ?, ?)", + input.Text, priority, joinTags(input.Tags), + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + id, _ := res.LastInsertId() + + t, err := scanTodo(db.QueryRow("SELECT "+selectCols+" FROM todos WHERE id = ?", id)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, t) +} + +func handleUpdateTodo(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var input struct { + Text *string `json:"text"` + Done *bool `json:"done"` + Priority *int `json:"priority"` + Tags []string `json:"tags"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + if input.Text != nil { + if _, err := db.Exec("UPDATE todos SET text = ? WHERE id = ?", strings.TrimSpace(*input.Text), id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + if input.Done != nil { + done := 0 + if *input.Done { + done = 1 + } + if _, err := db.Exec("UPDATE todos SET done = ? WHERE id = ?", done, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + if input.Priority != nil { + if _, err := db.Exec("UPDATE todos SET priority = ? WHERE id = ?", clampPriority(*input.Priority), id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + if input.Tags != nil { + if _, err := db.Exec("UPDATE todos SET tags = ? WHERE id = ?", joinTags(input.Tags), id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + t, err := scanTodo(db.QueryRow("SELECT "+selectCols+" FROM todos WHERE id = ?", id)) + if err == sql.ErrNoRows { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, t) +} + +func handleDeleteTodo(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + res, err := db.Exec("DELETE FROM todos WHERE id = ?", id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + n, _ := res.RowsAffected() + if n == 0 { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func clampPriority(p int) int { + if p < 0 { + return 0 + } + if p > 3 { + return 3 + } + return p +} + +func main() { + port := env("TODO_PORT", "8081") + dbPath := env("TODO_DB_PATH", "./todos.db") + + initDB(dbPath) + defer db.Close() + + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", handleWidget) + mux.HandleFunc("GET /api/todos", handleListTodos) + mux.HandleFunc("POST /api/todos", handleCreateTodo) + mux.HandleFunc("PUT /api/todos/{id}", handleUpdateTodo) + mux.HandleFunc("DELETE /api/todos/{id}", handleDeleteTodo) + + log.Printf("todo-glance listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, cors(mux))) +} + +const widgetHTML = ` + + + + + +
+
+
+ + + +
+
+ +
+
+ +
+ + +`