package settings import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "time" ) var diskSafeFilenamePattern = regexp.MustCompile(`[^A-Za-z0-9._-]+`) // LoadNodeActionGroups reads and parses node-action-groups.json from dir. func LoadNodeActionGroups(dir string) (NodeActionGroupStore, error) { path := filepath.Join(dir, NodeActionGroupsFileName) payload, err := os.ReadFile(path) if err != nil { return NodeActionGroupStore{}, err } var store NodeActionGroupStore if err := json.Unmarshal(payload, &store); err != nil { return NodeActionGroupStore{}, fmt.Errorf("parse node action groups: %w", err) } normalizeNodeActionGroupStore(&store) return store, nil } // LoadNodeActionGroupsOrEmpty returns an empty store when the file is missing. func LoadNodeActionGroupsOrEmpty(dir string) (NodeActionGroupStore, error) { store, err := LoadNodeActionGroups(dir) if err != nil { if errors.Is(err, os.ErrNotExist) { return NodeActionGroupStore{Groups: []NodeActionGroup{}}, nil } return NodeActionGroupStore{}, err } return store, nil } // SaveNodeActionGroups writes node-action-groups.json to dir with mode 0640. func SaveNodeActionGroups(dir string, store NodeActionGroupStore) error { if err := ensureConfigDir(dir); err != nil { return err } normalizeNodeActionGroupStore(&store) payload, err := json.MarshalIndent(store, "", " ") if err != nil { return fmt.Errorf("encode node action groups: %w", err) } payload = append(payload, '\n') path := filepath.Join(dir, NodeActionGroupsFileName) if err := os.WriteFile(path, payload, 0o640); err != nil { return fmt.Errorf("write node action groups: %w", err) } return nil } func normalizeNodeActionGroupStore(store *NodeActionGroupStore) { if store.Groups == nil { store.Groups = []NodeActionGroup{} } for index := range store.Groups { if store.Groups[index].Items == nil { store.Groups[index].Items = []NodeActionItem{} } if store.Groups[index].Schedule.Times == nil { store.Groups[index].Schedule.Times = []ActionGroupTimeSpec{} } for itemIndex := range store.Groups[index].Items { if store.Groups[index].Items[itemIndex].Env == nil { store.Groups[index].Items[itemIndex].Env = []ActionEnvVar{} } } } } // DiskSafeFilename converts a group name into a safe filename stem. func DiskSafeFilename(name string) string { trimmed := strings.TrimSpace(name) if trimmed == "" { return "unnamed" } safe := diskSafeFilenamePattern.ReplaceAllString(trimmed, "_") safe = strings.Trim(safe, "._-") if safe == "" { return "unnamed" } if len(safe) > 120 { safe = safe[:120] } return safe } // FindNodeActionGroupIndex returns the index of a group by id, or -1. func FindNodeActionGroupIndex(groups []NodeActionGroup, groupID string) int { for index := range groups { if groups[index].ID == groupID { return index } } return -1 } // FindNodeActionItemIndex returns the index of an item by id, or -1. func FindNodeActionItemIndex(items []NodeActionItem, itemID string) int { for index := range items { if items[index].ID == itemID { return index } } return -1 } // GroupsForNode returns groups belonging to nodeID. func GroupsForNode(store NodeActionGroupStore, nodeID string) []NodeActionGroup { result := make([]NodeActionGroup, 0) for _, group := range store.Groups { if group.NodeID == nodeID { result = append(result, group) } } return result } // LibraryActionInUse reports whether any group item still references actionID as a library source. // Local clones (source local) do not count even if they previously came from that action. func LibraryActionInUse(store NodeActionGroupStore, actionID string) bool { actionID = strings.TrimSpace(actionID) if actionID == "" { return false } for _, group := range store.Groups { for _, item := range group.Items { if item.Source == ActionItemSourceLibrary && item.LibraryActionID == actionID { return true } } } return false } // UpdateGroupLastRunAt sets LastRunAt and UpdatedAt for a group and saves. func UpdateGroupLastRunAt(dir string, groupID string, at time.Time) error { store, err := LoadNodeActionGroupsOrEmpty(dir) if err != nil { return err } index := FindNodeActionGroupIndex(store.Groups, groupID) if index < 0 { return fmt.Errorf("action group not found") } runAt := at.UTC() store.Groups[index].LastRunAt = &runAt store.Groups[index].UpdatedAt = runAt return SaveNodeActionGroups(dir, store) } // IsScheduleDue reports whether the group should run at now given its schedule and LastRunAt. func IsScheduleDue(group NodeActionGroup, now time.Time) bool { schedule := group.Schedule if !schedule.Enabled { return false } localNow := now.In(time.Local) switch schedule.Kind { case ActionGroupScheduleInterval: if schedule.EverySeconds < 1 { return false } if group.LastRunAt == nil { return true } elapsed := localNow.Sub(group.LastRunAt.In(time.Local)) return elapsed >= time.Duration(schedule.EverySeconds)*time.Second case ActionGroupScheduleTimes: if len(schedule.Times) == 0 { return false } for _, spec := range schedule.Times { if !timeSpecMatches(spec, localNow) { continue } if group.LastRunAt == nil { return true } lastLocal := group.LastRunAt.In(time.Local) // Fire once per matching minute window. sameMinute := lastLocal.Year() == localNow.Year() && lastLocal.YearDay() == localNow.YearDay() && lastLocal.Hour() == localNow.Hour() && lastLocal.Minute() == localNow.Minute() if !sameMinute { return true } } return false default: return false } } func timeSpecMatches(spec ActionGroupTimeSpec, now time.Time) bool { hour, minute, ok := parseHHMM(spec.Time) if !ok { return false } if now.Hour() != hour || now.Minute() != minute { return false } if len(spec.DaysOfWeek) == 0 { return true } weekday := int(now.Weekday()) for _, day := range spec.DaysOfWeek { if day == weekday { return true } } return false } func parseHHMM(value string) (hour int, minute int, ok bool) { parts := strings.Split(strings.TrimSpace(value), ":") if len(parts) != 2 { return 0, 0, false } if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil { return 0, 0, false } if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil { return 0, 0, false } if hour < 0 || hour > 23 || minute < 0 || minute > 59 { return 0, 0, false } return hour, minute, true }