5d01c9f564
Separate configuration from data storage and make paths configurable via environment variables and command-line flags. This improves Unix/Linux compliance and supports both development and production deployments. Key changes: - Separate config dir (opal.yml) from data dir (database, logs) - Support XDG Base Directory specification - Add --config-dir and --data-dir flags - Environment variables: OPAL_CONFIG_DIR, OPAL_DATA_DIR, OPAL_DB_PATH - Smart fallback: /etc/opal, /var/lib/opal -> ~/.config/opal, ~/.local/share/opal - Server mode validates required OAuth/JWT environment variables - Update naming from 'jade' to 'opal' throughout - Update systemd service name to 'opal.service' - Add migration guide in README Default paths: - Config: /etc/opal (fallback: ~/.config/opal) - Data: /var/lib/opal (fallback: ~/.local/share/opal) Files modified: - internal/engine/config.go: New directory resolution logic - internal/engine/database.go: Auto-create data directory - cmd/root.go: Add global flags for directory overrides - cmd/server.go: Add configuration validation - cmd/sync.go, internal/sync/*: Use new path helper functions - tests: Update to use directory overrides - docs: Update deployment guide and README
367 lines
11 KiB
Go
367 lines
11 KiB
Go
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
|
|
}
|