init commit
This commit is contained in:
279
cmd/root.go
Normal file
279
cmd/root.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yeho/klp/internal/clipboard"
|
||||
"github.com/yeho/klp/internal/config"
|
||||
"github.com/yeho/klp/internal/database"
|
||||
"github.com/yeho/klp/internal/search"
|
||||
"github.com/yeho/klp/internal/tui"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "dev" // Set at build time via ldflags
|
||||
|
||||
versionFlag bool
|
||||
listFlag bool
|
||||
listLimitFlag int
|
||||
limitFlag int
|
||||
searchFlag string
|
||||
deleteFlag string
|
||||
|
||||
cfg *config.Config
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "klp [id]",
|
||||
Short: "A clipboard history manager for Linux",
|
||||
Long: `klp is a clipboard history manager that monitors clipboard changes
|
||||
and provides quick access to your clipboard history.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if versionFlag {
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
if deleteFlag != "" {
|
||||
deleteEntry(deleteFlag)
|
||||
return
|
||||
}
|
||||
|
||||
if searchFlag != "" {
|
||||
searchEntries(searchFlag)
|
||||
return
|
||||
}
|
||||
|
||||
// If ID provided as argument, copy that entry to clipboard
|
||||
if len(args) == 1 {
|
||||
copyEntryToClipboard(args[0])
|
||||
return
|
||||
}
|
||||
|
||||
if listFlag {
|
||||
// Non-interactive list
|
||||
limit := cfg.DefaultLimit
|
||||
if listLimitFlag > 0 {
|
||||
limit = listLimitFlag
|
||||
}
|
||||
listEntriesNonInteractive(limit)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: interactive list
|
||||
limit := cfg.DefaultLimit
|
||||
if limitFlag > 0 {
|
||||
limit = limitFlag
|
||||
}
|
||||
listEntriesInteractive(limit)
|
||||
},
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "Print version")
|
||||
rootCmd.Flags().BoolVarP(&listFlag, "list", "l", false, "Non-interactive list output")
|
||||
rootCmd.Flags().IntVarP(&listLimitFlag, "list-limit", "n", 0, "Limit for non-interactive list (use with -l)")
|
||||
rootCmd.Flags().IntVar(&limitFlag, "limit", 0, "Limit for interactive list")
|
||||
rootCmd.Flags().StringVarP(&searchFlag, "search", "s", "", "Search clipboard entries")
|
||||
rootCmd.Flags().StringVarP(&deleteFlag, "delete", "d", "", "Delete entry by ID")
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
var err error
|
||||
cfg, err = config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func loadDatabase() *database.Database {
|
||||
db, err := database.Load(cfg.DBPath())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func copyEntryToClipboard(id string) {
|
||||
db := loadDatabase()
|
||||
|
||||
entry, err := db.Get(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Entry not found: %s\n", id)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
clip, err := clipboard.New()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error initializing clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := clip.Write(entry.Value); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing to clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
preview := entry.Value
|
||||
if len(preview) > 50 {
|
||||
preview = preview[:50] + "..."
|
||||
}
|
||||
fmt.Printf("Copied to clipboard: %s\n", cleanForDisplay(preview))
|
||||
}
|
||||
|
||||
func deleteEntry(id string) {
|
||||
db := loadDatabase()
|
||||
|
||||
if err := db.Delete(id); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error deleting entry: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Deleted entry: %s\n", id)
|
||||
}
|
||||
|
||||
func searchEntries(query string) {
|
||||
db := loadDatabase()
|
||||
searcher := search.NewSearcher(db)
|
||||
|
||||
limit := cfg.DefaultLimit
|
||||
if limitFlag > 0 {
|
||||
limit = limitFlag
|
||||
}
|
||||
|
||||
results := searcher.Search(query, search.Options{MaxResults: limit})
|
||||
|
||||
if len(results) == 0 {
|
||||
fmt.Printf("No results found for '%s'\n", query)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert results to entries for display
|
||||
entries := make([]*database.Entry, len(results))
|
||||
for i, r := range results {
|
||||
entries[i] = r.Entry
|
||||
}
|
||||
|
||||
if listFlag {
|
||||
// Non-interactive search results
|
||||
printEntries(entries)
|
||||
return
|
||||
}
|
||||
|
||||
// Interactive search results
|
||||
title := fmt.Sprintf("Search results for '%s' (%d found)", query, len(results))
|
||||
selected, err := tui.Run(entries, db, title)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if selected != nil {
|
||||
copyToClipboard(selected)
|
||||
}
|
||||
}
|
||||
|
||||
func listEntriesNonInteractive(limit int) {
|
||||
db := loadDatabase()
|
||||
entries := db.List(limit)
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No clipboard entries found")
|
||||
return
|
||||
}
|
||||
|
||||
printEntries(entries)
|
||||
}
|
||||
|
||||
func listEntriesInteractive(limit int) {
|
||||
db := loadDatabase()
|
||||
entries := db.List(limit)
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No clipboard entries found")
|
||||
return
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Clipboard History (%d entries)", len(entries))
|
||||
selected, err := tui.Run(entries, db, title)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if selected != nil {
|
||||
copyToClipboard(selected)
|
||||
}
|
||||
}
|
||||
|
||||
func printEntries(entries []*database.Entry) {
|
||||
for i, entry := range entries {
|
||||
preview := entry.Value
|
||||
if len(preview) > 60 {
|
||||
preview = preview[:60] + "..."
|
||||
}
|
||||
preview = cleanForDisplay(preview)
|
||||
|
||||
fmt.Printf("%s. [%s] %s %s\n",
|
||||
padLeft(strconv.Itoa(i+1), 2),
|
||||
entry.ID,
|
||||
entry.DateTime.Format("01/02 15:04"),
|
||||
preview,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func copyToClipboard(entry *database.Entry) {
|
||||
clip, err := clipboard.New()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error initializing clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := clip.Write(entry.Value); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing to clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
preview := entry.Value
|
||||
if len(preview) > 50 {
|
||||
preview = preview[:50] + "..."
|
||||
}
|
||||
fmt.Printf("Copied to clipboard: %s\n", cleanForDisplay(preview))
|
||||
}
|
||||
|
||||
func cleanForDisplay(s string) string {
|
||||
result := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' || s[i] == '\r' {
|
||||
result = append(result, ' ')
|
||||
} else if s[i] == '\t' {
|
||||
result = append(result, ' ')
|
||||
} else {
|
||||
result = append(result, s[i])
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func padLeft(s string, width int) string {
|
||||
for len(s) < width {
|
||||
s = " " + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
211
cmd/service.go
Normal file
211
cmd/service.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yeho/klp/internal/database"
|
||||
"github.com/yeho/klp/internal/service"
|
||||
)
|
||||
|
||||
var (
|
||||
foregroundFlag bool
|
||||
)
|
||||
|
||||
var serviceCmd = &cobra.Command{
|
||||
Use: "service",
|
||||
Short: "Service management commands",
|
||||
Long: `Commands for managing the clipboard monitoring service.`,
|
||||
}
|
||||
|
||||
var serviceStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start the clipboard monitor",
|
||||
Long: `Start the clipboard monitoring service as a background daemon.
|
||||
The service monitors clipboard changes and stores them in the database.
|
||||
|
||||
Use --foreground to run in foreground instead of daemonizing.
|
||||
Use 'klp service stop' to stop the daemon.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if !foregroundFlag && os.Getenv("KLP_DAEMON") != "1" {
|
||||
// Daemonize: re-exec ourselves in background
|
||||
daemonize()
|
||||
return
|
||||
}
|
||||
|
||||
// Running as daemon or in foreground mode
|
||||
runMonitor()
|
||||
},
|
||||
}
|
||||
|
||||
var serviceStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the clipboard monitor daemon",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
pidFile := getPidFile()
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
fmt.Println("No running daemon found")
|
||||
return
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(string(data))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Invalid PID file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
fmt.Println("No running daemon found")
|
||||
os.Remove(pidFile)
|
||||
return
|
||||
}
|
||||
|
||||
if err := process.Signal(syscall.SIGTERM); err != nil {
|
||||
fmt.Printf("Daemon not running (PID %d)\n", pid)
|
||||
os.Remove(pidFile)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Stopped daemon (PID %d)\n", pid)
|
||||
os.Remove(pidFile)
|
||||
},
|
||||
}
|
||||
|
||||
var serviceStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Check if the clipboard monitor is running",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
pidFile := getPidFile()
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
fmt.Println("Daemon is not running")
|
||||
return
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(string(data))
|
||||
if err != nil {
|
||||
fmt.Println("Daemon is not running (invalid PID file)")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if process exists
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
fmt.Println("Daemon is not running")
|
||||
os.Remove(pidFile)
|
||||
return
|
||||
}
|
||||
|
||||
// On Unix, FindProcess always succeeds, so we need to send signal 0 to check
|
||||
if err := process.Signal(syscall.Signal(0)); err != nil {
|
||||
fmt.Println("Daemon is not running")
|
||||
os.Remove(pidFile)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Daemon is running (PID %d)\n", pid)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(serviceStartCmd)
|
||||
serviceCmd.AddCommand(serviceStopCmd)
|
||||
serviceCmd.AddCommand(serviceStatusCmd)
|
||||
|
||||
serviceStartCmd.Flags().BoolVarP(&foregroundFlag, "foreground", "f", false, "Run in foreground instead of daemonizing")
|
||||
}
|
||||
|
||||
func getPidFile() string {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, ".config", "klp", "klp.pid")
|
||||
}
|
||||
|
||||
func getLogFile() string {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, ".config", "klp", "klp.log")
|
||||
}
|
||||
|
||||
func daemonize() {
|
||||
// Check if already running
|
||||
pidFile := getPidFile()
|
||||
if data, err := os.ReadFile(pidFile); err == nil {
|
||||
if pid, err := strconv.Atoi(string(data)); err == nil {
|
||||
if process, err := os.FindProcess(pid); err == nil {
|
||||
if err := process.Signal(syscall.Signal(0)); err == nil {
|
||||
fmt.Printf("Daemon already running (PID %d)\n", pid)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get executable path
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting executable path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Ensure config directory exists
|
||||
os.MkdirAll(filepath.Dir(pidFile), 0755)
|
||||
|
||||
// Open log file
|
||||
logFile, err := os.OpenFile(getLogFile(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start daemon process
|
||||
cmd := exec.Command(executable, "service", "start", "--foreground")
|
||||
cmd.Env = append(os.Environ(), "KLP_DAEMON=1")
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true, // Create new session
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error starting daemon: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Write PID file
|
||||
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing PID file: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
|
||||
fmt.Printf("Log file: %s\n", getLogFile())
|
||||
}
|
||||
|
||||
func runMonitor() {
|
||||
db, err := database.Load(cfg.DBPath())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
monitor, err := service.NewMonitor(cfg, db)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating monitor: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Clean up PID file on exit
|
||||
pidFile := getPidFile()
|
||||
defer os.Remove(pidFile)
|
||||
|
||||
if err := monitor.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error running monitor: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user