938b895d37
- 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>
242 lines
6.5 KiB
Go
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)
|
|
})
|
|
}
|