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>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
bin/
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/alexandrev/homeflow/internal/api"
|
||||
"github.com/alexandrev/homeflow/internal/config"
|
||||
"github.com/alexandrev/homeflow/internal/state"
|
||||
"github.com/alexandrev/homeflow/internal/tray"
|
||||
"github.com/alexandrev/homeflow/internal/watcher"
|
||||
)
|
||||
|
||||
@@ -30,6 +32,7 @@ func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", defaultCfg, "config file path")
|
||||
|
||||
rootCmd.AddCommand(
|
||||
trayCmd,
|
||||
watchCmd,
|
||||
runOnceCmd,
|
||||
statusCmd,
|
||||
@@ -41,6 +44,53 @@ func init() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── tray ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
var trayCmd = &cobra.Command{
|
||||
Use: "tray",
|
||||
Short: "Start daemon with menu bar icon and web UI",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, st, err := loadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
srv := api.New(cfg, configPath, st)
|
||||
port := cfg.Settings.UIPort
|
||||
|
||||
w, err := watcher.New(cfg, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
if err := srv.Start(port); err != nil {
|
||||
log.Printf("[api] server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := w.Start(stop); err != nil {
|
||||
log.Printf("[watcher] error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("homeflow tray started — UI at http://127.0.0.1:%d", port)
|
||||
|
||||
pauseFn := func() { w.SetPaused(true) }
|
||||
resumeFn := func() { w.SetPaused(false) }
|
||||
|
||||
// tray.Run blocks on the main thread (required by macOS)
|
||||
tray.Run(port, pauseFn, resumeFn)
|
||||
|
||||
close(stop)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ── watch ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
var watchCmd = &cobra.Command{
|
||||
|
||||
@@ -3,16 +3,27 @@ module github.com/alexandrev/homeflow
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
||||
github.com/getlantern/systray v1.2.2 // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/h2non/filetype v1.1.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
|
||||
@@ -1,29 +1,61 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
|
||||
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
|
||||
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+82
-36
@@ -2,6 +2,7 @@ package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/alexandrev/homeflow/internal/config"
|
||||
"github.com/alexandrev/homeflow/internal/engine"
|
||||
@@ -12,64 +13,111 @@ type Action interface {
|
||||
Execute(ctx *engine.ActionContext) error
|
||||
}
|
||||
|
||||
// Factory creates an Action from its raw params map.
|
||||
// Factory creates an Action from its raw params and service map.
|
||||
// Control-flow actions (if, switch, for_each) use BuildFromDef instead.
|
||||
type Factory func(params map[string]any, services map[string]config.ServiceConfig) (Action, error)
|
||||
|
||||
var registry = map[string]Factory{
|
||||
// local
|
||||
"move": newMoveAction,
|
||||
"copy": newCopyAction,
|
||||
"delete": newDeleteAction,
|
||||
"rename": newRenameAction,
|
||||
"decompress": newDecompressAction,
|
||||
"compress": newCompressAction,
|
||||
"run_script": newRunScriptAction,
|
||||
"checksum": newChecksumAction,
|
||||
"quarantine": newQuarantineAction,
|
||||
// macOS
|
||||
// ── local ────────────────────────────────────────────────────────────────
|
||||
"move": newMoveAction,
|
||||
"copy": newCopyAction,
|
||||
"delete": newDeleteAction,
|
||||
"rename": newRenameAction,
|
||||
"decompress": newDecompressAction,
|
||||
"compress": newCompressAction,
|
||||
"run_script": newRunScriptAction,
|
||||
"checksum": newChecksumAction,
|
||||
"quarantine": newQuarantineAction,
|
||||
// ── macOS ────────────────────────────────────────────────────────────────
|
||||
"macos_tag": newMacOSTagAction,
|
||||
"macos_notification": newMacOSNotificationAction,
|
||||
"macos_open": newMacOSOpenAction,
|
||||
// homelab
|
||||
"paperless_upload": newPaperlessAction,
|
||||
"immich_upload": newImmichAction,
|
||||
"calibre_copy": newCalibreAction,
|
||||
"n8n_webhook": newN8nAction,
|
||||
// ── homelab ──────────────────────────────────────────────────────────────
|
||||
"paperless_upload": newPaperlessAction,
|
||||
"immich_upload": newImmichAction,
|
||||
"calibre_copy": newCalibreAction,
|
||||
"n8n_webhook": newN8nAction,
|
||||
// ── new v0.1.0 ───────────────────────────────────────────────────────────
|
||||
"dedup": newDedupAction,
|
||||
"ocr": newOCRAction,
|
||||
"image_info": newImageInfoAction,
|
||||
"image_resize": newImageResizeAction,
|
||||
"image_convert": newImageConvertAction,
|
||||
"read_metadata": newMetadataAction,
|
||||
}
|
||||
|
||||
// Build constructs the action pipeline for a rule.
|
||||
// Build constructs an action pipeline from a list of ActionDefs.
|
||||
// Control-flow types (if, switch, for_each) are handled specially via BuildFromDef.
|
||||
func Build(defs []config.ActionDef, services map[string]config.ServiceConfig) ([]Action, error) {
|
||||
out := make([]Action, 0, len(defs))
|
||||
for _, def := range defs {
|
||||
factory, ok := registry[def.Type]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown action type %q", def.Type)
|
||||
}
|
||||
a, err := factory(def.Params, services)
|
||||
for i := range defs {
|
||||
a, err := buildOne(&defs[i], services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("action %q: %w", def.Type, err)
|
||||
return nil, fmt.Errorf("action[%d] %q: %w", i, defs[i].Type, err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RunPipeline executes actions sequentially. If an action fails and
|
||||
// ContinueOnError is not set it stops and returns the error.
|
||||
func RunPipeline(actions []Action, defs []config.ActionDef, ctx *engine.ActionContext) error {
|
||||
for i, a := range actions {
|
||||
if err := a.Execute(ctx); err != nil {
|
||||
if i < len(defs) && defs[i].ContinueOnError {
|
||||
fmt.Printf("[WARN] action %q failed (continuing): %v\n", defs[i].Type, err)
|
||||
func buildOne(def *config.ActionDef, services map[string]config.ServiceConfig) (Action, error) {
|
||||
var (
|
||||
a Action
|
||||
err error
|
||||
)
|
||||
|
||||
// Control-flow types need the full ActionDef (sub-pipelines, condition, cases…)
|
||||
switch def.Type {
|
||||
case "if":
|
||||
a, err = newIfActionFromDef(*def, services)
|
||||
case "switch":
|
||||
a, err = newSwitchActionFromDef(*def, services)
|
||||
case "for_each":
|
||||
a, err = newForEachActionFromDef(*def, services)
|
||||
default:
|
||||
factory, ok := registry[def.Type]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown action type %q", def.Type)
|
||||
}
|
||||
a, err = factory(def.Params, services)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return wrapRetry(a, def.Retry), nil
|
||||
}
|
||||
|
||||
// RunPipeline executes actions sequentially, passing the mutable ActionContext
|
||||
// so that each action can update CurrentPath and pipeline variables.
|
||||
func RunPipeline(acts []Action, defs []config.ActionDef, ctx *engine.ActionContext) error {
|
||||
for i, a := range acts {
|
||||
err := a.Execute(ctx)
|
||||
if err != nil {
|
||||
def := defAt(defs, i)
|
||||
if def != nil && def.ContinueOnError {
|
||||
log.Printf("[WARN] action %q failed (continuing): %v", def.Type, err)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("action %q: %w", defs[i].Type, err)
|
||||
typeName := "unknown"
|
||||
if def != nil {
|
||||
typeName = def.Type
|
||||
}
|
||||
return fmt.Errorf("action %q: %w", typeName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// paramString extracts a string param, returns def if missing.
|
||||
func defAt(defs []config.ActionDef, i int) *config.ActionDef {
|
||||
if i < len(defs) {
|
||||
return &defs[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── helper extractors (shared across action files) ────────────────────────────
|
||||
|
||||
func paramString(p map[string]any, key, def string) string {
|
||||
if v, ok := p[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
@@ -79,7 +127,6 @@ func paramString(p map[string]any, key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
// paramBool extracts a bool param.
|
||||
func paramBool(p map[string]any, key string, def bool) bool {
|
||||
if v, ok := p[key]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
@@ -89,7 +136,6 @@ func paramBool(p map[string]any, key string, def bool) bool {
|
||||
return def
|
||||
}
|
||||
|
||||
// paramStrings extracts a []string param.
|
||||
func paramStrings(p map[string]any, key string) []string {
|
||||
if v, ok := p[key]; ok {
|
||||
switch t := v.(type) {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/alexandrev/homeflow/internal/conditions"
|
||||
"github.com/alexandrev/homeflow/internal/config"
|
||||
"github.com/alexandrev/homeflow/internal/engine"
|
||||
)
|
||||
|
||||
// ── if ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ifAction struct {
|
||||
condition *config.ConditionDef
|
||||
then []Action
|
||||
thenDefs []config.ActionDef
|
||||
els []Action
|
||||
elsDefs []config.ActionDef
|
||||
}
|
||||
|
||||
func newIfAction(p map[string]any, services map[string]config.ServiceConfig) (Action, error) {
|
||||
return &ifAction{}, nil // populated by BuildWithDef
|
||||
}
|
||||
|
||||
func newIfActionFromDef(def config.ActionDef, services map[string]config.ServiceConfig) (Action, error) {
|
||||
if def.Condition == nil {
|
||||
return nil, fmt.Errorf("if: condition required")
|
||||
}
|
||||
thenPipeline, err := Build(def.Then, services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("if then: %w", err)
|
||||
}
|
||||
elsPipeline, err := Build(def.Else, services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("if else: %w", err)
|
||||
}
|
||||
return &ifAction{
|
||||
condition: def.Condition,
|
||||
then: thenPipeline,
|
||||
thenDefs: def.Then,
|
||||
els: elsPipeline,
|
||||
elsDefs: def.Else,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ifAction) Execute(ctx *engine.ActionContext) error {
|
||||
if conditions.Evaluate(a.condition, ctx) {
|
||||
return RunPipeline(a.then, a.thenDefs, ctx)
|
||||
}
|
||||
return RunPipeline(a.els, a.elsDefs, ctx)
|
||||
}
|
||||
|
||||
// ── switch ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type switchCase struct {
|
||||
pipeline []Action
|
||||
defs []config.ActionDef
|
||||
}
|
||||
|
||||
type switchAction struct {
|
||||
on string
|
||||
cases map[string]switchCase
|
||||
defaultCase *switchCase
|
||||
}
|
||||
|
||||
func newSwitchAction(p map[string]any, _ map[string]config.ServiceConfig) (Action, error) {
|
||||
return &switchAction{}, nil // populated by BuildWithDef
|
||||
}
|
||||
|
||||
func newSwitchActionFromDef(def config.ActionDef, services map[string]config.ServiceConfig) (Action, error) {
|
||||
on, _ := def.Params["on"].(string)
|
||||
if on == "" {
|
||||
return nil, fmt.Errorf("switch: on required")
|
||||
}
|
||||
a := &switchAction{on: on, cases: make(map[string]switchCase)}
|
||||
|
||||
for k, actionDefs := range def.Cases {
|
||||
pipeline, err := Build(actionDefs, services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("switch case %q: %w", k, err)
|
||||
}
|
||||
a.cases[k] = switchCase{pipeline: pipeline, defs: actionDefs}
|
||||
}
|
||||
|
||||
if len(def.Default) > 0 {
|
||||
pipeline, err := Build(def.Default, services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("switch default: %w", err)
|
||||
}
|
||||
a.defaultCase = &switchCase{pipeline: pipeline, defs: def.Default}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *switchAction) Execute(ctx *engine.ActionContext) error {
|
||||
key := engine.RenderTemplate(a.on, ctx)
|
||||
if sc, ok := a.cases[key]; ok {
|
||||
return RunPipeline(sc.pipeline, sc.defs, ctx)
|
||||
}
|
||||
if a.defaultCase != nil {
|
||||
return RunPipeline(a.defaultCase.pipeline, a.defaultCase.defs, ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── for_each ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type forEachAction struct {
|
||||
path string
|
||||
match config.MatchConfig
|
||||
subActions []Action
|
||||
subDefs []config.ActionDef
|
||||
}
|
||||
|
||||
func newForEachAction(p map[string]any, _ map[string]config.ServiceConfig) (Action, error) {
|
||||
return &forEachAction{}, nil // populated by BuildWithDef
|
||||
}
|
||||
|
||||
func newForEachActionFromDef(def config.ActionDef, services map[string]config.ServiceConfig) (Action, error) {
|
||||
path := paramString(def.Params, "path", "")
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("for_each: path required")
|
||||
}
|
||||
|
||||
var match config.MatchConfig
|
||||
if m, ok := def.Params["match"]; ok {
|
||||
// Re-marshal/unmarshal to convert map[string]any → MatchConfig
|
||||
if data, err := yaml.Marshal(m); err == nil {
|
||||
yaml.Unmarshal(data, &match)
|
||||
}
|
||||
}
|
||||
|
||||
pipeline, err := Build(def.SubActions, services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("for_each actions: %w", err)
|
||||
}
|
||||
|
||||
return &forEachAction{
|
||||
path: config.ExpandPath(path),
|
||||
match: match,
|
||||
subActions: pipeline,
|
||||
subDefs: def.SubActions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *forEachAction) Execute(ctx *engine.ActionContext) error {
|
||||
path := engine.RenderTemplate(a.path, ctx)
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("for_each: reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
rule := &config.RuleConfig{
|
||||
Name: ctx.RuleName + "/for_each",
|
||||
Match: a.match,
|
||||
Actions: a.subDefs,
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
filePath := filepath.Join(path, e.Name())
|
||||
matched, err := engine.MatchFile(filePath, rule)
|
||||
if err != nil || !matched {
|
||||
continue
|
||||
}
|
||||
subCtx, err := engine.BuildContext(filePath, rule, ctx.Services, ctx.State)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
RunPipeline(a.subActions, a.subDefs, subCtx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── retryAction wraps another Action with retry logic ─────────────────────────
|
||||
|
||||
type retryAction struct {
|
||||
inner Action
|
||||
maxAttempts int
|
||||
backoff time.Duration
|
||||
}
|
||||
|
||||
func (a *retryAction) Execute(ctx *engine.ActionContext) error {
|
||||
var err error
|
||||
for attempt := 0; attempt <= a.maxAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(a.backoff)
|
||||
}
|
||||
err = a.inner.Execute(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// wrapRetry wraps an Action with retry logic if a RetryConfig is present.
|
||||
func wrapRetry(a Action, r *config.RetryConfig) Action {
|
||||
if r == nil || r.MaxAttempts <= 0 {
|
||||
return a
|
||||
}
|
||||
return &retryAction{
|
||||
inner: a,
|
||||
maxAttempts: r.MaxAttempts,
|
||||
backoff: time.Duration(r.BackoffSecs) * time.Second,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/alexandrev/homeflow/internal/config"
|
||||
"github.com/alexandrev/homeflow/internal/engine"
|
||||
)
|
||||
|
||||
// dedup detects duplicate files and takes a configured action.
|
||||
// Modes:
|
||||
// - content: compare by SHA256 hash
|
||||
// - name: compare by filename (case-insensitive)
|
||||
// - both: content AND name must match
|
||||
//
|
||||
// On duplicate:
|
||||
// - skip: stop pipeline (return error to halt further actions)
|
||||
// - move: move duplicate to dest dir
|
||||
// - rename: rename with _dup_N suffix and continue
|
||||
// - delete_duplicate: delete the incoming file
|
||||
|
||||
type dedupAction struct {
|
||||
mode string // content | name | both
|
||||
dirs []string // directories to scan (empty = state DB only)
|
||||
onDup string // skip | move | rename | delete_duplicate
|
||||
dest string // used by move
|
||||
}
|
||||
|
||||
var errDuplicate = fmt.Errorf("duplicate detected")
|
||||
|
||||
func newDedupAction(p map[string]any, _ map[string]config.ServiceConfig) (Action, error) {
|
||||
dirs := paramStrings(p, "search_dirs")
|
||||
expandedDirs := make([]string, len(dirs))
|
||||
for i, d := range dirs {
|
||||
expandedDirs[i] = config.ExpandPath(d)
|
||||
}
|
||||
return &dedupAction{
|
||||
mode: paramString(p, "mode", "content"),
|
||||
dirs: expandedDirs,
|
||||
onDup: paramString(p, "on_duplicate", "skip"),
|
||||
dest: config.ExpandPath(paramString(p, "dest", "")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *dedupAction) Execute(ctx *engine.ActionContext) error {
|
||||
// Check state DB first (already processed with same hash)
|
||||
if a.mode == "content" || a.mode == "both" {
|
||||
if ctx.State != nil {
|
||||
hash, err := fileHash(ctx.CurrentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.SetVar("dedup.hash", hash)
|
||||
already, _ := ctx.State.IsProcessed(ctx.CurrentPath, hash)
|
||||
if already {
|
||||
return a.handleDuplicate(ctx, "state_db")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan search_dirs for duplicates
|
||||
for _, dir := range a.dirs {
|
||||
dupPath, found, err := a.findDuplicate(ctx.CurrentPath, dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
ctx.SetVar("dedup.duplicate_of", dupPath)
|
||||
return a.handleDuplicate(ctx, dupPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *dedupAction) findDuplicate(src, dir string) (string, bool, error) {
|
||||
srcHash, err := fileHash(src)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
srcName := strings.ToLower(filepath.Base(src))
|
||||
|
||||
var found string
|
||||
err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || path == src {
|
||||
return nil
|
||||
}
|
||||
nameMatch := strings.ToLower(d.Name()) == srcName
|
||||
var contentMatch bool
|
||||
if a.mode == "content" || a.mode == "both" {
|
||||
h, err := fileHash(path)
|
||||
if err == nil {
|
||||
contentMatch = h == srcHash
|
||||
}
|
||||
}
|
||||
switch a.mode {
|
||||
case "content":
|
||||
if contentMatch {
|
||||
found = path
|
||||
return fmt.Errorf("found")
|
||||
}
|
||||
case "name":
|
||||
if nameMatch {
|
||||
found = path
|
||||
return fmt.Errorf("found")
|
||||
}
|
||||
case "both":
|
||||
if nameMatch && contentMatch {
|
||||
found = path
|
||||
return fmt.Errorf("found")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && err.Error() != "found" {
|
||||
return "", false, err
|
||||
}
|
||||
return found, found != "", nil
|
||||
}
|
||||
|
||||
func (a *dedupAction) handleDuplicate(ctx *engine.ActionContext, origin string) error {
|
||||
ctx.SetVar("dedup.is_duplicate", "true")
|
||||
ctx.SetVar("dedup.duplicate_of", origin)
|
||||
|
||||
switch a.onDup {
|
||||
case "move":
|
||||
dest := a.dest
|
||||
if dest == "" {
|
||||
dest = filepath.Join(filepath.Dir(ctx.CurrentPath), "duplicates")
|
||||
}
|
||||
os.MkdirAll(dest, 0o755)
|
||||
target := filepath.Join(dest, filepath.Base(ctx.CurrentPath))
|
||||
os.Rename(ctx.CurrentPath, target)
|
||||
ctx.CurrentPath = target
|
||||
return errDuplicate // halt pipeline
|
||||
case "rename":
|
||||
base := strings.TrimSuffix(ctx.CurrentPath, filepath.Ext(ctx.CurrentPath))
|
||||
ext := filepath.Ext(ctx.CurrentPath)
|
||||
for i := 1; i < 100; i++ {
|
||||
candidate := fmt.Sprintf("%s_dup_%d%s", base, i, ext)
|
||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||
os.Rename(ctx.CurrentPath, candidate)
|
||||
ctx.CurrentPath = candidate
|
||||
return nil // continue pipeline with renamed file
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("dedup: could not find a free rename for duplicate")
|
||||
case "delete_duplicate":
|
||||
return os.Remove(ctx.CurrentPath) // halt pipeline (file gone)
|
||||
default: // skip
|
||||
return errDuplicate // halt pipeline, no file modification
|
||||
}
|
||||
}
|
||||
|
||||
func fileHash(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
"github.com/alexandrev/homeflow/internal/config"
|
||||
"github.com/alexandrev/homeflow/internal/engine"
|
||||
)
|
||||
|
||||
// ── image_info ────────────────────────────────────────────────────────────────
|
||||
// Sets {img.width}, {img.height}, {img.format} in the pipeline vars.
|
||||
|
||||
type imageInfoAction struct{}
|
||||
|
||||
func newImageInfoAction(_ map[string]any, _ map[string]config.ServiceConfig) (Action, error) {
|
||||
return &imageInfoAction{}, nil
|
||||
}
|
||||
|
||||
func (a *imageInfoAction) Execute(ctx *engine.ActionContext) error {
|
||||
f, err := os.Open(ctx.CurrentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cfg, format, err := image.DecodeConfig(f)
|
||||
if err != nil {
|
||||
// Fallback to ImageMagick identify
|
||||
out, err2 := exec.Command("identify", "-format", "%w %h %m", ctx.CurrentPath).Output()
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("image_info: %w", err)
|
||||
}
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) >= 3 {
|
||||
ctx.SetVar("img.width", parts[0])
|
||||
ctx.SetVar("img.height", parts[1])
|
||||
ctx.SetVar("img.format", strings.ToLower(parts[2]))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx.SetVar("img.width", strconv.Itoa(cfg.Width))
|
||||
ctx.SetVar("img.height", strconv.Itoa(cfg.Height))
|
||||
ctx.SetVar("img.format", format)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── image_resize ──────────────────────────────────────────────────────────────
|
||||
|
||||
type imageResizeAction struct {
|
||||
width int
|
||||
height int
|
||||
fit string // contain | cover | fill | stretch
|
||||
format string // jpg | png | webp | (empty = keep)
|
||||
quality int
|
||||
output string // overwrite | new_file
|
||||
}
|
||||
|
||||
func newImageResizeAction(p map[string]any, _ map[string]config.ServiceConfig) (Action, error) {
|
||||
width, height := 0, 0
|
||||
if v, ok := p["width"]; ok {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
width = t
|
||||
case float64:
|
||||
width = int(t)
|
||||
}
|
||||
}
|
||||
if v, ok := p["height"]; ok {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
height = t
|
||||
case float64:
|
||||
height = int(t)
|
||||
}
|
||||
}
|
||||
quality := 85
|
||||
if v, ok := p["quality"]; ok {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
quality = t
|
||||
case float64:
|
||||
quality = int(t)
|
||||
}
|
||||
}
|
||||
return &imageResizeAction{
|
||||
width: width,
|
||||
height: height,
|
||||
fit: paramString(p, "fit", "contain"),
|
||||
format: paramString(p, "format", ""),
|
||||
quality: quality,
|
||||
output: paramString(p, "output", "overwrite"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *imageResizeAction) Execute(ctx *engine.ActionContext) error {
|
||||
// Prefer ImageMagick for broader format support
|
||||
if _, err := exec.LookPath("convert"); err == nil {
|
||||
return a.resizeImageMagick(ctx)
|
||||
}
|
||||
return a.resizePureGo(ctx)
|
||||
}
|
||||
|
||||
func (a *imageResizeAction) resizeImageMagick(ctx *engine.ActionContext) error {
|
||||
var geometry string
|
||||
switch a.fit {
|
||||
case "cover":
|
||||
geometry = fmt.Sprintf("%dx%d^", a.width, a.height)
|
||||
case "fill", "stretch":
|
||||
geometry = fmt.Sprintf("%dx%d!", a.width, a.height)
|
||||
default: // contain
|
||||
geometry = fmt.Sprintf("%dx%d>", a.width, a.height)
|
||||
}
|
||||
|
||||
outPath := a.outputPath(ctx)
|
||||
args := []string{ctx.CurrentPath}
|
||||
args = append(args, "-resize", geometry)
|
||||
if a.format == "jpg" || strings.HasSuffix(strings.ToLower(outPath), ".jpg") {
|
||||
args = append(args, "-quality", strconv.Itoa(a.quality))
|
||||
}
|
||||
args = append(args, outPath)
|
||||
|
||||
if out, err := exec.Command("convert", args...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("imagemagick resize: %w\n%s", err, out)
|
||||
}
|
||||
ctx.CurrentPath = outPath
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *imageResizeAction) resizePureGo(ctx *engine.ActionContext) error {
|
||||
f, err := os.Open(ctx.CurrentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src, _, err := image.Decode(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("image decode: %w", err)
|
||||
}
|
||||
|
||||
tw, th := targetDimensions(src.Bounds(), a.width, a.height, a.fit)
|
||||
dst := image.NewRGBA(image.Rect(0, 0, tw, th))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
|
||||
|
||||
outPath := a.outputPath(ctx)
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
switch strings.ToLower(filepath.Ext(outPath)) {
|
||||
case ".png":
|
||||
err = png.Encode(out, dst)
|
||||
default:
|
||||
err = jpeg.Encode(out, dst, &jpeg.Options{Quality: a.quality})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.CurrentPath = outPath
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *imageResizeAction) outputPath(ctx *engine.ActionContext) string {
|
||||
if a.output == "overwrite" && a.format == "" {
|
||||
return ctx.CurrentPath
|
||||
}
|
||||
ext := filepath.Ext(ctx.CurrentPath)
|
||||
base := strings.TrimSuffix(ctx.CurrentPath, ext)
|
||||
if a.format != "" {
|
||||
ext = "." + a.format
|
||||
}
|
||||
if a.output == "new_file" {
|
||||
return base + "_resized" + ext
|
||||
}
|
||||
return base + ext
|
||||
}
|
||||
|
||||
func targetDimensions(bounds image.Rectangle, tw, th int, fit string) (int, int) {
|
||||
ow, oh := bounds.Dx(), bounds.Dy()
|
||||
if tw == 0 && th == 0 {
|
||||
return ow, oh
|
||||
}
|
||||
if tw == 0 {
|
||||
return ow * th / oh, th
|
||||
}
|
||||
if th == 0 {
|
||||
return tw, oh * tw / ow
|
||||
}
|
||||
switch fit {
|
||||
case "fill", "stretch":
|
||||
return tw, th
|
||||
default: // contain
|
||||
rw := float64(tw) / float64(ow)
|
||||
rh := float64(th) / float64(oh)
|
||||
if rw < rh {
|
||||
return tw, int(float64(oh) * rw)
|
||||
}
|
||||
return int(float64(ow) * rh), th
|
||||
}
|
||||
}
|
||||
|
||||
// ── image_convert ─────────────────────────────────────────────────────────────
|
||||
|
||||
type imageConvertAction struct {
|
||||
format string
|
||||
quality int
|
||||
output string // overwrite | new_file
|
||||
}
|
||||
|
||||
func newImageConvertAction(p map[string]any, _ map[string]config.ServiceConfig) (Action, error) {
|
||||
format := paramString(p, "format", "")
|
||||
if format == "" {
|
||||
return nil, fmt.Errorf("image_convert: format required (jpg, png, webp, ...)")
|
||||
}
|
||||
quality := 85
|
||||
if v, ok := p["quality"]; ok {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
quality = t
|
||||
case float64:
|
||||
quality = int(t)
|
||||
}
|
||||
}
|
||||
return &imageConvertAction{
|
||||
format: format,
|
||||
quality: quality,
|
||||
output: paramString(p, "output", "new_file"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *imageConvertAction) Execute(ctx *engine.ActionContext) error {
|
||||
base := strings.TrimSuffix(ctx.CurrentPath, filepath.Ext(ctx.CurrentPath))
|
||||
var outPath string
|
||||
if a.output == "overwrite" {
|
||||
outPath = base + "." + a.format
|
||||
} else {
|
||||
outPath = base + "_converted." + a.format
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("convert"); err == nil {
|
||||
args := []string{ctx.CurrentPath}
|
||||
if a.format == "jpg" {
|
||||
args = append(args, "-quality", strconv.Itoa(a.quality))
|
||||
}
|
||||
args = append(args, outPath)
|
||||
if out, err := exec.Command("convert", args...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("image_convert: %w\n%s", err, out)
|
||||
}
|
||||
ctx.CurrentPath = outPath
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("image_convert: ImageMagick (convert) not found. Install: brew install imagemagick")
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/alexandrev/homeflow/internal/config"
|
||||
"github.com/alexandrev/homeflow/internal/engine"
|
||||
)
|
||||
|
||||
// read_metadata extracts file metadata and stores all fields as pipeline vars.
|
||||
// Uses exiftool (images/video), pdfinfo (PDF), and dhowden/tag (audio).
|
||||
// All extracted fields become available as {meta.FieldName} in subsequent actions.
|
||||
|
||||
type metadataAction struct {
|
||||
prefix string // default "meta"
|
||||
}
|
||||
|
||||
func newMetadataAction(p map[string]any, _ map[string]config.ServiceConfig) (Action, error) {
|
||||
return &metadataAction{
|
||||
prefix: paramString(p, "prefix", "meta"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *metadataAction) Execute(ctx *engine.ActionContext) error {
|
||||
ext := strings.ToLower(filepath.Ext(ctx.CurrentPath))
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case isImageOrVideo(ext):
|
||||
err = a.readExiftool(ctx)
|
||||
case ext == ".pdf":
|
||||
err = a.readPDF(ctx)
|
||||
case isAudio(ext):
|
||||
err = a.readAudio(ctx)
|
||||
default:
|
||||
err = a.readBasicStat(ctx)
|
||||
}
|
||||
|
||||
// Never fail the pipeline for metadata errors — just log
|
||||
if err != nil {
|
||||
ctx.SetVar(a.prefix+".error", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readExiftool uses exiftool -j to get all metadata as JSON.
|
||||
func (a *metadataAction) readExiftool(ctx *engine.ActionContext) error {
|
||||
out, err := exec.Command("exiftool", "-j", "-q", ctx.CurrentPath).Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("exiftool: %w", err)
|
||||
}
|
||||
var results []map[string]any
|
||||
if err := json.Unmarshal(out, &results); err != nil || len(results) == 0 {
|
||||
return fmt.Errorf("exiftool: parse error")
|
||||
}
|
||||
for k, v := range results[0] {
|
||||
// Flatten to string, using dot-notation prefix
|
||||
ctx.SetVar(a.prefix+"."+k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPDF uses pdfinfo to extract PDF metadata.
|
||||
func (a *metadataAction) readPDF(ctx *engine.ActionContext) error {
|
||||
out, err := exec.Command("pdfinfo", ctx.CurrentPath).Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pdfinfo: %w", err)
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if k, v, ok := strings.Cut(line, ":"); ok {
|
||||
key := strings.TrimSpace(k)
|
||||
val := strings.TrimSpace(v)
|
||||
ctx.SetVar(a.prefix+"."+key, val)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readAudio uses dhowden/tag for ID3/MP4/FLAC tags.
|
||||
func (a *metadataAction) readAudio(ctx *engine.ActionContext) error {
|
||||
f, err := os.Open(ctx.CurrentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Avoid importing dhowden/tag directly to keep the build simple;
|
||||
// fall back to exiftool if available, else skip.
|
||||
out, err := exec.Command("exiftool", "-j", "-q", ctx.CurrentPath).Output()
|
||||
if err != nil {
|
||||
return nil // best effort
|
||||
}
|
||||
var results []map[string]any
|
||||
if err := json.Unmarshal(out, &results); err != nil || len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
for k, v := range results[0] {
|
||||
ctx.SetVar(a.prefix+"."+k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *metadataAction) readBasicStat(ctx *engine.ActionContext) error {
|
||||
info, err := os.Stat(ctx.CurrentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.SetVar(a.prefix+".FileSize", fmt.Sprintf("%d", info.Size()))
|
||||
ctx.SetVar(a.prefix+".FileModifyDate", info.ModTime().String())
|
||||
ctx.SetVar(a.prefix+".FileName", info.Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
func isImageOrVideo(ext string) bool {
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".tiff", ".bmp",
|
||||
".raw", ".cr2", ".arw", ".nef", ".orf",
|
||||
".mp4", ".mov", ".avi", ".mkv", ".m4v", ".wmv":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isAudio(ext string) bool {
|
||||
switch ext {
|
||||
case ".mp3", ".flac", ".aac", ".ogg", ".wav", ".m4a", ".wma":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/alexandrev/homeflow/internal/config"
|
||||
"github.com/alexandrev/homeflow/internal/engine"
|
||||
)
|
||||
|
||||
// ocr runs OCR on a PDF or image file.
|
||||
// Engines: ocrmypdf (PDF→searchable PDF), tesseract (image→text)
|
||||
// Sets {ocr.text} if output mode includes text extraction.
|
||||
|
||||
type ocrAction struct {
|
||||
engine_ string // ocrmypdf | tesseract | auto
|
||||
language string // e.g. "spa+eng"
|
||||
output string // overwrite | sidecar | var_only
|
||||
dpi int
|
||||
}
|
||||
|
||||
func newOCRAction(p map[string]any, _ map[string]config.ServiceConfig) (Action, error) {
|
||||
dpi := 300
|
||||
if v, ok := p["dpi"]; ok {
|
||||
if d, ok := v.(int); ok {
|
||||
dpi = d
|
||||
}
|
||||
}
|
||||
return &ocrAction{
|
||||
engine_: paramString(p, "engine", "auto"),
|
||||
language: paramString(p, "language", ""),
|
||||
output: paramString(p, "output", "overwrite"),
|
||||
dpi: dpi,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ocrAction) Execute(ctx *engine.ActionContext) error {
|
||||
ext := strings.ToLower(filepath.Ext(ctx.CurrentPath))
|
||||
|
||||
switch {
|
||||
case ext == ".pdf":
|
||||
return a.ocrPDF(ctx)
|
||||
case isImageOrVideo(ext): // reuse helper from metadata.go
|
||||
return a.ocrImage(ctx)
|
||||
default:
|
||||
return fmt.Errorf("ocr: unsupported file type %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ocrAction) ocrPDF(ctx *engine.ActionContext) error {
|
||||
ext := strings.ToLower(filepath.Ext(ctx.CurrentPath))
|
||||
eng := a.engine_
|
||||
if eng == "auto" {
|
||||
if _, err := exec.LookPath("ocrmypdf"); err == nil {
|
||||
eng = "ocrmypdf"
|
||||
} else {
|
||||
return fmt.Errorf("ocr: ocrmypdf not found, install with: brew install ocrmypdf")
|
||||
}
|
||||
}
|
||||
|
||||
switch eng {
|
||||
case "ocrmypdf":
|
||||
args := []string{"--skip-text"}
|
||||
if a.language != "" {
|
||||
args = append(args, "-l", a.language)
|
||||
}
|
||||
if a.dpi > 0 {
|
||||
args = append(args, "--image-dpi", fmt.Sprintf("%d", a.dpi))
|
||||
}
|
||||
var outPath string
|
||||
switch a.output {
|
||||
case "overwrite":
|
||||
tmp := ctx.CurrentPath + ".ocr.tmp.pdf"
|
||||
args = append(args, ctx.CurrentPath, tmp)
|
||||
if out, err := exec.Command("ocrmypdf", args...).CombinedOutput(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("ocrmypdf: %w\n%s", err, out)
|
||||
}
|
||||
if err := os.Rename(tmp, ctx.CurrentPath); err != nil {
|
||||
return err
|
||||
}
|
||||
outPath = ctx.CurrentPath
|
||||
case "sidecar", "var_only":
|
||||
tmp := ctx.CurrentPath + ".ocr.tmp.pdf"
|
||||
txtPath := strings.TrimSuffix(ctx.CurrentPath, ext) + ".txt"
|
||||
args = append(args, "--sidecar", txtPath, ctx.CurrentPath, tmp)
|
||||
out, err := exec.Command("ocrmypdf", args...).CombinedOutput()
|
||||
os.Remove(tmp)
|
||||
if err != nil && !strings.Contains(string(out), "PriorOcrFoundError") {
|
||||
return fmt.Errorf("ocrmypdf: %w\n%s", err, out)
|
||||
}
|
||||
if data, err := os.ReadFile(txtPath); err == nil {
|
||||
ctx.SetVar("ocr.text", strings.TrimSpace(string(data)))
|
||||
}
|
||||
if a.output == "var_only" {
|
||||
os.Remove(txtPath)
|
||||
}
|
||||
outPath = ctx.CurrentPath
|
||||
}
|
||||
_ = outPath
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ocrAction) ocrImage(ctx *engine.ActionContext) error {
|
||||
if _, err := exec.LookPath("tesseract"); err != nil {
|
||||
return fmt.Errorf("ocr: tesseract not found, install with: brew install tesseract")
|
||||
}
|
||||
|
||||
outBase := strings.TrimSuffix(ctx.CurrentPath, filepath.Ext(ctx.CurrentPath))
|
||||
args := []string{ctx.CurrentPath, outBase}
|
||||
if a.language != "" {
|
||||
args = append(args, "-l", a.language)
|
||||
}
|
||||
args = append(args, "txt")
|
||||
|
||||
if out, err := exec.Command("tesseract", args...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("tesseract: %w\n%s", err, out)
|
||||
}
|
||||
|
||||
txtPath := outBase + ".txt"
|
||||
if data, err := os.ReadFile(txtPath); err == nil {
|
||||
ctx.SetVar("ocr.text", strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
if a.output == "var_only" {
|
||||
os.Remove(txtPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>homeflow</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
.nav-item { @apply flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium cursor-pointer transition-colors; }
|
||||
.nav-item.active { @apply bg-indigo-600 text-white; }
|
||||
.nav-item:not(.active) { @apply text-gray-600 hover:bg-gray-100; }
|
||||
.badge-success { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800; }
|
||||
.badge-error { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800; }
|
||||
.badge-skip { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600; }
|
||||
#editor { height: calc(100vh - 220px); border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full bg-gray-50" x-data="app()" x-init="init()">
|
||||
|
||||
<div class="flex h-screen">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-56 bg-white border-r border-gray-200 flex flex-col">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<i class="fa-solid fa-bolt text-white text-sm"></i>
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900">homeflow</span>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<span x-show="!status.paused" class="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span x-show="status.paused" class="w-2 h-2 rounded-full bg-yellow-400"></span>
|
||||
<span class="text-xs text-gray-500" x-text="status.paused ? 'Pausado' : 'Activo'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-3 space-y-1">
|
||||
<div class="nav-item" :class="{active: page==='dashboard'}" @click="page='dashboard'">
|
||||
<i class="fa-solid fa-gauge-high w-4"></i> Dashboard
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: page==='rules'}" @click="page='rules'; loadConfig()">
|
||||
<i class="fa-solid fa-sliders w-4"></i> Reglas
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: page==='logs'}" @click="page='logs'; loadLogs()">
|
||||
<i class="fa-solid fa-list-ul w-4"></i> Logs
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: page==='import'}" @click="page='import'">
|
||||
<i class="fa-solid fa-file-import w-4"></i> Import / Export
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="p-3 border-t border-gray-200 space-y-1">
|
||||
<button @click="togglePause()"
|
||||
class="w-full text-left nav-item"
|
||||
:class="status.paused ? 'text-green-600 hover:bg-green-50' : 'text-yellow-600 hover:bg-yellow-50'">
|
||||
<i :class="status.paused ? 'fa-solid fa-play' : 'fa-solid fa-pause'" class="w-4"></i>
|
||||
<span x-text="status.paused ? 'Reanudar' : 'Pausar'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
|
||||
<!-- ── Dashboard ─────────────────────────────────────────────────────── -->
|
||||
<div x-show="page==='dashboard'" class="p-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900 mb-6">Dashboard</h1>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<p class="text-sm text-gray-500">Procesados</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="status.processed_total ?? 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<p class="text-sm text-gray-500">Errores</p>
|
||||
<p class="text-3xl font-bold text-red-500 mt-1" x-text="status.errors_total ?? 0"></p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<p class="text-sm text-gray-500">Directorios vigilados</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="(status.watches ?? []).length"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 class="text-sm font-medium text-gray-700 mb-3">Directorios monitorizados</h2>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="w in (status.watches ?? [])">
|
||||
<li class="flex items-center gap-2 text-sm text-gray-600">
|
||||
<i class="fa-solid fa-folder text-indigo-400 w-4"></i>
|
||||
<span x-text="w"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 class="text-sm font-medium text-gray-700 mb-3">Últimas actividades</h2>
|
||||
<div x-show="logs.length === 0" class="text-sm text-gray-400">Sin actividad reciente.</div>
|
||||
<table x-show="logs.length > 0" class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-gray-400 text-xs border-b">
|
||||
<th class="pb-2 font-medium">Estado</th>
|
||||
<th class="pb-2 font-medium">Regla</th>
|
||||
<th class="pb-2 font-medium">Fichero</th>
|
||||
<th class="pb-2 font-medium">Hace</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="r in logs.slice(0,10)">
|
||||
<tr class="border-b border-gray-50 hover:bg-gray-50">
|
||||
<td class="py-2"><span :class="badgeClass(r.Status)" x-text="r.Status"></span></td>
|
||||
<td class="py-2 text-gray-700" x-text="r.Rule"></td>
|
||||
<td class="py-2 text-gray-500 font-mono text-xs" x-text="basename(r.Path)"></td>
|
||||
<td class="py-2 text-gray-400 text-xs" x-text="ago(r.ProcessedAt)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Rules editor ───────────────────────────────────────────────────── -->
|
||||
<div x-show="page==='rules'" class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Editor de Reglas</h1>
|
||||
<div class="flex gap-2">
|
||||
<button @click="saveConfig()"
|
||||
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 flex items-center gap-2">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Guardar
|
||||
</button>
|
||||
<button @click="reloadConfig()"
|
||||
class="px-4 py-2 bg-white border border-gray-300 text-sm font-medium rounded-lg hover:bg-gray-50 flex items-center gap-2">
|
||||
<i class="fa-solid fa-rotate-right"></i> Recargar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="saveMsg" class="mb-3 px-3 py-2 rounded-lg text-sm"
|
||||
:class="saveErr ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'"
|
||||
x-text="saveMsg"></div>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Logs ───────────────────────────────────────────────────────────── -->
|
||||
<div x-show="page==='logs'" class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Logs</h1>
|
||||
<button @click="loadLogs()"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-arrows-rotate text-xs"></i> Actualizar
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div x-show="logs.length === 0" class="p-6 text-center text-sm text-gray-400">Sin actividad aún.</div>
|
||||
<table x-show="logs.length > 0" class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr class="text-left text-xs text-gray-400">
|
||||
<th class="px-4 py-3 font-medium">Estado</th>
|
||||
<th class="px-4 py-3 font-medium">Regla</th>
|
||||
<th class="px-4 py-3 font-medium">Fichero</th>
|
||||
<th class="px-4 py-3 font-medium">Fecha</th>
|
||||
<th class="px-4 py-3 font-medium">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<template x-for="r in logs">
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2.5"><span :class="badgeClass(r.Status)" x-text="r.Status"></span></td>
|
||||
<td class="px-4 py-2.5 text-gray-700" x-text="r.Rule"></td>
|
||||
<td class="px-4 py-2.5 text-gray-500 font-mono text-xs max-w-xs truncate" :title="r.Path" x-text="basename(r.Path)"></td>
|
||||
<td class="px-4 py-2.5 text-gray-400 text-xs" x-text="ago(r.ProcessedAt)"></td>
|
||||
<td class="px-4 py-2.5 text-red-500 text-xs max-w-xs truncate" x-text="r.ErrorMsg"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Import / Export ────────────────────────────────────────────────── -->
|
||||
<div x-show="page==='import'" class="p-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900 mb-6">Import / Export</h1>
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 class="font-medium text-gray-900 mb-1">Exportar configuración</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">Descarga el fichero config.yaml completo.</p>
|
||||
<button @click="exportConfig()"
|
||||
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 flex items-center gap-2">
|
||||
<i class="fa-solid fa-download"></i> Descargar config.yaml
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 class="font-medium text-gray-900 mb-1">Importar configuración</h2>
|
||||
<p class="text-sm text-gray-500 mb-3">Sube un config.yaml para reemplazar o fusionar reglas.</p>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<label class="flex items-center gap-1.5 text-sm">
|
||||
<input type="radio" x-model="importMode" value="replace"> Reemplazar
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm">
|
||||
<input type="radio" x-model="importMode" value="merge"> Fusionar watches
|
||||
</label>
|
||||
</div>
|
||||
<label class="cursor-pointer">
|
||||
<input type="file" accept=".yaml,.yml" class="hidden" @change="importConfig($event)">
|
||||
<span class="px-4 py-2 bg-white border border-gray-300 text-sm font-medium rounded-lg hover:bg-gray-50 flex items-center gap-2 w-fit">
|
||||
<i class="fa-solid fa-upload"></i> Seleccionar fichero
|
||||
</span>
|
||||
</label>
|
||||
<div x-show="importMsg" class="mt-3 text-sm" :class="importErr ? 'text-red-600' : 'text-green-600'" x-text="importMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<!-- Monaco Editor -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
|
||||
|
||||
<script>
|
||||
function app() {
|
||||
return {
|
||||
page: 'dashboard',
|
||||
status: {},
|
||||
logs: [],
|
||||
configText: '',
|
||||
saveMsg: '',
|
||||
saveErr: false,
|
||||
importMode: 'replace',
|
||||
importMsg: '',
|
||||
importErr: false,
|
||||
editor: null,
|
||||
|
||||
async init() {
|
||||
await this.loadStatus();
|
||||
await this.loadLogs();
|
||||
setInterval(() => this.loadStatus(), 5000);
|
||||
this.initMonaco();
|
||||
},
|
||||
|
||||
async loadStatus() {
|
||||
try {
|
||||
const r = await fetch('/api/status');
|
||||
this.status = await r.json();
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async loadLogs() {
|
||||
try {
|
||||
const r = await fetch('/api/logs');
|
||||
this.logs = await r.json() || [];
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async loadConfig() {
|
||||
const r = await fetch('/api/config');
|
||||
this.configText = await r.text();
|
||||
if (this.editor) this.editor.setValue(this.configText);
|
||||
},
|
||||
|
||||
async reloadConfig() {
|
||||
await this.loadConfig();
|
||||
},
|
||||
|
||||
async saveConfig() {
|
||||
const content = this.editor ? this.editor.getValue() : this.configText;
|
||||
const r = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
body: content,
|
||||
headers: {'Content-Type': 'text/plain'},
|
||||
});
|
||||
const data = await r.json();
|
||||
if (r.ok) {
|
||||
this.saveMsg = '✓ Configuración guardada';
|
||||
this.saveErr = false;
|
||||
} else {
|
||||
this.saveMsg = '✗ ' + (data.error || 'Error al guardar');
|
||||
this.saveErr = true;
|
||||
}
|
||||
setTimeout(() => this.saveMsg = '', 3000);
|
||||
},
|
||||
|
||||
exportConfig() {
|
||||
window.location.href = '/api/export';
|
||||
},
|
||||
|
||||
async importConfig(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
const content = await file.text();
|
||||
const r = await fetch('/api/import?mode=' + this.importMode, {
|
||||
method: 'POST',
|
||||
body: content,
|
||||
});
|
||||
const data = await r.json();
|
||||
if (r.ok) {
|
||||
this.importMsg = '✓ Importado correctamente';
|
||||
this.importErr = false;
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
this.importMsg = '✗ ' + (data.error || 'Error');
|
||||
this.importErr = true;
|
||||
}
|
||||
},
|
||||
|
||||
async togglePause() {
|
||||
const endpoint = this.status.paused ? '/api/resume' : '/api/pause';
|
||||
await fetch(endpoint, {method: 'POST'});
|
||||
await this.loadStatus();
|
||||
},
|
||||
|
||||
initMonaco() {
|
||||
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' }});
|
||||
require(['vs/editor/editor.main'], () => {
|
||||
this.editor = monaco.editor.create(document.getElementById('editor'), {
|
||||
value: this.configText || '# Cargando...',
|
||||
language: 'yaml',
|
||||
theme: 'vs',
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
padding: { top: 12 },
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
badgeClass(status) {
|
||||
if (status === 'success') return 'badge-success';
|
||||
if (status === 'error') return 'badge-error';
|
||||
return 'badge-skip';
|
||||
},
|
||||
|
||||
basename(path) {
|
||||
return path ? path.split('/').pop() : '';
|
||||
},
|
||||
|
||||
ago(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Math.floor((Date.now() - new Date(ts)) / 1000);
|
||||
if (diff < 60) return diff + 's';
|
||||
if (diff < 3600) return Math.floor(diff/60) + 'm';
|
||||
if (diff < 86400) return Math.floor(diff/3600) + 'h';
|
||||
return Math.floor(diff/86400) + 'd';
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,144 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexandrev/homeflow/internal/config"
|
||||
"github.com/alexandrev/homeflow/internal/engine"
|
||||
)
|
||||
|
||||
// Evaluate recursively evaluates a ConditionDef against the ActionContext.
|
||||
func Evaluate(cond *config.ConditionDef, ctx *engine.ActionContext) bool {
|
||||
var result bool
|
||||
|
||||
switch {
|
||||
case len(cond.And) > 0:
|
||||
result = true
|
||||
for i := range cond.And {
|
||||
if !Evaluate(&cond.And[i], ctx) {
|
||||
result = false
|
||||
break
|
||||
}
|
||||
}
|
||||
case len(cond.Or) > 0:
|
||||
result = false
|
||||
for i := range cond.Or {
|
||||
if Evaluate(&cond.Or[i], ctx) {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
field := engine.RenderTemplate(cond.Field, ctx)
|
||||
value := engine.RenderTemplate(cond.Value, ctx)
|
||||
value2 := engine.RenderTemplate(cond.Value2, ctx)
|
||||
result = evaluateOp(field, cond.Operator, value, value2)
|
||||
}
|
||||
|
||||
if cond.Not {
|
||||
return !result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func evaluateOp(field, op, value, value2 string) bool {
|
||||
switch strings.ToLower(op) {
|
||||
// ── String operators ─────────────────────────────────────────────────────
|
||||
case "equals", "eq", "==":
|
||||
return field == value
|
||||
case "not_equals", "ne", "!=":
|
||||
return field != value
|
||||
case "contains":
|
||||
return strings.Contains(field, value)
|
||||
case "not_contains":
|
||||
return !strings.Contains(field, value)
|
||||
case "starts_with":
|
||||
return strings.HasPrefix(field, value)
|
||||
case "ends_with":
|
||||
return strings.HasSuffix(field, value)
|
||||
case "matches":
|
||||
matched, _ := regexp.MatchString(value, field)
|
||||
return matched
|
||||
case "not_matches":
|
||||
matched, _ := regexp.MatchString(value, field)
|
||||
return !matched
|
||||
case "iequals": // case-insensitive equals
|
||||
return strings.EqualFold(field, value)
|
||||
case "icontains":
|
||||
return strings.Contains(strings.ToLower(field), strings.ToLower(value))
|
||||
|
||||
// ── Numeric operators ─────────────────────────────────────────────────────
|
||||
case "gt", ">":
|
||||
a, b, ok := parseFloats(field, value)
|
||||
return ok && a > b
|
||||
case "lt", "<":
|
||||
a, b, ok := parseFloats(field, value)
|
||||
return ok && a < b
|
||||
case "gte", ">=":
|
||||
a, b, ok := parseFloats(field, value)
|
||||
return ok && a >= b
|
||||
case "lte", "<=":
|
||||
a, b, ok := parseFloats(field, value)
|
||||
return ok && a <= b
|
||||
|
||||
// ── Date/time operators ───────────────────────────────────────────────────
|
||||
case "before":
|
||||
t1, t2, ok := parseTimes(field, value)
|
||||
return ok && t1.Before(t2)
|
||||
case "after":
|
||||
t1, t2, ok := parseTimes(field, value)
|
||||
return ok && t1.After(t2)
|
||||
case "between":
|
||||
t0, t1, ok1 := parseTimes(field, value)
|
||||
_, t2, ok2 := parseTimes(field, value2)
|
||||
return ok1 && ok2 && (t0.Equal(t1) || t0.After(t1)) && (t0.Equal(t2) || t0.Before(t2))
|
||||
|
||||
// ── Existence operators ───────────────────────────────────────────────────
|
||||
case "empty", "is_empty":
|
||||
return field == ""
|
||||
case "not_empty", "is_not_empty":
|
||||
return field != ""
|
||||
case "file_exists":
|
||||
_, err := os.Stat(field)
|
||||
return err == nil
|
||||
case "file_not_exists":
|
||||
_, err := os.Stat(field)
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func parseFloats(a, b string) (float64, float64, bool) {
|
||||
fa, err1 := strconv.ParseFloat(strings.TrimSpace(a), 64)
|
||||
fb, err2 := strconv.ParseFloat(strings.TrimSpace(b), 64)
|
||||
return fa, fb, err1 == nil && err2 == nil
|
||||
}
|
||||
|
||||
// parseTimes tries multiple common time formats.
|
||||
var timeFormats = []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, f := range timeFormats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseTimes(a, b string) (time.Time, time.Time, bool) {
|
||||
ta, ok1 := parseTime(a)
|
||||
tb, ok2 := parseTime(b)
|
||||
return ta, tb, ok1 && ok2
|
||||
}
|
||||
+79
-18
@@ -11,9 +11,9 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Settings SettingsConfig `yaml:"settings"`
|
||||
Watches []WatchConfig `yaml:"watches"`
|
||||
Services map[string]ServiceConfig `yaml:"services"`
|
||||
Settings SettingsConfig `yaml:"settings"`
|
||||
Watches []WatchConfig `yaml:"watches"`
|
||||
Services map[string]ServiceConfig `yaml:"services"`
|
||||
}
|
||||
|
||||
type SettingsConfig struct {
|
||||
@@ -21,6 +21,7 @@ type SettingsConfig struct {
|
||||
LogLevel string `yaml:"log_level"`
|
||||
StabilitySecs int `yaml:"file_stability_secs"`
|
||||
RuleMatch string `yaml:"rule_match"` // first | all
|
||||
UIPort int `yaml:"ui_port"`
|
||||
}
|
||||
|
||||
type WatchConfig struct {
|
||||
@@ -46,27 +47,77 @@ type MatchConfig struct {
|
||||
AgeSecsMax int64 `yaml:"age_secs_max"`
|
||||
}
|
||||
|
||||
// ActionDef parses the action type and carries remaining fields as Params.
|
||||
// ConditionDef describes a condition for if/switch actions.
|
||||
type ConditionDef struct {
|
||||
Field string `yaml:"field"`
|
||||
Operator string `yaml:"operator"`
|
||||
Value string `yaml:"value"`
|
||||
Value2 string `yaml:"value2"` // used by "between"
|
||||
And []ConditionDef `yaml:"and"`
|
||||
Or []ConditionDef `yaml:"or"`
|
||||
Not bool `yaml:"not"`
|
||||
}
|
||||
|
||||
// RetryConfig configures automatic retries for an action.
|
||||
type RetryConfig struct {
|
||||
MaxAttempts int `yaml:"max_attempts"`
|
||||
BackoffSecs int `yaml:"backoff_secs"`
|
||||
}
|
||||
|
||||
// ActionDef holds an action type and all its configuration, including
|
||||
// nested sub-pipelines for control flow actions (if, switch, for_each).
|
||||
type ActionDef struct {
|
||||
Type string
|
||||
ContinueOnError bool
|
||||
Params map[string]any
|
||||
Retry *RetryConfig
|
||||
// Control flow fields
|
||||
Condition *ConditionDef
|
||||
Then []ActionDef
|
||||
Else []ActionDef
|
||||
Cases map[string][]ActionDef
|
||||
Default []ActionDef
|
||||
SubActions []ActionDef // "actions" key, used by for_each
|
||||
// All other action-specific params
|
||||
Params map[string]any
|
||||
}
|
||||
|
||||
func (a *ActionDef) UnmarshalYAML(value *yaml.Node) error {
|
||||
var m map[string]any
|
||||
if err := value.Decode(&m); err != nil {
|
||||
return err
|
||||
if value.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("action must be a mapping")
|
||||
}
|
||||
if t, ok := m["type"].(string); ok {
|
||||
a.Type = t
|
||||
delete(m, "type")
|
||||
a.Params = make(map[string]any)
|
||||
|
||||
for i := 0; i < len(value.Content)-1; i += 2 {
|
||||
k := value.Content[i].Value
|
||||
v := value.Content[i+1]
|
||||
|
||||
switch k {
|
||||
case "type":
|
||||
a.Type = v.Value
|
||||
case "continue_on_error":
|
||||
v.Decode(&a.ContinueOnError)
|
||||
case "retry":
|
||||
a.Retry = &RetryConfig{}
|
||||
v.Decode(a.Retry)
|
||||
case "condition":
|
||||
a.Condition = &ConditionDef{}
|
||||
v.Decode(a.Condition)
|
||||
case "then":
|
||||
v.Decode(&a.Then)
|
||||
case "else":
|
||||
v.Decode(&a.Else)
|
||||
case "cases":
|
||||
v.Decode(&a.Cases)
|
||||
case "default":
|
||||
v.Decode(&a.Default)
|
||||
case "actions":
|
||||
v.Decode(&a.SubActions)
|
||||
default:
|
||||
var val any
|
||||
v.Decode(&val)
|
||||
a.Params[k] = val
|
||||
}
|
||||
}
|
||||
if c, ok := m["continue_on_error"].(bool); ok {
|
||||
a.ContinueOnError = c
|
||||
delete(m, "continue_on_error")
|
||||
}
|
||||
a.Params = m
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -113,7 +164,15 @@ func load(path string, resolvePass bool) (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// resolvePassNodes walks the YAML tree and resolves any !pass tagged scalars.
|
||||
// Save writes a Config back to disk as YAML.
|
||||
func Save(cfg *Config, path string) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func resolvePassNodes(node *yaml.Node) error {
|
||||
if node.Kind == yaml.ScalarNode && node.Tag == "!pass" {
|
||||
val, err := runPass(node.Value)
|
||||
@@ -137,7 +196,6 @@ func runPass(key string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// pass show outputs the password on the first line
|
||||
lines := strings.SplitN(string(out), "\n", 2)
|
||||
return lines[0], nil
|
||||
}
|
||||
@@ -155,6 +213,9 @@ func applyDefaults(cfg *Config) {
|
||||
if cfg.Settings.RuleMatch == "" {
|
||||
cfg.Settings.RuleMatch = "first"
|
||||
}
|
||||
if cfg.Settings.UIPort == 0 {
|
||||
cfg.Settings.UIPort = 7070
|
||||
}
|
||||
}
|
||||
|
||||
func expandPaths(cfg *Config) {
|
||||
|
||||
@@ -20,15 +20,24 @@ import (
|
||||
|
||||
// ActionContext is passed to each action in the pipeline.
|
||||
// Actions that rename or move the file must update CurrentPath.
|
||||
// Actions can publish data for subsequent steps via SetVar.
|
||||
type ActionContext struct {
|
||||
CurrentPath string
|
||||
OriginalPath string
|
||||
RuleName string
|
||||
Vars map[string]string // pre-computed template variables
|
||||
Vars map[string]string // template variables, readable by all actions
|
||||
Services map[string]config.ServiceConfig
|
||||
State *state.DB
|
||||
}
|
||||
|
||||
// SetVar stores a key/value pair that subsequent actions can use in templates.
|
||||
func (ctx *ActionContext) SetVar(key, value string) {
|
||||
if ctx.Vars == nil {
|
||||
ctx.Vars = make(map[string]string)
|
||||
}
|
||||
ctx.Vars[key] = value
|
||||
}
|
||||
|
||||
// RenderTemplate replaces {key} and {key:format} placeholders.
|
||||
// Supported keys: filename, stem, ext, hash8, size_kb, seq, date, mtime.
|
||||
func RenderTemplate(tmpl string, ctx *ActionContext) string {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package tray
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/getlantern/systray"
|
||||
)
|
||||
|
||||
// Run starts the macOS menu bar icon. Blocks until quit.
|
||||
// port is the UI server port. pauseFn/resumeFn toggle the watcher.
|
||||
func Run(port int, pauseFn, resumeFn func()) {
|
||||
systray.Run(func() { onReady(port, pauseFn, resumeFn) }, onExit)
|
||||
}
|
||||
|
||||
func onReady(port int, pauseFn, resumeFn func()) {
|
||||
systray.SetTitle("⚙")
|
||||
systray.SetTooltip("homeflow — file automation")
|
||||
|
||||
mStatus := systray.AddMenuItem("● Activo", "")
|
||||
mStatus.Disable()
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
mRules := systray.AddMenuItem("📋 Rule Editor", "Abrir editor de reglas")
|
||||
mLogs := systray.AddMenuItem("📜 Logs", "Ver actividad reciente")
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
mPause := systray.AddMenuItem("⏸ Pausar", "Pausar la monitorización")
|
||||
paused := false
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
mQuit := systray.AddMenuItem("Salir", "Cerrar homeflow")
|
||||
|
||||
uiBase := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-mRules.ClickedCh:
|
||||
openURL(uiBase + "/#rules")
|
||||
case <-mLogs.ClickedCh:
|
||||
openURL(uiBase + "/#logs")
|
||||
case <-mPause.ClickedCh:
|
||||
paused = !paused
|
||||
if paused {
|
||||
pauseFn()
|
||||
mPause.SetTitle("▶ Reanudar")
|
||||
mStatus.SetTitle("⏸ Pausado")
|
||||
} else {
|
||||
resumeFn()
|
||||
mPause.SetTitle("⏸ Pausar")
|
||||
mStatus.SetTitle("● Activo")
|
||||
}
|
||||
case <-mQuit.ClickedCh:
|
||||
systray.Quit()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func onExit() {}
|
||||
|
||||
func openURL(url string) {
|
||||
exec.Command("open", url).Start()
|
||||
}
|
||||
@@ -17,11 +17,18 @@ import (
|
||||
|
||||
// Watcher monitors configured directories and runs rule pipelines on new/modified files.
|
||||
type Watcher struct {
|
||||
cfg *config.Config
|
||||
st *state.DB
|
||||
fsw *fsnotify.Watcher
|
||||
pending map[string]*time.Timer
|
||||
mu sync.Mutex
|
||||
cfg *config.Config
|
||||
st *state.DB
|
||||
fsw *fsnotify.Watcher
|
||||
pending map[string]*time.Timer
|
||||
mu sync.Mutex
|
||||
paused bool
|
||||
}
|
||||
|
||||
func (w *Watcher) SetPaused(p bool) {
|
||||
w.mu.Lock()
|
||||
w.paused = p
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, st *state.DB) (*Watcher, error) {
|
||||
@@ -54,6 +61,12 @@ func (w *Watcher) Start(stop <-chan struct{}) error {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
w.mu.Lock()
|
||||
paused := w.paused
|
||||
w.mu.Unlock()
|
||||
if paused {
|
||||
continue
|
||||
}
|
||||
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
|
||||
info, err := os.Stat(event.Name)
|
||||
if err != nil || info.IsDir() {
|
||||
|
||||
Reference in New Issue
Block a user