Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve

- Initial development items tracked here.

## [0.2.03] - 2026-01-09

### Added

- Daily notification commands: `--notification daily [HH:MM]`, `--notification off`, `--show-notification`, and `--trigger-notification`
- Cross-platform OS notification support (macOS, Linux, Windows)
- New `--config` command to display configuration file path and content
- Comprehensive test suite for notification and configuration functionality
- Documentation for notification and configuration commands in help system

## [0.2.02] - 2026-01-06

### Added
Expand Down
62 changes: 61 additions & 1 deletion cmd/journal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"journal-cli/internal/updater"
)

const Version = "0.2.02"
const Version = "0.2.03"

func main() {
// Check for "self-update" subcommand
Expand All @@ -28,6 +28,10 @@ func main() {
todoFlag := flag.Bool("todo", false, "Update today's todos (shorthand for --todos \"\")")
setTemplate := flag.String("set-template", "", "Set default template")
listTemplates := flag.Bool("list-templates", false, "List available templates")
notification := flag.String("notification", "", "Manage notifications: 'daily [HH:MM]' or 'off'")
showNotification := flag.Bool("show-notification", false, "Show current notification settings")
triggerNotification := flag.Bool("trigger-notification", false, "Trigger an OS notification (for testing)")
showConfig := flag.Bool("config", false, "Show configuration file path and content")

// Custom usage message using YAML documentation
flag.Usage = func() {
Expand Down Expand Up @@ -71,6 +75,62 @@ func main() {
return
}

// Handle notification status display
if *showNotification {
if err := app.ShowNotificationStatus(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}

// Handle config display
if *showConfig {
if err := app.ShowConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error showing config: %v\n", err)
os.Exit(1)
}
return
}

// Handle notification triggering (for testing)
if *triggerNotification {
if err := app.TriggerNotification(); err != nil {
fmt.Fprintf(os.Stderr, "Error triggering notification: %v\n", err)
os.Exit(1)
}
return
}

// Handle notification commands
if *notification != "" {
args := flag.Args()

if *notification == "off" {
// Disable notifications
if err := app.DisableNotification(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
} else if *notification == "daily" {
// Enable daily notification with optional time
timeStr := ""
if len(args) > 0 {
timeStr = args[0]
}
if err := app.SetNotification("daily", timeStr); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
} else {
fmt.Fprintf(os.Stderr, "Invalid notification option: %s\n", *notification)
fmt.Fprintf(os.Stderr, "Usage: --notification daily [HH:MM] | --notification off\n")
os.Exit(1)
}
}

// Run the todo updater only when explicitly requested
if *todos != "" || *todoFlag {
// If --todo boolean is set, pass empty string to mean today
Expand Down
195 changes: 195 additions & 0 deletions internal/app/notification_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package app

import (
"fmt"
"os"
"os/exec"
"regexp"
"runtime"
"strings"

"journal-cli/internal/config"
)

// SetNotification enables daily notifications at the specified time
func SetNotification(frequency, timeStr string) error {
cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

// Validate frequency
if frequency != "daily" && frequency != "" {
return fmt.Errorf("invalid frequency: %s (only 'daily' is supported)", frequency)
}

// Default to daily if not specified
if frequency == "" {
frequency = "daily"
}

// Default time to 09:00 if not specified
if timeStr == "" {
timeStr = "09:00"
}

// Validate time format (HH:MM)
timeRegex := regexp.MustCompile(`^([0-1][0-9]|2[0-3]):([0-5][0-9])$`)
if !timeRegex.MatchString(timeStr) {
return fmt.Errorf("invalid time format: %s (expected HH:MM in 24-hour format)", timeStr)
}

// Initialize notification if nil
if cfg.Notification == nil {
cfg.Notification = &config.Notification{}
}

// Update notification settings
cfg.Notification.Enabled = true
cfg.Notification.Frequency = frequency
cfg.Notification.Time = timeStr

// Save config
if err := config.SaveConfig(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}

fmt.Printf("✓ Daily notification enabled at %s\n", timeStr)
fmt.Printf(" Frequency: %s\n", frequency)
fmt.Printf("\nNote: You'll need to set up your system scheduler to run 'journal' at the specified time.\n")
fmt.Printf("See documentation for platform-specific instructions.\n")

return nil
}

// DisableNotification disables notifications
func DisableNotification() error {
cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

if cfg.Notification == nil {
fmt.Println("Notifications are not configured.")
return nil
}

cfg.Notification.Enabled = false

if err := config.SaveConfig(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}

fmt.Println("✓ Notifications disabled")
return nil
}

// ShowNotificationStatus displays current notification settings
func ShowNotificationStatus() error {
cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

fmt.Println("Notification Settings")
fmt.Println(strings.Repeat("─", 40))

if cfg.Notification == nil || !cfg.Notification.Enabled {
fmt.Println("Status: Disabled")
return nil
}

fmt.Printf("Status: Enabled\n")
fmt.Printf("Frequency: %s\n", cfg.Notification.Frequency)
fmt.Printf("Time: %s\n", cfg.Notification.Time)

return nil
}

// TriggerNotification checks if it's time to notify and shows an OS notification
func TriggerNotification() error {
cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

if cfg.Notification == nil || !cfg.Notification.Enabled {
fmt.Println("Notifications are not enabled.")
return nil
}

title := "Journal Reminder"
message := fmt.Sprintf("Time to write in your journal! (Current setting: %s)", cfg.Notification.Time)

// Trigger OS-specific notification
switch runtime.GOOS {
case "darwin":
err = showMacOSNotification(title, message)
case "linux":
err = showLinuxNotification(title, message)
case "windows":
err = showWindowsNotification(title, message)
default:
err = fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}

if err != nil {
return fmt.Errorf("failed to show notification: %w", err)
}

fmt.Printf("✓ %s notification triggered successfully\n", strings.Title(runtime.GOOS))
return nil
}

// showMacOSNotification displays a notification on macOS using osascript
func showMacOSNotification(title, message string) error {
script := fmt.Sprintf(`display notification "%s" with title "%s" sound name "default"`, message, title)
return executeCommand("osascript", "-e", script)
}

// showLinuxNotification displays a notification on Linux using notify-send
func showLinuxNotification(title, message string) error {
return executeCommand("notify-send", title, message)
}

// showWindowsNotification displays a notification on Windows using PowerShell
func showWindowsNotification(title, message string) error {
// A simple PowerShell command to show a balloon tip notification
psCommand := fmt.Sprintf(
`$wshell = New-Object -ComObject WScript.Shell; $wshell.Popup("%s", 0, "%s", 64)`,
message, title,
)
return executeCommand("powershell", "-Command", psCommand)
}

// ShowConfig displays the configuration path and content
func ShowConfig() error {
configPath, err := config.GetConfigPath()
if err != nil {
return fmt.Errorf("failed to get config path: %w", err)
}

fmt.Printf("Configuration file: %s\n", configPath)

data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
fmt.Println("\nConfiguration file does not exist (using defaults).")
return nil
}
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}

fmt.Println("\nContent:")
fmt.Println(strings.Repeat("─", 40))
fmt.Println(string(data))
fmt.Println(strings.Repeat("─", 40))

return nil
}

// executeCommand runs a command with arguments
func executeCommand(name string, args ...string) error {
cmd := exec.Command(name, args...)
return cmd.Run()
}
Loading
Loading