604 lines
14 KiB
Go
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">▲</button>' +
|
|
'<button class="delete-btn" onclick="deleteTodo(' + t.id + ')">×</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>`
|