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 = `