feat: initial gosleep-timer implementation
Go TUI sleep timer with modular architecture. - Config: YAML-based, profile inheritance, auto WM detection - Engine: timer, executor, stage pipeline (pre→timer→post) - Modules: workspace (niri/hyprland/kde), media, lock, kill, notify, sound, brightness, mute, custom, script - TUI: bubbletea app with profiles, module toggles, progress bar - CLI: run, list-profiles, export, import, history, stats - History: JSONL log with statistics - QR: config export as ASCII QR
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func DefaultConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("home dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".config", "gosleep-timer", "config.yaml"), nil
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = make(map[string]Profile)
|
||||
}
|
||||
|
||||
resolved := make(map[string]Profile, len(cfg.Profiles))
|
||||
for name := range cfg.Profiles {
|
||||
seen := make(map[string]bool)
|
||||
r := resolveExtends(cfg.Profiles, name, seen)
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("circular extends detected for profile %q", name)
|
||||
}
|
||||
resolved[name] = *r
|
||||
}
|
||||
cfg.Profiles = resolved
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func Save(path string, cfg *Config) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir config dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadOrCreate(path string) (*Config, error) {
|
||||
cfg, err := Load(path)
|
||||
if err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg = DefaultConfig()
|
||||
if err := Save(path, cfg); err != nil {
|
||||
return nil, fmt.Errorf("create default config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func SaveToString(cfg *Config) (string, error) {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func DetectDesktop() string {
|
||||
if de := os.Getenv("XDG_CURRENT_DESKTOP"); de != "" {
|
||||
lower := strings.ToLower(de)
|
||||
if strings.Contains(lower, "hyprland") {
|
||||
return "hyprland"
|
||||
}
|
||||
if strings.Contains(lower, "kde") || strings.Contains(lower, "plasma") {
|
||||
return "kde"
|
||||
}
|
||||
if strings.Contains(lower, "niri") {
|
||||
return "niri"
|
||||
}
|
||||
}
|
||||
|
||||
if err := exec.Command("hyprctl", "instances").Run(); err == nil {
|
||||
return "hyprland"
|
||||
}
|
||||
|
||||
if err := exec.Command("niri", "msg", "-h").Run(); err == nil {
|
||||
return "niri"
|
||||
}
|
||||
|
||||
return "generic"
|
||||
}
|
||||
|
||||
func resolveExtends(profiles map[string]Profile, name string, seen map[string]bool) *Profile {
|
||||
if seen[name] {
|
||||
return nil
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
p, ok := profiles[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
base := &Profile{}
|
||||
if p.Extends != "" {
|
||||
base = resolveExtends(profiles, p.Extends, seen)
|
||||
if base == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
merged := &Profile{
|
||||
Extends: p.Extends,
|
||||
Modules: copyModules(base.Modules),
|
||||
}
|
||||
|
||||
if p.Modules != nil {
|
||||
if merged.Modules == nil {
|
||||
merged.Modules = &Modules{}
|
||||
}
|
||||
overlayModules(merged.Modules, p.Modules)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func copyModules(m *Modules) *Modules {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
c := &Modules{}
|
||||
if m.Workspace != nil {
|
||||
v := *m.Workspace
|
||||
c.Workspace = &v
|
||||
}
|
||||
if m.Media != nil {
|
||||
v := *m.Media
|
||||
c.Media = &v
|
||||
}
|
||||
if m.Lock != nil {
|
||||
v := *m.Lock
|
||||
c.Lock = &v
|
||||
}
|
||||
if m.Kill != nil {
|
||||
v := *m.Kill
|
||||
v.Processes = append([]string(nil), m.Kill.Processes...)
|
||||
c.Kill = &v
|
||||
}
|
||||
if m.Notify != nil {
|
||||
v := *m.Notify
|
||||
c.Notify = &v
|
||||
}
|
||||
if m.Sound != nil {
|
||||
v := *m.Sound
|
||||
c.Sound = &v
|
||||
}
|
||||
if m.Brightness != nil {
|
||||
v := *m.Brightness
|
||||
c.Brightness = &v
|
||||
}
|
||||
if m.Mute != nil {
|
||||
v := *m.Mute
|
||||
c.Mute = &v
|
||||
}
|
||||
if m.Custom != nil {
|
||||
v := *m.Custom
|
||||
v.Pre = append([]string(nil), m.Custom.Pre...)
|
||||
v.Post = append([]string(nil), m.Custom.Post...)
|
||||
c.Custom = &v
|
||||
}
|
||||
if m.Script != nil {
|
||||
v := *m.Script
|
||||
c.Script = &v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func overlayModules(base, overlay *Modules) {
|
||||
if overlay.Workspace != nil {
|
||||
if base.Workspace == nil {
|
||||
base.Workspace = &WorkspaceConfig{}
|
||||
}
|
||||
base.Workspace.Enabled = overlay.Workspace.Enabled
|
||||
if overlay.Workspace.Backend != "" {
|
||||
base.Workspace.Backend = overlay.Workspace.Backend
|
||||
}
|
||||
if overlay.Workspace.Workspace != 0 {
|
||||
base.Workspace.Workspace = overlay.Workspace.Workspace
|
||||
}
|
||||
}
|
||||
if overlay.Media != nil {
|
||||
if base.Media == nil {
|
||||
base.Media = &MediaConfig{}
|
||||
}
|
||||
base.Media.Enabled = overlay.Media.Enabled
|
||||
if overlay.Media.Action != "" {
|
||||
base.Media.Action = overlay.Media.Action
|
||||
}
|
||||
}
|
||||
if overlay.Lock != nil {
|
||||
if base.Lock == nil {
|
||||
base.Lock = &LockConfig{}
|
||||
}
|
||||
base.Lock.Enabled = overlay.Lock.Enabled
|
||||
if overlay.Lock.Command != "" {
|
||||
base.Lock.Command = overlay.Lock.Command
|
||||
}
|
||||
}
|
||||
if overlay.Kill != nil {
|
||||
base.Kill = &KillConfig{
|
||||
Enabled: overlay.Kill.Enabled,
|
||||
Processes: append([]string(nil), overlay.Kill.Processes...),
|
||||
}
|
||||
}
|
||||
if overlay.Notify != nil {
|
||||
if base.Notify == nil {
|
||||
base.Notify = &NotifyConfig{}
|
||||
}
|
||||
base.Notify.Enabled = overlay.Notify.Enabled
|
||||
if overlay.Notify.Sound != "" {
|
||||
base.Notify.Sound = overlay.Notify.Sound
|
||||
}
|
||||
}
|
||||
if overlay.Sound != nil {
|
||||
if base.Sound == nil {
|
||||
base.Sound = &SoundConfig{}
|
||||
}
|
||||
base.Sound.Enabled = overlay.Sound.Enabled
|
||||
if overlay.Sound.File != "" {
|
||||
base.Sound.File = overlay.Sound.File
|
||||
}
|
||||
}
|
||||
if overlay.Brightness != nil {
|
||||
base.Brightness = &BrightnessConfig{
|
||||
Enabled: overlay.Brightness.Enabled,
|
||||
Value: overlay.Brightness.Value,
|
||||
}
|
||||
}
|
||||
if overlay.Mute != nil {
|
||||
base.Mute = &MuteConfig{
|
||||
Enabled: overlay.Mute.Enabled,
|
||||
}
|
||||
}
|
||||
if overlay.Custom != nil {
|
||||
base.Custom = &CustomConfig{
|
||||
Enabled: overlay.Custom.Enabled,
|
||||
Pre: append([]string(nil), overlay.Custom.Pre...),
|
||||
Post: append([]string(nil), overlay.Custom.Post...),
|
||||
}
|
||||
}
|
||||
if overlay.Script != nil {
|
||||
base.Script = &ScriptConfig{
|
||||
Enabled: overlay.Script.Enabled,
|
||||
Path: overlay.Script.Path,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user