Files
homeflow/internal/engine/engine.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

282 lines
5.9 KiB
Go

package engine
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/h2non/filetype"
"github.com/alexandrev/homeflow/internal/config"
"github.com/alexandrev/homeflow/internal/state"
)
// 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 // 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 {
var sb strings.Builder
i := 0
for i < len(tmpl) {
if tmpl[i] == '{' {
j := strings.Index(tmpl[i:], "}")
if j == -1 {
sb.WriteByte(tmpl[i])
i++
continue
}
token := tmpl[i+1 : i+j]
i += j + 1
sb.WriteString(resolveToken(token, ctx))
} else {
sb.WriteByte(tmpl[i])
i++
}
}
return sb.String()
}
func resolveToken(token string, ctx *ActionContext) string {
key, format, hasFormat := strings.Cut(token, ":")
switch key {
case "filename":
return filepath.Base(ctx.CurrentPath)
case "stem":
base := filepath.Base(ctx.CurrentPath)
ext := filepath.Ext(base)
return strings.TrimSuffix(base, ext)
case "ext":
return filepath.Ext(ctx.CurrentPath)
case "hash8":
if h, ok := ctx.Vars["hash8"]; ok {
return h
}
return ""
case "size_kb":
if s, ok := ctx.Vars["size_kb"]; ok {
return s
}
return "0"
case "seq":
if s, ok := ctx.Vars["seq"]; ok {
return s
}
return "0"
case "date":
if hasFormat {
return time.Now().Format(format)
}
return time.Now().Format("2006-01-02")
case "mtime":
if ts, ok := ctx.Vars["mtime"]; ok {
if hasFormat {
t, err := time.Parse(time.RFC3339, ts)
if err == nil {
return t.Format(format)
}
}
return ts
}
return ""
}
// fall back to Vars map
if v, ok := ctx.Vars[key]; ok {
return v
}
return "{" + token + "}"
}
// MatchFile checks if a file matches a rule's conditions.
func MatchFile(path string, rule *config.RuleConfig) (bool, error) {
info, err := os.Stat(path)
if err != nil {
return false, err
}
m := &rule.Match
// Extension check
if len(m.Extensions) > 0 {
ext := strings.ToLower(filepath.Ext(path))
found := false
for _, e := range m.Extensions {
if strings.ToLower(e) == ext {
found = true
break
}
}
if !found {
return false, nil
}
}
// Name pattern (glob)
if m.NamePattern != "" {
matched, err := filepath.Match(m.NamePattern, filepath.Base(path))
if err != nil {
return false, fmt.Errorf("invalid name_pattern: %w", err)
}
if !matched {
return false, nil
}
}
// Name regex
if m.NameRegex != "" {
re, err := regexp.Compile(m.NameRegex)
if err != nil {
return false, fmt.Errorf("invalid name_regex: %w", err)
}
if !re.MatchString(filepath.Base(path)) {
return false, nil
}
}
// Size checks
sizeKB := info.Size() / 1024
if m.MinSizeKB > 0 && sizeKB < m.MinSizeKB {
return false, nil
}
if m.MaxSizeKB > 0 && sizeKB > m.MaxSizeKB {
return false, nil
}
// Age checks
ageSecs := int64(time.Since(info.ModTime()).Seconds())
if m.AgeSecsMin > 0 && ageSecs < m.AgeSecsMin {
return false, nil
}
if m.AgeSecsMax > 0 && ageSecs > m.AgeSecsMax {
return false, nil
}
// MIME type check (uses magic bytes — relatively expensive, do last)
if len(m.MimeTypes) > 0 {
mime, err := detectMIME(path)
if err != nil {
return false, err
}
found := false
for _, mt := range m.MimeTypes {
if strings.EqualFold(mt, mime) {
found = true
break
}
}
if !found {
return false, nil
}
}
return true, nil
}
func detectMIME(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
buf := make([]byte, 261)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return "", err
}
kind, _ := filetype.Match(buf[:n])
if kind == filetype.Unknown {
return "application/octet-stream", nil
}
return kind.MIME.Value, nil
}
// BuildContext creates an ActionContext for a matched file + rule.
func BuildContext(path string, rule *config.RuleConfig, services map[string]config.ServiceConfig, st *state.DB) (*ActionContext, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
vars := map[string]string{
"mtime": info.ModTime().UTC().Format(time.RFC3339),
"size_kb": strconv.FormatInt(info.Size()/1024, 10),
}
// Compute hash8 lazily by doing it once here
if h, err := fileHash8(path); err == nil {
vars["hash8"] = h
}
// Get seq for this rule
var seqStr string
if st != nil {
if seq, err := st.NextSeq(rule.Name); err == nil {
seqStr = strconv.FormatInt(seq, 10)
}
}
vars["seq"] = seqStr
return &ActionContext{
CurrentPath: path,
OriginalPath: path,
RuleName: rule.Name,
Vars: vars,
Services: services,
State: st,
}, nil
}
func fileHash8(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))[:8], nil
}
// FileSHA256 returns the full hex SHA256 of a file (used for dedup in state).
func FileSHA256(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
}