diff --git a/core/cmd/dms/commands_clipboard.go b/core/cmd/dms/commands_clipboard.go index 62891635..6f71e774 100644 --- a/core/cmd/dms/commands_clipboard.go +++ b/core/cmd/dms/commands_clipboard.go @@ -1,17 +1,29 @@ package main import ( + "bytes" "context" + "encoding/base64" + "encoding/binary" "encoding/json" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "io" "os" "os/exec" "os/signal" + "path/filepath" "strconv" "syscall" "time" + bolt "go.etcd.io/bbolt" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" @@ -144,6 +156,30 @@ var ( clipConfigEnabled bool ) +var clipExportCmd = &cobra.Command{ + Use: "export [file]", + Short: "Export clipboard history to JSON", + Long: "Export clipboard history to JSON file. If no file specified, writes to stdout.", + Run: runClipExport, +} + +var clipImportCmd = &cobra.Command{ + Use: "import ", + Short: "Import clipboard history from JSON", + Long: "Import clipboard history from JSON file exported by 'dms cl export'.", + Args: cobra.ExactArgs(1), + Run: runClipImport, +} + +var clipMigrateCmd = &cobra.Command{ + Use: "cliphist-migrate [db-path]", + Short: "Migrate from cliphist", + Long: "Migrate clipboard history from cliphist. Uses default cliphist path if not specified.", + Run: runClipMigrate, +} + +var clipMigrateDelete bool + func init() { clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking") clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste") @@ -170,8 +206,10 @@ func init() { clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)") + clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration") + clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd) - clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd) + clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd) } func runClipCopy(cmd *cobra.Command, args []string) { @@ -606,3 +644,154 @@ func runClipConfigSet(cmd *cobra.Command, args []string) { fmt.Println("Config updated") } + +func runClipExport(cmd *cobra.Command, args []string) { + req := models.Request{ + ID: 1, + Method: "clipboard.getHistory", + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get clipboard history: %v", err) + } + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + if resp.Result == nil { + log.Fatal("No clipboard history") + } + + out, err := json.MarshalIndent(resp.Result, "", " ") + if err != nil { + log.Fatalf("Failed to marshal: %v", err) + } + + if len(args) == 0 { + fmt.Println(string(out)) + return + } + + if err := os.WriteFile(args[0], out, 0644); err != nil { + log.Fatalf("Failed to write file: %v", err) + } + fmt.Printf("Exported to %s\n", args[0]) +} + +func runClipImport(cmd *cobra.Command, args []string) { + data, err := os.ReadFile(args[0]) + if err != nil { + log.Fatalf("Failed to read file: %v", err) + } + + var entries []map[string]any + if err := json.Unmarshal(data, &entries); err != nil { + log.Fatalf("Failed to parse JSON: %v", err) + } + + var imported int + for _, entry := range entries { + dataStr, ok := entry["data"].(string) + if !ok { + continue + } + mimeType, _ := entry["mimeType"].(string) + if mimeType == "" { + mimeType = "text/plain" + } + + var entryData []byte + if decoded, err := base64.StdEncoding.DecodeString(dataStr); err == nil { + entryData = decoded + } else { + entryData = []byte(dataStr) + } + + if err := clipboard.Store(entryData, mimeType); err != nil { + log.Errorf("Failed to store entry: %v", err) + continue + } + imported++ + } + + fmt.Printf("Imported %d entries\n", imported) +} + +func runClipMigrate(cmd *cobra.Command, args []string) { + dbPath := getCliphistPath() + if len(args) > 0 { + dbPath = args[0] + } + + if _, err := os.Stat(dbPath); err != nil { + log.Fatalf("Cliphist db not found: %s", dbPath) + } + + db, err := bolt.Open(dbPath, 0644, &bolt.Options{ + ReadOnly: true, + Timeout: 1 * time.Second, + }) + if err != nil { + log.Fatalf("Failed to open cliphist db: %v", err) + } + defer db.Close() + + var migrated int + err = db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("b")) + if b == nil { + return fmt.Errorf("cliphist bucket not found") + } + + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + if len(v) == 0 { + continue + } + + mimeType := detectMimeType(v) + if err := clipboard.Store(v, mimeType); err != nil { + log.Errorf("Failed to store entry %d: %v", btoi(k), err) + continue + } + migrated++ + } + return nil + }) + if err != nil { + log.Fatalf("Migration failed: %v", err) + } + + fmt.Printf("Migrated %d entries from cliphist\n", migrated) + + if !clipMigrateDelete { + return + } + + db.Close() + if err := os.Remove(dbPath); err != nil { + log.Errorf("Failed to delete cliphist db: %v", err) + return + } + os.Remove(filepath.Dir(dbPath)) + fmt.Println("Deleted cliphist db") +} + +func getCliphistPath() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + return filepath.Join(os.Getenv("HOME"), ".cache", "cliphist", "db") + } + return filepath.Join(cacheDir, "cliphist", "db") +} + +func detectMimeType(data []byte) string { + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + return "image/png" + } + return "text/plain" +} + +func btoi(v []byte) uint64 { + return binary.BigEndian.Uint64(v) +} diff --git a/core/internal/clipboard/store.go b/core/internal/clipboard/store.go index 9e9f7f28..70a73ce4 100644 --- a/core/internal/clipboard/store.go +++ b/core/internal/clipboard/store.go @@ -55,7 +55,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error { return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize) } - dbPath, err := getDBPath() + dbPath, err := GetDBPath() if err != nil { return fmt.Errorf("get db path: %w", err) } @@ -111,7 +111,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error { }) } -func getDBPath() (string, error) { +func GetDBPath() (string, error) { cacheDir, err := os.UserCacheDir() if err != nil { homeDir, err := os.UserHomeDir() @@ -121,12 +121,31 @@ func getDBPath() (string, error) { cacheDir = filepath.Join(homeDir, ".cache") } - dbDir := filepath.Join(cacheDir, "dms-clipboard") - if err := os.MkdirAll(dbDir, 0700); err != nil { - return "", err + newDir := filepath.Join(cacheDir, "DankMaterialShell", "clipboard") + newPath := filepath.Join(newDir, "db") + + if _, err := os.Stat(newPath); err == nil { + return newPath, nil } - return filepath.Join(dbDir, "db"), nil + oldDir := filepath.Join(cacheDir, "dms-clipboard") + oldPath := filepath.Join(oldDir, "db") + + if _, err := os.Stat(oldPath); err == nil { + if err := os.MkdirAll(newDir, 0700); err != nil { + return "", err + } + if err := os.Rename(oldPath, newPath); err != nil { + return "", err + } + os.Remove(oldDir) + return newPath, nil + } + + if err := os.MkdirAll(newDir, 0700); err != nil { + return "", err + } + return newPath, nil } func deduplicateInTx(b *bolt.Bucket, hash uint64) error { diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index 0d46d482..41b3e619 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -24,6 +24,7 @@ import ( bolt "go.etcd.io/bbolt" + clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" @@ -37,7 +38,7 @@ var sensitiveMimeTypes = []string{ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) { display := wlCtx.Display() - dbPath, err := getDBPath() + dbPath, err := clipboardstore.GetDBPath() if err != nil { return nil, fmt.Errorf("failed to get db path: %w", err) } @@ -102,24 +103,6 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) return m, nil } -func getDBPath() (string, error) { - cacheDir := os.Getenv("XDG_CACHE_HOME") - if cacheDir == "" { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", err - } - cacheDir = filepath.Join(homeDir, ".cache") - } - - dbDir := filepath.Join(cacheDir, "dms-clipboard") - if err := os.MkdirAll(dbDir, 0700); err != nil { - return "", err - } - - return filepath.Join(dbDir, "db"), nil -} - func openDB(path string) (*bolt.DB, error) { db, err := bolt.Open(path, 0644, &bolt.Options{ Timeout: 1 * time.Second,