package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"slices"
"strconv"
"strings"
"sync"
"unicode/utf8"
)
const todoFile = "todo.json"
const maxNameLength = 100
type task struct {
Name string
}
// errNoTask means that no task has the index asked for.
var errNoTask = errors.New("no task has that index")
// mu protects tasks, and the file that holds it.
var mu sync.Mutex
var tasks = []task{}
func loadTasks() ([]task, error) {
data, err := os.ReadFile(todoFile)
if errors.Is(err, os.ErrNotExist) {
return []task{}, nil
}
if err != nil {
return nil, err
}
var loaded []task
err = json.Unmarshal(data, &loaded)
if err != nil {
return nil, fmt.Errorf("%s is not valid: %v", todoFile, err)
}
return loaded, nil
}
func saveTasks(list []task) error {
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
temporary := todoFile + ".tmp"
err = os.WriteFile(temporary, data, 0644)
if err != nil {
return err
}
return os.Rename(temporary, todoFile)
}
func allTasks() []task {
mu.Lock()
defer mu.Unlock()
return slices.Clone(tasks)
}
func addTask(t task) error {
mu.Lock()
defer mu.Unlock()
updated := append(slices.Clone(tasks), t)
err := saveTasks(updated)
if err != nil {
return err
}
tasks = updated
return nil
}
// removeTask removes the task at index n, counting from 0. The check is inside
// the lock, because another request could change the list at any moment.
func removeTask(n int) error {
mu.Lock()
defer mu.Unlock()
if n < 0 || n >= len(tasks) {
return errNoTask
}
updated := slices.Clone(tasks)
updated = append(updated[:n], updated[n+1:]...)
err := saveTasks(updated)
if err != nil {
return err
}
tasks = updated
return nil
}
const todoText = `
To-do
To-do
{{range $i, $task := .}}
-
{{$task.Name}}
{{else}}
- Nothing to do.
{{end}}
`
var todoTemplate = template.Must(template.New("todo").Parse(todoText))
func handleHome(w http.ResponseWriter, r *http.Request) {
var page bytes.Buffer
err := todoTemplate.Execute(&page, allTasks())
if err != nil {
fmt.Println("Template error:", err)
http.Error(w, "The page could not be built.", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
page.WriteTo(w)
}
func handleAdd(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "The task has no name.", http.StatusBadRequest)
return
}
if utf8.RuneCountInString(name) > maxNameLength {
http.Error(w, "The name is too long.", http.StatusBadRequest)
return
}
err := addTask(task{Name: name})
if err != nil {
fmt.Println("Save error:", err)
http.Error(w, "The task could not be saved.", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleDelete(w http.ResponseWriter, r *http.Request) {
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil {
http.Error(w, "The index must be a whole number.", http.StatusBadRequest)
return
}
err = removeTask(n)
if errors.Is(err, errNoTask) {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if err != nil {
fmt.Println("Save error:", err)
http.Error(w, "The list could not be saved.", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func main() {
loaded, err := loadTasks()
if err != nil {
fmt.Println("Could not load the list:", err)
fmt.Println("Fix or remove the file, then start the server again.")
return
}
tasks = loaded
http.HandleFunc("GET /{$}", handleHome)
http.HandleFunc("POST /tasks", handleAdd)
http.HandleFunc("POST /delete/{n}", handleDelete)
fmt.Println("Server running at http://localhost:8080")
fmt.Println("Press Ctrl+C to stop it.")
err = http.ListenAndServe("localhost:8080", nil)
if err != nil {
fmt.Println("Server stopped:", err)
}
}