// yaml.go — Appends key entries to config.yaml without destroying comments. // // ## Why raw text append instead of yaml.Marshal? // // yaml.v3's Marshal destroys comments, reorders map keys, and normalises // formatting. Users keep the default config's comments (key layout diagram, // examples, etc.), so we must preserve them. The approach: // // - Parse config.yaml with config.Load() for DATA (which keys exist, icons_dir, etc.) // - Write new keys by appending raw YAML text to the end of the file // // This works because keys: is always the last top-level block in config.yaml, // and YAML maps are unordered, so appending a new 2-space-indented key entry // to the end of the file is valid. // // ## Three cases handled by appendKeyToConfig // // Case A: "keys: {}" — the empty map literal from the default config generator. // // Replace {} with a newline, then insert the key block. This is the most // common case on a fresh install. // // Case B: "keys:" with existing children — the normal case after keys exist. // // Append the new key block to the end of the file. It lands inside the // keys: block because it's indented with 2 spaces. // // Case C: no "keys:" block at all — a malformed or minimal config. // // Append "keys:\n" then the key block. // // ## Expanding // // Toggle keys (icon_true/icon_false + poll block) are supported — see IsToggle // and the Poll* fields on keyEntry, and the toggle branch in renderKeySnippet(). // // To support deleting or editing existing keys, you'd need a different strategy // (e.g. yaml.v3 node-level manipulation). That's not in scope for the current // append-only tool. package main import ( "fmt" "os" "regexp" "sort" "strings" ) // keyEntry holds the data collected from the TUI forms, ready to be rendered // as a YAML snippet and appended to config.yaml. // // Three mutually exclusive modes: // - Module key: Module + Function + Params are set, Command is empty // - Command key: Command is set, Module/Function/Params are empty // - Toggle key: IsToggle is true, IconTrue/IconFalse replace Icon, // and Poll* fields define state checking. Press action uses Module or Command. type keyEntry struct { Index int // key slot (0-31) Icon string // icon filename relative to icons_dir Module string // module name from modules.yaml (e.g. "slack") Function string // function name within the module (e.g. "set_status") Params map[string]string // param overrides (merged with module defaults at runtime) Command string // raw shell command (non-module keys only) // Toggle key fields IsToggle bool IconTrue string // icon when poll state is true/on IconFalse string // icon when poll state is false/off PollCommand string // shell command to check state PollModule string // module for poll (alternative to PollCommand) PollFunction string // function for poll PollParams map[string]string // poll param overrides PollMatch string // substring match in output -> true state PollInterval string // poll frequency (e.g. "2s") } // appendKeyToConfig reads config.yaml, appends a key entry as raw YAML text, // and writes the file back. Preserves all existing content including comments. func appendKeyToConfig(path string, entry keyEntry) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read config: %w", err) } content := string(data) snippet := renderKeySnippet(entry) // Case A: keys: {} — empty map literal from the default config generator. // Replace the {} with a newline so the snippet becomes the first child. emptyKeys := regexp.MustCompile(`(?m)^keys:\s*\{\}\s*$`) if emptyKeys.MatchString(content) { content = emptyKeys.ReplaceAllString(content, "keys:\n"+snippet) return os.WriteFile(path, []byte(content), 0644) } // Case B: keys: exists with children — append snippet to end of file. // The 2-space indent on the snippet places it inside the keys: block. hasKeys := regexp.MustCompile(`(?m)^keys:\s*$`) if hasKeys.MatchString(content) { if !strings.HasSuffix(content, "\n") { content += "\n" } content += snippet return os.WriteFile(path, []byte(content), 0644) } // Case C: no keys: block at all — append both the block header and snippet. if !strings.HasSuffix(content, "\n") { content += "\n" } content += "\nkeys:\n" + snippet return os.WriteFile(path, []byte(content), 0644) } // renderKeySnippet formats a keyEntry as an indented YAML block (2-space indent // for the key index, 4-space for fields, 6-space for params). // // Module key output: // // 3: // icon: status.png // module: slack // function: set_status // params: // emoji: ":brb:" // text: "BRB" // // Command key output: // // 3: // icon: firefox.png // command: "open -a Firefox" func renderKeySnippet(e keyEntry) string { var b strings.Builder fmt.Fprintf(&b, " %d:\n", e.Index) if e.IsToggle { fmt.Fprintf(&b, " icon_true: %s\n", e.IconTrue) fmt.Fprintf(&b, " icon_false: %s\n", e.IconFalse) } else { fmt.Fprintf(&b, " icon: %s\n", e.Icon) } if e.Module != "" { fmt.Fprintf(&b, " module: %s\n", e.Module) fmt.Fprintf(&b, " function: %s\n", e.Function) if len(e.Params) > 0 { b.WriteString(" params:\n") // Sort param keys for deterministic, diffable output. keys := make([]string, 0, len(e.Params)) for k := range e.Params { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { fmt.Fprintf(&b, " %s: \"%s\"\n", k, e.Params[k]) } } } else { fmt.Fprintf(&b, " command: \"%s\"\n", e.Command) } if e.IsToggle { b.WriteString(" poll:\n") if e.PollModule != "" { fmt.Fprintf(&b, " module: %s\n", e.PollModule) fmt.Fprintf(&b, " function: %s\n", e.PollFunction) if len(e.PollParams) > 0 { b.WriteString(" params:\n") pkeys := make([]string, 0, len(e.PollParams)) for k := range e.PollParams { pkeys = append(pkeys, k) } sort.Strings(pkeys) for _, k := range pkeys { fmt.Fprintf(&b, " %s: \"%s\"\n", k, e.PollParams[k]) } } } else { fmt.Fprintf(&b, " command: \"%s\"\n", e.PollCommand) } if e.PollMatch != "" { fmt.Fprintf(&b, " match: \"%s\"\n", e.PollMatch) } if e.PollInterval != "" { fmt.Fprintf(&b, " interval: \"%s\"\n", e.PollInterval) } } return b.String() }