From 7f55c389f69f9c13a1eb4c933d78263476a7b45d Mon Sep 17 00:00:00 2001 From: Brad Jones Date: Tue, 30 Dec 2025 11:52:21 -0800 Subject: [PATCH 1/2] Add support for TOML for config files. Config will default to TOML and fall back to JSON if the TOML file doesn't exist. JSON should be considered deprecated at this time and new options will not be added to it. --- .gitignore | 1 + README.md | 41 +++++++-------- calblink.go | 3 +- config.go | 144 ++++++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 152 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 0e1cefe..9566f23 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ go.sum calblink client_secret.json conf.json +conf.toml diff --git a/README.md b/README.md index 70a020c..d1fb227 100644 --- a/README.md +++ b/README.md @@ -109,8 +109,9 @@ To use calblink, you need the following: First off, run it with the --help option to see what the command-line options are. Useful, perhaps, but maybe not what you want to use every time you run it. -calblink will look for a file named (by default) conf.json for its configuration -options. conf.json includes several useful options you can set: +calblink will look for a file named (by default) conf.toml for its configuration +options; if that doesn't exist, it will look for conf.json. The configuration file +includes several useful options you can set: * excludes - a list of event titles which it will ignore. If you like blocking out time with "Make Time" or similar, you can add these names to the @@ -169,26 +170,26 @@ options. conf.json includes several useful options you can set: * 'office:NAME' to match an office location called NAME. * 'custom:NAME' to match a custom location called NAME. -An example file: - -```json - { - "excludes": ["Commute"], - "skipDays": ["Saturday", "Sunday"], - "startTime": "08:45", - "endTime": "18:00", - "pollInterval": 60, - "calendars": ["primary", "username@example.com"], - "responseState": "accepted", - "multiEvent": "true", - "priorityFlashSide": 1, - "workingLocations": ["home"] - } +An example TOML file: + +```toml + excludes = ["Commute"] + skipDays = ["Saturday", "Sunday"] + startTime = "08:45" + endTime = "18:00" + pollInterval = 60 + calendars = ["primary", "username@example.com"] + responseState = "accepted" + multiEvent = true + priorityFlashSide = 1 + workingLocations = ["home"] ``` -(Yes, the curly braces are required. Sorry. It's a JSON dictionary.) - - +The JSON version should be considered deprecated, and new options will not be added +to it. At some later date, it may be removed entirely. Migrating to TOML is +recommended, not least because it's a much cleaner file format that supports handy +features like "comments" and "trailing commas in arrays" and "not needing to be wrapped +in braces and having a comma after every field". ### New Requirements diff --git a/calblink.go b/calblink.go index 5f62b39..f803583 100644 --- a/calblink.go +++ b/calblink.go @@ -29,7 +29,8 @@ var debugFlag = flag.Bool("debug", false, "Show debug messages") var verboseFlag = flag.Bool("verbose", false, "Show verbose debug messages (forces --debug to true)") var clientSecretFlag = flag.String("clientsecret", "client_secret.json", "Path to JSON file containing client secret") var calNameFlag = flag.String("calendar", "primary", "Name of calendar to base blinker on (overrides value in config file)") -var configFileFlag = flag.String("config", "conf.json", "Path to configuration file") +var configFileFlag = flag.String("config", "conf.toml", "Path to configuration file") +var backupConfigFileFlag = flag.String("backup_config", "conf.json", "Path to configuration file that will be used if config doesn't exist.") var pollIntervalFlag = flag.Int("poll_interval", 30, "Number of seconds between polls of calendar API (overrides value in config file)") var responseStateFlag = flag.String("response_state", "notRejected", "Which events to consider based on response: all, accepted, or notRejected") var deviceFailureRetriesFlag = flag.Int("device_failure_retries", 10, "Number of times to retry initializing the device before quitting the program") diff --git a/config.go b/config.go index ed390b7..d0ccbf6 100644 --- a/config.go +++ b/config.go @@ -18,29 +18,34 @@ package main import ( "encoding/json" + "errors" "fmt" + "io/fs" "log" "os" "strings" "time" + + "github.com/BurntSushi/toml" ) // Configuration file: -// JSON file with the following structure: -// { -// excludes: [ "event", "names", "to", "ignore"], -// excludePrefixes: [ "prefixes", "to", "ignore"], -// startTime: "hh:mm (24 hr format) to start blinking at every day", -// endTime: "hh:mm (24 hr format) to stop blinking at every day", -// skipDays: [ "weekdays", "to", "skip"], -// pollInterval: 30 -// calendar: "calendar" -// responseState: "all" -// deviceFailureRetries: 10 -// showDots: true -// multiEvent: true -// priorityFlashSide: 1 -//} +// TOML file with the following structure: +// excludes = ["event", "names", "to", "ignore"] +// excludePrefixes = ["prefixes", "to", "ignore"] +// startTime = "hh:mm (24 hr format) to start blinking at every day" +// endTime = "hh:mm (24 hr format) to stop blinking at every day" +// skipDays = [ "weekdays", "to", "skip"] +// pollInterval = 30 +// calendar = "calendar" +// responseState = "all" +// deviceFailureRetries = 10 +// showDots = true +// multiEvent = true +// priorityFlashSide = 1 +// +// An older JSON format is also supported but you don't want to use it. +// // Notes on items: // Calendar is the calendar ID - the email address of the calendar. For a person's calendar, that's their email. // For a secondary calendar, it's the base64 string @group.calendar.google.com on the calendar details page. "primary" @@ -89,6 +94,23 @@ type prefLayout struct { WorkingLocations []string } +type tomlLayout struct { + Excludes []string + ExcludePrefixes []string + StartTime string + EndTime string + SkipDays []string + PollInterval int64 + Calendar string + Calendars []string + ResponseState string + DeviceFailureRetries int64 + ShowDots bool + MultiEvent bool + PriorityFlashSide int64 + WorkingLocations []string +} + // responseState is an enumerated list of event response states, used to control which events will activate the blink(1). type ResponseState string @@ -179,6 +201,96 @@ func makeWorkSite(location string) WorkSite { // User preferences methods func readUserPrefs() *UserPrefs { + configFile := *configFileFlag + _, err := os.Stat(configFile) + if errors.Is(err, fs.ErrNotExist) { + // primary file doesn't exist, try the secondary. + configFile = *backupConfigFileFlag + } + if strings.HasSuffix(configFile, "toml") { + return readTomlPrefs(configFile) + } else { + return readJsonPrefs(configFile) + } +} + +func readTomlPrefs(configFile string) *UserPrefs { + prefs := tomlLayout{} + userPrefs := &UserPrefs{} + // Set defaults from command line + userPrefs.PollInterval = *pollIntervalFlag + userPrefs.Calendars = []string{*calNameFlag} + userPrefs.ResponseState = ResponseState(*responseStateFlag) + userPrefs.DeviceFailureRetries = *deviceFailureRetriesFlag + userPrefs.ShowDots = *showDotsFlag + _, err := toml.DecodeFile(configFile, &prefs) + debugLog("Decoded TOML: %v\n", prefs) + if err != nil { + log.Fatalf("Unable to parse config file %v", err) + } + if prefs.StartTime != "" { + startTime, err := time.Parse("15:04", prefs.StartTime) + if err != nil { + log.Fatalf("Invalid start time %v : %v", prefs.StartTime, err) + } + userPrefs.StartTime = &startTime + } + if prefs.EndTime != "" { + endTime, err := time.Parse("15:04", prefs.EndTime) + if err != nil { + log.Fatalf("Invalid end time %v : %v", prefs.EndTime, err) + } + userPrefs.EndTime = &endTime + } + userPrefs.Excludes = make(map[string]bool) + for _, item := range prefs.Excludes { + debugLog("Excluding item %v\n", item) + userPrefs.Excludes[item] = true + } + userPrefs.ExcludePrefixes = prefs.ExcludePrefixes + weekdays := make(map[string]int) + for i := 0; i < 7; i++ { + weekdays[time.Weekday(i).String()] = i + } + for _, day := range prefs.SkipDays { + i, ok := weekdays[day] + if ok { + userPrefs.SkipDays[i] = true + } else { + log.Fatalf("Invalid day in skipdays: %v", day) + } + } + if prefs.Calendar != "" { + userPrefs.Calendars = []string{prefs.Calendar} + } + if len(prefs.Calendars) > 0 { + userPrefs.Calendars = prefs.Calendars + } + if prefs.PollInterval != 0 { + userPrefs.PollInterval = int(prefs.PollInterval) + } + if prefs.ResponseState != "" { + userPrefs.ResponseState = ResponseState(prefs.ResponseState) + if !userPrefs.ResponseState.isValidState() { + log.Fatalf("Invalid response state %v", prefs.ResponseState) + } + } + if prefs.DeviceFailureRetries != 0 { + userPrefs.DeviceFailureRetries = int(prefs.DeviceFailureRetries) + } + userPrefs.ShowDots = prefs.ShowDots + userPrefs.MultiEvent = prefs.MultiEvent + if prefs.PriorityFlashSide != 0 { + userPrefs.PriorityFlashSide = int(prefs.PriorityFlashSide) + } + for _, location := range prefs.WorkingLocations { + userPrefs.WorkingLocations = append(userPrefs.WorkingLocations, makeWorkSite(location)) + } + debugLog("User prefs: %v\n", userPrefs) + return userPrefs +} + +func readJsonPrefs(configFile string) *UserPrefs { userPrefs := &UserPrefs{} // Set defaults from command line userPrefs.PollInterval = *pollIntervalFlag @@ -186,7 +298,7 @@ func readUserPrefs() *UserPrefs { userPrefs.ResponseState = ResponseState(*responseStateFlag) userPrefs.DeviceFailureRetries = *deviceFailureRetriesFlag userPrefs.ShowDots = *showDotsFlag - file, err := os.Open(*configFileFlag) + file, err := os.Open(configFile) defer file.Close() if err != nil { // Lack of a config file is not a fatal error. From a7b5994819f0aacc73b80a606ec87cc3b90e38b4 Mon Sep 17 00:00:00 2001 From: Brad Jones Date: Tue, 30 Dec 2025 12:02:14 -0800 Subject: [PATCH 2/2] Better handling for the case where there is no config file. --- config.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/config.go b/config.go index d0ccbf6..8e0a670 100644 --- a/config.go +++ b/config.go @@ -207,6 +207,14 @@ func readUserPrefs() *UserPrefs { // primary file doesn't exist, try the secondary. configFile = *backupConfigFileFlag } + _, err = os.Stat(configFile) + if errors.Is(err, fs.ErrNotExist) { + // There is no config file, so grab the default prefs. + // Lack of a config file is not a fatal error. + debugLog("No config file found.\n") + return getDefaultPrefs() + } + debugLog("Reading from config file %v\n", configFile) if strings.HasSuffix(configFile, "toml") { return readTomlPrefs(configFile) } else { @@ -214,8 +222,7 @@ func readUserPrefs() *UserPrefs { } } -func readTomlPrefs(configFile string) *UserPrefs { - prefs := tomlLayout{} +func getDefaultPrefs() *UserPrefs { userPrefs := &UserPrefs{} // Set defaults from command line userPrefs.PollInterval = *pollIntervalFlag @@ -223,6 +230,12 @@ func readTomlPrefs(configFile string) *UserPrefs { userPrefs.ResponseState = ResponseState(*responseStateFlag) userPrefs.DeviceFailureRetries = *deviceFailureRetriesFlag userPrefs.ShowDots = *showDotsFlag + return userPrefs +} + +func readTomlPrefs(configFile string) *UserPrefs { + prefs := tomlLayout{} + userPrefs := getDefaultPrefs() _, err := toml.DecodeFile(configFile, &prefs) debugLog("Decoded TOML: %v\n", prefs) if err != nil { @@ -291,13 +304,8 @@ func readTomlPrefs(configFile string) *UserPrefs { } func readJsonPrefs(configFile string) *UserPrefs { - userPrefs := &UserPrefs{} // Set defaults from command line - userPrefs.PollInterval = *pollIntervalFlag - userPrefs.Calendars = []string{*calNameFlag} - userPrefs.ResponseState = ResponseState(*responseStateFlag) - userPrefs.DeviceFailureRetries = *deviceFailureRetriesFlag - userPrefs.ShowDots = *showDotsFlag + userPrefs := getDefaultPrefs() file, err := os.Open(configFile) defer file.Close() if err != nil {