Files
homeflow/internal/api/server.go
T
alexandrev 938b895d37 feat: v0.1.0 — UI, control flow, new actions
- Menu bar icon (systray) + embedded web UI (Monaco + Tailwind) at localhost:7070
- REST API: status, config editor, logs, import/export rules
- Control flow actions: if/else, switch/cases, for_each, per-action retry
- Condition evaluators: string, numeric, date, existence operators with AND/OR/NOT
- New actions: dedup, ocr (ocrmypdf/tesseract), image_resize/convert/info, read_metadata
- Pipeline vars: SetVar() lets any action publish data to subsequent steps
- Config: ActionDef supports nested sub-pipelines, RetryConfig, ConditionDef

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:07:39 +02:00

242 lines
6.5 KiB
Go

package api
import (
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"sync"
"time"
"gopkg.in/yaml.v3"
"github.com/alexandrev/homeflow/internal/config"
"github.com/alexandrev/homeflow/internal/state"
)
//go:embed ui
var uiFS embed.FS
// DaemonStatus is reported by the HTTP API.
type DaemonStatus struct {
Running bool `json:"running"`
Paused bool `json:"paused"`
StartedAt time.Time `json:"started_at"`
Watches []string `json:"watches"`
Processed int `json:"processed_total"`
Errors int `json:"errors_total"`
}
// Server holds shared state exposed to the REST API.
type Server struct {
cfg *config.Config
cfgPath string
st *state.DB
mu sync.RWMutex
status DaemonStatus
pauseCh chan bool // send true=pause, false=resume
}
func New(cfg *config.Config, cfgPath string, st *state.DB) *Server {
watches := make([]string, len(cfg.Watches))
for i, w := range cfg.Watches {
watches[i] = w.Path
}
return &Server{
cfg: cfg,
cfgPath: cfgPath,
st: st,
pauseCh: make(chan bool, 1),
status: DaemonStatus{
Running: true,
StartedAt: time.Now(),
Watches: watches,
},
}
}
func (s *Server) SetPaused(p bool) {
s.mu.Lock()
s.status.Paused = p
s.mu.Unlock()
}
func (s *Server) PauseCh() <-chan bool { return s.pauseCh }
func (s *Server) IncrProcessed() {
s.mu.Lock()
s.status.Processed++
s.mu.Unlock()
}
func (s *Server) IncrErrors() {
s.mu.Lock()
s.status.Errors++
s.mu.Unlock()
}
func (s *Server) Start(port int) error {
mux := http.NewServeMux()
// ── API ──────────────────────────────────────────────────────────────────
mux.HandleFunc("GET /api/status", s.handleStatus)
mux.HandleFunc("GET /api/config", s.handleGetConfig)
mux.HandleFunc("POST /api/config", s.handleSaveConfig)
mux.HandleFunc("GET /api/logs", s.handleLogs)
mux.HandleFunc("GET /api/export", s.handleExport)
mux.HandleFunc("POST /api/import", s.handleImport)
mux.HandleFunc("POST /api/pause", s.handlePause)
mux.HandleFunc("POST /api/resume", s.handleResume)
// ── Embedded UI ──────────────────────────────────────────────────────────
sub, err := fs.Sub(uiFS, "ui")
if err != nil {
return err
}
mux.Handle("/", http.FileServer(http.FS(sub)))
addr := fmt.Sprintf("127.0.0.1:%d", port)
log.Printf("[api] listening on http://%s", addr)
return http.ListenAndServe(addr, corsMiddleware(mux))
}
// ── handlers ─────────────────────────────────────────────────────────────────
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
st := s.status
s.mu.RUnlock()
jsonOK(w, st)
}
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
data, err := os.ReadFile(s.cfgPath)
if err != nil {
jsonErr(w, err, 500)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(data)
}
func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
jsonErr(w, err, 400)
return
}
// Validate: try to parse as a Config (dry — no pass resolution)
var root yaml.Node
if err := yaml.Unmarshal(body, &root); err != nil {
jsonErr(w, fmt.Errorf("invalid YAML: %w", err), 400)
return
}
if err := os.WriteFile(s.cfgPath, body, 0o644); err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]string{"status": "saved"})
}
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
n := 50
records, err := s.st.Recent(n)
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, records)
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
data, err := os.ReadFile(s.cfgPath)
if err != nil {
jsonErr(w, err, 500)
return
}
w.Header().Set("Content-Disposition", "attachment; filename=homeflow-config.yaml")
w.Header().Set("Content-Type", "application/yaml")
w.Write(data)
}
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
jsonErr(w, err, 400)
return
}
mode := r.URL.Query().Get("mode") // replace | merge (default replace)
if mode == "merge" {
existing, err := os.ReadFile(s.cfgPath)
if err == nil {
var existingCfg, incoming config.Config
if err1 := yaml.Unmarshal(existing, &existingCfg); err1 == nil {
if err2 := yaml.Unmarshal(body, &incoming); err2 == nil {
existingCfg.Watches = append(existingCfg.Watches, incoming.Watches...)
merged, _ := yaml.Marshal(existingCfg)
body = merged
}
}
}
}
var root yaml.Node
if err := yaml.Unmarshal(body, &root); err != nil {
jsonErr(w, fmt.Errorf("invalid YAML: %w", err), 400)
return
}
if err := os.WriteFile(s.cfgPath, body, 0o644); err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]string{"status": "imported"})
}
func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) {
s.SetPaused(true)
select {
case s.pauseCh <- true:
default:
}
jsonOK(w, map[string]string{"status": "paused"})
}
func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
s.SetPaused(false)
select {
case s.pauseCh <- false:
default:
}
jsonOK(w, map[string]string{"status": "resumed"})
}
// ── helpers ───────────────────────────────────────────────────────────────────
func jsonOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, err error, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:*")
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 {
return
}
next.ServeHTTP(w, r)
})
}