package config import ( "bufio" "fmt" "os" "path/filepath" "strconv" "strings" "time" "vrc_osc_go/internal/common" ) type Config struct { OSC OSCConfig VrcLog VrcLogConfig GUI GUIConfig } type OSCConfig struct { Host string Port int } type VrcLogConfig struct { SelfName string GuestNames []string GuestFile string StaffNames []string MissingCount int LogPatterns []string } type GUIConfig struct { TopMost bool FontSize int AutoUnmuteOnSelfLeave bool RefreshIntervalValue int RefreshIntervalUnit string HistoryRegex string HistoryFromDate string HistoryToDate string } func Load(path string) (*Config, error) { cfg := &Config{ OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}, GUI: GUIConfig{FontSize: 18, RefreshIntervalValue: 2, RefreshIntervalUnit: "sec"}, } if path == "" { path = filepath.Join(common.RootDir(), "config", "config.toml") } else if !filepath.IsAbs(path) { path = filepath.Join(common.RootDir(), path) } file, err := os.Open(path) if err != nil { return cfg, nil } defer file.Close() var section string scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) line = strings.TrimPrefix(line, "\ufeff") if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { section = strings.Trim(line, "[]") continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) val := strings.TrimSpace(parts[1]) val = strings.Trim(val, "\"'") switch section { case "osc": switch key { case "host": cfg.OSC.Host = val case "port": if n, err := strconv.Atoi(val); err == nil { cfg.OSC.Port = n } } case "self": if key == "name" { cfg.VrcLog.SelfName = val } case "notice": if key == "missing_count" { if n, err := strconv.Atoi(val); err == nil { cfg.VrcLog.MissingCount = n } } case "vrc_log": if key == "patterns" { cfg.VrcLog.LogPatterns = parseList(val) } case "staff": if key == "names" { cfg.VrcLog.StaffNames = parseList(val) } case "guest": switch key { case "names": cfg.VrcLog.GuestNames = parseList(val) case "file": cfg.VrcLog.GuestFile = val } case "gui": switch key { case "top_most": cfg.GUI.TopMost = parseBool(val, cfg.GUI.TopMost) case "font_size": if n, err := strconv.Atoi(val); err == nil && n > 0 { cfg.GUI.FontSize = n } case "auto_unmute_on_self_leave": cfg.GUI.AutoUnmuteOnSelfLeave = parseBool(val, cfg.GUI.AutoUnmuteOnSelfLeave) case "refresh_interval_value": if n, err := strconv.Atoi(val); err == nil && n > 0 { cfg.GUI.RefreshIntervalValue = n } case "refresh_interval_unit": cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(val) case "history_regex": cfg.GUI.HistoryRegex = val case "history_from": cfg.GUI.HistoryFromDate = normalizeHistoryDate(val) case "history_to": cfg.GUI.HistoryToDate = normalizeHistoryDate(val) } } } if cfg.GUI.RefreshIntervalValue <= 0 { cfg.GUI.RefreshIntervalValue = 2 } cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(cfg.GUI.RefreshIntervalUnit) cfg.GUI.HistoryFromDate = normalizeHistoryDate(cfg.GUI.HistoryFromDate) cfg.GUI.HistoryToDate = normalizeHistoryDate(cfg.GUI.HistoryToDate) if cfg.VrcLog.GuestFile != "" { if !filepath.IsAbs(cfg.VrcLog.GuestFile) { cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile) } if names, err := loadLines(cfg.VrcLog.GuestFile); err == nil { cfg.VrcLog.GuestNames = names } } return cfg, scanner.Err() } func SaveGUI(path string, gui GUIConfig) error { path = resolvePath(path) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return err } content := upsertGUISection(string(data), gui) return os.WriteFile(path, []byte(content), 0o644) } func parseList(value string) []string { value = strings.TrimSpace(value) value = strings.TrimPrefix(value, "[") value = strings.TrimSuffix(value, "]") if value == "" { return nil } parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(strings.Trim(part, "\"'")) if part != "" { out = append(out, part) } } return out } func parseBool(value string, fallback bool) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "true", "1", "on", "yes": return true case "false", "0", "off", "no": return false default: return fallback } } func resolvePath(path string) string { if path == "" { return filepath.Join(common.RootDir(), "config", "config.toml") } if !filepath.IsAbs(path) { return filepath.Join(common.RootDir(), path) } return path } func upsertGUISection(existing string, gui GUIConfig) string { normalized := strings.ReplaceAll(existing, "\r\n", "\n") lines := strings.Split(normalized, "\n") out := make([]string, 0, len(lines)+6) inGUI := false replaced := false for _, raw := range lines { line := raw trimmed := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { section := strings.Trim(trimmed, "[]") if section == "gui" { if !replaced { appendGUISection(&out, gui) replaced = true } inGUI = true continue } if inGUI { inGUI = false } out = append(out, line) continue } if inGUI { continue } out = append(out, line) } if !replaced { if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { out = append(out, "") } appendGUISection(&out, gui) } content := strings.Join(out, "\n") content = strings.TrimRight(content, "\n") return content + "\n" } func appendGUISection(out *[]string, gui GUIConfig) { gui.RefreshIntervalUnit = normalizeRefreshIntervalUnit(gui.RefreshIntervalUnit) if gui.RefreshIntervalValue <= 0 { gui.RefreshIntervalValue = 2 } *out = append(*out, "[gui]", fmt.Sprintf("top_most = %t", gui.TopMost), fmt.Sprintf("font_size = %d", gui.FontSize), fmt.Sprintf("auto_unmute_on_self_leave = %t", gui.AutoUnmuteOnSelfLeave), fmt.Sprintf("refresh_interval_value = %d", gui.RefreshIntervalValue), fmt.Sprintf("refresh_interval_unit = %q", gui.RefreshIntervalUnit), fmt.Sprintf("history_regex = %q", gui.HistoryRegex), fmt.Sprintf("history_from = %q", gui.HistoryFromDate), fmt.Sprintf("history_to = %q", gui.HistoryToDate), ) } func normalizeRefreshIntervalUnit(unit string) string { switch strings.ToLower(strings.TrimSpace(unit)) { case "min", "minute", "minutes": return "min" default: return "sec" } } func (g GUIConfig) RefreshInterval() time.Duration { value := g.RefreshIntervalValue if value <= 0 { value = 2 } switch normalizeRefreshIntervalUnit(g.RefreshIntervalUnit) { case "min": return time.Duration(value) * time.Minute default: return time.Duration(value) * time.Second } } func normalizeHistoryDate(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if _, err := time.Parse("2006-01-02", value); err != nil { return "" } return value } func loadLines(path string) ([]string, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } lines := strings.Split(string(data), "\n") out := make([]string, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) if line != "" && !strings.HasPrefix(line, "#") { out = append(out, line) } } return out, nil }