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>
282 lines
5.9 KiB
Go
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
|
|
}
|