package engine import ( "fmt" "os" "path/filepath" "strings" "time" "github.com/spf13/viper" ) type Config struct { DefaultFilter string `mapstructure:"default_filter"` DefaultSort string `mapstructure:"default_sort"` DefaultReport string `mapstructure:"default_report"` ColorOutput bool `mapstructure:"color_output"` WeekStartDay string `mapstructure:"week_start_day"` DefaultDueTime string `mapstructure:"default_due_time"` // Urgency coefficients UrgencyDue float64 `mapstructure:"urgency_due_coefficient"` UrgencyPriorityH float64 `mapstructure:"urgency_priority_h_coefficient"` UrgencyPriorityM float64 `mapstructure:"urgency_priority_m_coefficient"` UrgencyPriorityD float64 `mapstructure:"urgency_priority_d_coefficient"` UrgencyPriorityL float64 `mapstructure:"urgency_priority_l_coefficient"` UrgencyActive float64 `mapstructure:"urgency_active_coefficient"` UrgencyAge float64 `mapstructure:"urgency_age_coefficient"` UrgencyAgeMax int `mapstructure:"urgency_age_max"` UrgencyTags float64 `mapstructure:"urgency_tags_coefficient"` UrgencyProject float64 `mapstructure:"urgency_project_coefficient"` UrgencyWaiting float64 `mapstructure:"urgency_waiting_coefficient"` UrgencyUrgentTag string `mapstructure:"urgency_urgent_tag"` UrgencyUrgentCoeff float64 `mapstructure:"urgency_urgent_coefficient"` NextLimit int `mapstructure:"next_limit"` // Sync settings SyncEnabled bool `mapstructure:"sync_enabled"` SyncURL string `mapstructure:"sync_url"` SyncAPIKey string `mapstructure:"sync_api_key"` SyncClientID string `mapstructure:"sync_client_id"` SyncStrategy string `mapstructure:"sync_strategy"` SyncQueueOffline bool `mapstructure:"sync_queue_offline"` } var globalConfig *Config // Global variables for flag/programmatic overrides var ( configDirOverride string dataDirOverride string ) // SetConfigDirOverride sets the config directory override (typically from --config-dir flag) func SetConfigDirOverride(dir string) { configDirOverride = dir } // SetDataDirOverride sets the data directory override (typically from --data-dir flag) func SetDataDirOverride(dir string) { dataDirOverride = dir } // GetConfigDir returns the configuration directory path // Resolution priority: // 1. Flag override (via SetConfigDirOverride) // 2. OPAL_CONFIG_DIR environment variable // 3. XDG_CONFIG_HOME/opal // 4. /etc/opal (with fallback to ~/.config/opal if not writable) func GetConfigDir() (string, error) { // Priority 1: Flag override if configDirOverride != "" { return configDirOverride, nil } // Priority 2: Environment variable if dir := os.Getenv("OPAL_CONFIG_DIR"); dir != "" { return dir, nil } // Priority 3: XDG_CONFIG_HOME if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { return filepath.Join(xdgConfig, "opal"), nil } // Priority 4: Try /etc/opal, fallback to ~/.config/opal if not writable etcOpal := "/etc/opal" if isWritable(etcOpal) { return etcOpal, nil } // Fallback to user config directory home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get home directory: %w", err) } return filepath.Join(home, ".config", "opal"), nil } // GetDataDir returns the data directory path // Resolution priority: // 1. Flag override (via SetDataDirOverride) // 2. OPAL_DATA_DIR environment variable // 3. XDG_DATA_HOME/opal // 4. /var/lib/opal (with fallback to ~/.local/share/opal if not writable) func GetDataDir() (string, error) { // Priority 1: Flag override if dataDirOverride != "" { return dataDirOverride, nil } // Priority 2: Environment variable if dir := os.Getenv("OPAL_DATA_DIR"); dir != "" { return dir, nil } // Priority 3: XDG_DATA_HOME if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" { return filepath.Join(xdgData, "opal"), nil } // Priority 4: Try /var/lib/opal, fallback to ~/.local/share/opal if not writable varLibOpal := "/var/lib/opal" if isWritable(varLibOpal) { return varLibOpal, nil } // Fallback to user data directory home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get home directory: %w", err) } return filepath.Join(home, ".local", "share", "opal"), nil } // isWritable checks if a directory exists and is writable, or if parent exists and is writable func isWritable(path string) bool { // Check if path exists info, err := os.Stat(path) if err == nil { // Path exists, check if it's a directory and writable if !info.IsDir() { return false } // Test write permission by trying to create a temp file testFile := filepath.Join(path, ".write-test") f, err := os.Create(testFile) if err != nil { return false } f.Close() os.Remove(testFile) return true } // Path doesn't exist, check if parent is writable parent := filepath.Dir(path) parentInfo, err := os.Stat(parent) if err != nil { return false } if !parentInfo.IsDir() { return false } // Test if we can create in parent testFile := filepath.Join(parent, ".write-test") f, err := os.Create(testFile) if err != nil { return false } f.Close() os.Remove(testFile) return true } // GetDBPath returns the path to the SQLite database // Resolution priority: // 1. OPAL_DB_PATH environment variable // 2. {data-dir}/opal.db func GetDBPath() (string, error) { // Priority 1: OPAL_DB_PATH override if dbPath := os.Getenv("OPAL_DB_PATH"); dbPath != "" { return dbPath, nil } // Priority 2: Data directory + opal.db dataDir, err := GetDataDir() if err != nil { return "", err } return filepath.Join(dataDir, "opal.db"), nil } // GetConfigPath returns the path to the config file func GetConfigPath() (string, error) { configDir, err := GetConfigDir() if err != nil { return "", err } return filepath.Join(configDir, "opal.yml"), nil } // GetSyncQueuePath returns the path to the sync queue file func GetSyncQueuePath() (string, error) { dataDir, err := GetDataDir() if err != nil { return "", err } return filepath.Join(dataDir, "sync_queue.json"), nil } // GetSyncConflictLogPath returns the path to the sync conflict log func GetSyncConflictLogPath() (string, error) { dataDir, err := GetDataDir() if err != nil { return "", err } return filepath.Join(dataDir, "sync_conflicts.log"), nil } // LoadConfig loads the configuration from file or creates default func LoadConfig() (*Config, error) { if globalConfig != nil { return globalConfig, nil } configDir, err := GetConfigDir() if err != nil { return nil, err } // Ensure config directory exists if err := os.MkdirAll(configDir, 0755); err != nil { return nil, fmt.Errorf("failed to create config directory: %w", err) } configPath, err := GetConfigPath() if err != nil { return nil, err } v := viper.New() v.SetConfigFile(configPath) v.SetConfigType("yaml") // Set defaults v.SetDefault("default_filter", "status:pending") v.SetDefault("default_sort", "due,priority") v.SetDefault("default_report", "list") v.SetDefault("color_output", true) v.SetDefault("week_start_day", "monday") v.SetDefault("default_due_time", "") // Urgency defaults (adjusted for Opal's simpler model) v.SetDefault("urgency_due_coefficient", 12.0) v.SetDefault("urgency_priority_h_coefficient", 6.0) v.SetDefault("urgency_priority_m_coefficient", 3.9) v.SetDefault("urgency_priority_d_coefficient", 1.8) v.SetDefault("urgency_priority_l_coefficient", 0.0) v.SetDefault("urgency_active_coefficient", 4.0) v.SetDefault("urgency_age_coefficient", 2.0) v.SetDefault("urgency_age_max", 365) v.SetDefault("urgency_tags_coefficient", 1.0) v.SetDefault("urgency_project_coefficient", 1.0) v.SetDefault("urgency_waiting_coefficient", -3.0) v.SetDefault("urgency_urgent_tag", "next") v.SetDefault("urgency_urgent_coefficient", 15.0) v.SetDefault("next_limit", 5) // Sync defaults v.SetDefault("sync_enabled", false) v.SetDefault("sync_url", "") v.SetDefault("sync_api_key", "") v.SetDefault("sync_client_id", "") v.SetDefault("sync_strategy", "last-write-wins") v.SetDefault("sync_queue_offline", true) // Try to read existing config err = v.ReadInConfig() if err != nil { // Config doesn't exist, create it with defaults // Write config with defaults (ignoring the error type check for now) if err := v.WriteConfigAs(configPath); err != nil { return nil, fmt.Errorf("failed to create config file: %w", err) } // Try reading again if err := v.ReadInConfig(); err != nil { return nil, fmt.Errorf("failed to read newly created config: %w", err) } } cfg := &Config{} if err := v.Unmarshal(cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } globalConfig = cfg return cfg, nil } // SaveConfig saves the configuration to file func SaveConfig(cfg *Config) error { configPath, err := GetConfigPath() if err != nil { return err } v := viper.New() v.SetConfigFile(configPath) v.SetConfigType("yaml") v.Set("default_filter", cfg.DefaultFilter) v.Set("default_sort", cfg.DefaultSort) v.Set("default_report", cfg.DefaultReport) v.Set("color_output", cfg.ColorOutput) v.Set("week_start_day", cfg.WeekStartDay) v.Set("default_due_time", cfg.DefaultDueTime) // Urgency settings v.Set("urgency_due_coefficient", cfg.UrgencyDue) v.Set("urgency_priority_h_coefficient", cfg.UrgencyPriorityH) v.Set("urgency_priority_m_coefficient", cfg.UrgencyPriorityM) v.Set("urgency_priority_d_coefficient", cfg.UrgencyPriorityD) v.Set("urgency_priority_l_coefficient", cfg.UrgencyPriorityL) v.Set("urgency_active_coefficient", cfg.UrgencyActive) v.Set("urgency_age_coefficient", cfg.UrgencyAge) v.Set("urgency_age_max", cfg.UrgencyAgeMax) v.Set("urgency_tags_coefficient", cfg.UrgencyTags) v.Set("urgency_project_coefficient", cfg.UrgencyProject) v.Set("urgency_waiting_coefficient", cfg.UrgencyWaiting) v.Set("urgency_urgent_tag", cfg.UrgencyUrgentTag) v.Set("urgency_urgent_coefficient", cfg.UrgencyUrgentCoeff) v.Set("next_limit", cfg.NextLimit) // Sync settings v.Set("sync_enabled", cfg.SyncEnabled) v.Set("sync_url", cfg.SyncURL) v.Set("sync_api_key", cfg.SyncAPIKey) v.Set("sync_client_id", cfg.SyncClientID) v.Set("sync_strategy", cfg.SyncStrategy) v.Set("sync_queue_offline", cfg.SyncQueueOffline) return v.WriteConfig() } // GetConfig returns the loaded config or loads it if not already loaded func GetConfig() (*Config, error) { if globalConfig != nil { return globalConfig, nil } return LoadConfig() } // GetWeekStart returns the configured week start day func (c *Config) GetWeekStart() time.Weekday { if strings.ToLower(c.WeekStartDay) == "sunday" { return time.Sunday } return time.Monday // default } // GetDefaultDueTime returns the configured default due time (HH:MM format or empty) func (c *Config) GetDefaultDueTime() string { return c.DefaultDueTime }