Files
glance-todo/main.go
2026-03-09 08:19:51 +01:00

604 lines
14 KiB
Go

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 = `<!DOCTYPE html>
<html>
<head>
<style>
*{ box-sizing:border-box; margin:0; padding:0; }
.todo-container {
font-family: var(--font, sans-serif);
color: var(--color-text-base, #e2e8f0);
}
.todo-form {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.todo-form .todo-form-top {
display: flex;
gap: 8px;
width: 100%%;
}
.todo-form input[type="text"] {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--color-separator, #374151);
background: var(--color-widget-background-highlight, #1e293b);
color: var(--color-text-base, #e2e8f0);
font-size: 14px;
outline: none;
border-radius: 0;
}
.todo-form input[type="text"]:focus {
border-color: var(--color-primary, #6c9fe8);
}
.todo-form select {
padding: 6px 8px;
border: 1px solid var(--color-separator, #374151);
background: var(--color-widget-background-highlight, #1e293b);
color: var(--color-text-base, #e2e8f0);
font-size: 13px;
outline: none;
border-radius: 0;
cursor: pointer;
}
.todo-form button {
padding: 6px 14px;
border: none;
background: var(--color-primary, #6c9fe8);
color: var(--color-widget-background, #0f172a);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border-radius: 0;
}
.todo-form button:hover {
opacity: 0.85;
}
.todo-form .todo-form-bottom {
display: flex;
gap: 8px;
width: 100%%;
}
.todo-form .todo-form-bottom input {
flex: 1;
padding: 4px 10px;
border: 1px solid var(--color-separator, #374151);
background: var(--color-widget-background-highlight, #1e293b);
color: var(--color-text-base, #e2e8f0);
font-size: 12px;
outline: none;
border-radius: 0;
}
.todo-form .todo-form-bottom input:focus {
border-color: var(--color-primary, #6c9fe8);
}
.todo-list {
list-style: none;
}
.todo-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--color-separator, #374151);
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item input[type="checkbox"] {
cursor: pointer;
flex-shrink: 0;
accent-color: var(--color-primary, #6c9fe8);
}
.todo-item .priority-dot {
width: 8px;
height: 8px;
flex-shrink: 0;
}
.todo-item .priority-dot.p3 { background: var(--color-negative, #ef4444); }
.todo-item .priority-dot.p2 { background: #eab308; }
.todo-item .priority-dot.p1 { background: var(--color-primary, #6c9fe8); }
.todo-item .todo-content {
flex: 1;
min-width: 0;
}
.todo-item .todo-text {
font-size: 14px;
word-break: break-word;
}
.todo-item.done .todo-text {
text-decoration: line-through;
opacity: 0.5;
}
.todo-item .todo-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 3px;
}
.todo-tag {
font-size: 11px;
padding: 1px 6px;
background: var(--color-separator, #374151);
color: var(--color-text-subdue, #94a3b8);
border-radius: 0;
white-space: nowrap;
}
.todo-item .todo-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.todo-item .delete-btn,
.todo-item .priority-btn {
background: none;
border: none;
color: var(--color-text-subdue, #64748b);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
opacity: 0;
transition: opacity 0.15s;
border-radius: 0;
}
.todo-item:hover .delete-btn,
.todo-item:hover .priority-btn {
opacity: 1;
}
.todo-item .delete-btn:hover {
color: var(--color-negative, #ef4444);
}
.todo-item .priority-btn:hover {
color: var(--color-primary, #6c9fe8);
}
.todo-empty {
color: var(--color-text-subdue, #64748b);
font-size: 13px;
padding: 8px 0;
}
</style>
</head>
<body>
<div class="todo-container">
<form class="todo-form" onsubmit="addTodo(event)">
<div class="todo-form-top">
<input type="text" id="todo-input" placeholder="Add a task..." autocomplete="off">
<select id="todo-priority">
<option value="0">—</option>
<option value="1">Low</option>
<option value="2">Med</option>
<option value="3">High</option>
</select>
<button type="submit">Add</button>
</div>
<div class="todo-form-bottom">
<input type="text" id="todo-tags" placeholder="Tags (comma separated)" autocomplete="off">
</div>
</form>
<ul class="todo-list" id="todo-list"></ul>
</div>
<script>
const API = "%s/api/todos";
const list = document.getElementById("todo-list");
const inputText = document.getElementById("todo-input");
const inputPriority = document.getElementById("todo-priority");
const inputTags = document.getElementById("todo-tags");
const PRIORITY_LABELS = ["", "Low", "Med", "High"];
async function loadTodos() {
const res = await fetch(API);
const todos = await res.json();
list.innerHTML = "";
if (todos.length === 0) {
list.innerHTML = '<li class="todo-empty">No tasks yet</li>';
return;
}
todos.forEach(t => {
const li = document.createElement("li");
li.className = "todo-item" + (t.done ? " done" : "");
let dot = "";
if (t.priority > 0) {
dot = '<span class="priority-dot p' + t.priority + '" title="' + PRIORITY_LABELS[t.priority] + ' priority"></span>';
}
let tagsHTML = "";
if (t.tags && t.tags.length > 0) {
tagsHTML = '<div class="todo-tags">' + t.tags.map(tag => '<span class="todo-tag">' + esc(tag) + '</span>').join("") + '</div>';
}
li.innerHTML =
'<input type="checkbox"' + (t.done ? " checked" : "") + ' onchange="toggleTodo(' + t.id + ', this.checked)">' +
dot +
'<div class="todo-content"><span class="todo-text">' + esc(t.text) + '</span>' + tagsHTML + '</div>' +
'<div class="todo-actions">' +
'<button class="priority-btn" onclick="cyclePriority(' + t.id + ',' + t.priority + ')" title="Change priority">&#9650;</button>' +
'<button class="delete-btn" onclick="deleteTodo(' + t.id + ')">&times;</button>' +
'</div>';
list.appendChild(li);
});
}
async function addTodo(e) {
e.preventDefault();
const text = inputText.value.trim();
if (!text) return;
const priority = parseInt(inputPriority.value, 10);
const tags = inputTags.value.split(",").map(s => s.trim()).filter(Boolean);
await fetch(API, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({text, priority, tags})
});
inputText.value = "";
inputPriority.value = "0";
inputTags.value = "";
loadTodos();
}
async function toggleTodo(id, done) {
await fetch(API + "/" + id, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({done})
});
loadTodos();
}
async function cyclePriority(id, current) {
const next = (current + 1) %% 4;
await fetch(API + "/" + id, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({priority: next})
});
loadTodos();
}
async function deleteTodo(id) {
await fetch(API + "/" + id, {method: "DELETE"});
loadTodos();
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
loadTodos();
</script>
</body>
</html>`