diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..6a1bb3e --- /dev/null +++ b/api/api.go @@ -0,0 +1,249 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "woke/db" +) + +// Notifier is called after mutating operations to notify the TUI. +type Notifier func() + +// Server provides a REST API for alarm CRUD. +type Server struct { + store *db.Store + mux *http.ServeMux + notify Notifier +} + +func New(store *db.Store, notify Notifier) *Server { + s := &Server{store: store, notify: notify} + s.mux = http.NewServeMux() + s.mux.HandleFunc("GET /api/alarms", s.listAlarms) + s.mux.HandleFunc("GET /api/alarms/{id}", s.getAlarm) + s.mux.HandleFunc("POST /api/alarms", s.createAlarm) + s.mux.HandleFunc("PUT /api/alarms/{id}", s.updateAlarm) + s.mux.HandleFunc("DELETE /api/alarms/{id}", s.deleteAlarm) + s.mux.HandleFunc("PATCH /api/alarms/{id}/toggle", s.toggleAlarm) + return s +} + +func (s *Server) Handler() http.Handler { + return s.mux +} + +type alarmRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Time string `json:"time"` + Trigger string `json:"trigger"` + SoundPath string `json:"sound_path"` + Enabled *bool `json:"enabled"` +} + +type alarmResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Time string `json:"time"` + Trigger string `json:"trigger"` + SoundPath string `json:"sound_path"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastTriggered *string `json:"last_triggered"` + SnoozeCount int `json:"snooze_count"` +} + +func toResponse(a db.Alarm) alarmResponse { + r := alarmResponse{ + ID: a.ID, + Name: a.Name, + Description: a.Description, + Time: a.Time, + Trigger: a.Trigger, + SoundPath: a.SoundPath, + Enabled: a.Enabled, + CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"), + SnoozeCount: a.SnoozeCount, + } + if a.LastTriggered != nil { + t := a.LastTriggered.Format("2006-01-02T15:04:05Z") + r.LastTriggered = &t + } + return 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 writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func (s *Server) listAlarms(w http.ResponseWriter, r *http.Request) { + alarms, err := s.store.ListAlarms() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list alarms") + return + } + resp := make([]alarmResponse, len(alarms)) + for i, a := range alarms { + resp[i] = toResponse(a) + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) getAlarm(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + alarm, err := s.store.GetAlarm(id) + if err != nil { + writeError(w, http.StatusNotFound, "alarm not found") + return + } + writeJSON(w, http.StatusOK, toResponse(alarm)) +} + +func (s *Server) createAlarm(w http.ResponseWriter, r *http.Request) { + var req alarmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if req.Trigger == "" { + writeError(w, http.StatusBadRequest, "trigger is required") + return + } + + alarm := db.Alarm{ + Name: req.Name, + Description: req.Description, + Time: req.Time, + Trigger: req.Trigger, + SoundPath: req.SoundPath, + Enabled: true, + } + if alarm.SoundPath == "" { + alarm.SoundPath = "default" + } + if req.Enabled != nil { + alarm.Enabled = *req.Enabled + } + + id, err := s.store.CreateAlarm(alarm) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create alarm") + return + } + + created, err := s.store.GetAlarm(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read created alarm") + return + } + s.notify() + writeJSON(w, http.StatusCreated, toResponse(created)) +} + +func (s *Server) updateAlarm(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + existing, err := s.store.GetAlarm(id) + if err != nil { + writeError(w, http.StatusNotFound, "alarm not found") + return + } + + var req alarmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + if req.Name != "" { + existing.Name = req.Name + } + if req.Description != "" { + existing.Description = req.Description + } + if req.Time != "" { + existing.Time = req.Time + } + if req.Trigger != "" { + existing.Trigger = req.Trigger + } + if req.SoundPath != "" { + existing.SoundPath = req.SoundPath + } + if req.Enabled != nil { + existing.Enabled = *req.Enabled + } + + if err := s.store.UpdateAlarm(existing); err != nil { + writeError(w, http.StatusInternalServerError, "failed to update alarm") + return + } + + updated, _ := s.store.GetAlarm(id) + s.notify() + writeJSON(w, http.StatusOK, toResponse(updated)) +} + +func (s *Server) deleteAlarm(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + if _, err := s.store.GetAlarm(id); err != nil { + writeError(w, http.StatusNotFound, "alarm not found") + return + } + + if err := s.store.DeleteAlarm(id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete alarm") + return + } + s.notify() + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) toggleAlarm(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + if _, err := s.store.GetAlarm(id); err != nil { + writeError(w, http.StatusNotFound, "alarm not found") + return + } + + if err := s.store.ToggleAlarm(id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to toggle alarm") + return + } + + toggled, _ := s.store.GetAlarm(id) + s.notify() + writeJSON(w, http.StatusOK, toResponse(toggled)) +} diff --git a/main.go b/main.go index df122b3..922a683 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,9 @@ package main import ( "fmt" + "net/http" "os" + "woke/api" "woke/db" "woke/player" "woke/scheduler" @@ -11,7 +13,57 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +const helpText = `woke - TUI alarm clock with REST API + +Usage: + woke Start the TUI (API server runs on :9119) + woke --help Show this help + +API endpoints (http://localhost:9119): + + List alarms: + curl http://localhost:9119/api/alarms + + Get alarm: + curl http://localhost:9119/api/alarms/1 + + Create alarm (one-shot): + curl -X POST http://localhost:9119/api/alarms \ + -H 'Content-Type: application/json' \ + -d '{"name": "Standup", "time": "09:00", "trigger": "once"}' + + Create alarm (cron, weekdays at 7:30): + curl -X POST http://localhost:9119/api/alarms \ + -H 'Content-Type: application/json' \ + -d '{"name": "Morning", "time": "07:30", "trigger": "30 7 * * 1-5"}' + + Update alarm: + curl -X PUT http://localhost:9119/api/alarms/1 \ + -H 'Content-Type: application/json' \ + -d '{"name": "New name", "time": "08:00"}' + + Toggle alarm on/off: + curl -X PATCH http://localhost:9119/api/alarms/1/toggle + + Delete alarm: + curl -X DELETE http://localhost:9119/api/alarms/1 + +TUI keybindings: + j/k Navigate alarms + a Add alarm + e Edit alarm + d Delete alarm + space Toggle enabled + c Settings + q Quit +` + func main() { + if len(os.Args) > 1 && (os.Args[1] == "--help" || os.Args[1] == "-h") { + fmt.Print(helpText) + os.Exit(0) + } + store, err := db.Open() if err != nil { fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err) @@ -27,6 +79,16 @@ func main() { model := ui.NewModel(store, sched, pl) p := tea.NewProgram(model, tea.WithAltScreen()) + // Start HTTP API server — notify TUI on mutations via p.Send + srv := api.New(store, func() { p.Send(ui.AlarmsChangedMsg{}) }) + go func() { + addr := ":9119" + fmt.Fprintf(os.Stderr, "API server listening on %s\n", addr) + if err := http.ListenAndServe(addr, srv.Handler()); err != nil { + fmt.Fprintf(os.Stderr, "API server error: %v\n", err) + } + }() + if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/ui/model.go b/ui/model.go index 02447d5..364a408 100644 --- a/ui/model.go +++ b/ui/model.go @@ -28,6 +28,7 @@ type alarmFiredMsg scheduler.AlarmEvent type alarmsLoadedMsg []db.Alarm type snoozeFireMsg db.Alarm type autoTimeoutMsg struct{} +type AlarmsChangedMsg struct{} // Model is the main bubbletea model. type Model struct { @@ -149,6 +150,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case AlarmsChangedMsg: + m.refreshAlarms() + return m, nil + case tickMsg: m.now = time.Time(msg) if m.firingAlarm != nil { diff --git a/woke b/woke index 11765cc..2a7e649 100755 Binary files a/woke and b/woke differ