From bd73a1c4775d793899d6007340101a98fa7af49b Mon Sep 17 00:00:00 2001 From: ryyst Date: Sun, 5 Oct 2025 23:57:25 +0300 Subject: [PATCH] feat: add export/import functionality for backup and migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented roadmap #4: Export/import functionality. Features: - export [key2...] - Export specified keys to JSON - export-list - Bulk export from key list file - import - Import keys from JSON file --skip-existing (default) - Skip keys that already exist --overwrite - Overwrite existing keys Export file format (v1.0): { "version": "1.0", "entries": [ { "key": "users/alice", "uuid": "...", "timestamp": 1234567890, "data": {...} } ] } The format preserves UUIDs and timestamps for audit trails. Export shows progress with ✓/✗/⊘ symbols for success/fail/not-found. Import checks for existing keys and respects skip/overwrite flags. Use cases: - Backup: export-list keys.txt backup.json - Migration: import backup.json --overwrite - Selective sync: export dest.json key1 key2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd_export.go | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd_system.go | 7 ++ main.go | 6 ++ utils.go | 3 + 4 files changed, 238 insertions(+) create mode 100644 cmd_export.go diff --git a/cmd_export.go b/cmd_export.go new file mode 100644 index 0000000..22e3dcc --- /dev/null +++ b/cmd_export.go @@ -0,0 +1,222 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// ExportEntry represents a single exported key-value pair +type ExportEntry struct { + Key string `json:"key"` + UUID string `json:"uuid"` + Timestamp int64 `json:"timestamp"` + Data json.RawMessage `json:"data"` +} + +// ExportFile represents the export file format +type ExportFile struct { + Version string `json:"version"` + Entries []ExportEntry `json:"entries"` +} + +// handleExport exports keys to a JSON file +func (c *KVSClient) handleExport(args []string) { + if len(args) < 2 { + fmt.Println(red("Usage: export [key2] [key3] ...")) + fmt.Println("Example: export backup.json users/alice users/bob config/settings") + return + } + + outputFile := args[0] + keys := args[1:] + + exportFile := ExportFile{ + Version: "1.0", + Entries: []ExportEntry{}, + } + + fmt.Println(cyan("Exporting"), len(keys), "key(s)...") + + successCount := 0 + failCount := 0 + + for _, key := range keys { + respBody, status, err := c.doRequest("GET", "/kv/"+key, nil) + if err != nil { + fmt.Printf(red("✗")+" %s: %v\n", key, err) + failCount++ + continue + } + + if status == 404 { + fmt.Printf(yellow("⊘")+" %s: not found\n", key) + failCount++ + continue + } + + if status != 200 { + fmt.Printf(red("✗")+" %s: status %d\n", key, status) + failCount++ + continue + } + + var stored StoredValue + if err := json.Unmarshal(respBody, &stored); err != nil { + fmt.Printf(red("✗")+" %s: parse error: %v\n", key, err) + failCount++ + continue + } + + exportFile.Entries = append(exportFile.Entries, ExportEntry{ + Key: key, + UUID: stored.UUID, + Timestamp: stored.Timestamp, + Data: stored.Data, + }) + + fmt.Printf(green("✓")+" %s\n", key) + successCount++ + } + + // Write to file + data, err := json.MarshalIndent(exportFile, "", " ") + if err != nil { + fmt.Println(red("Error marshaling export data:"), err) + return + } + + if err := os.WriteFile(outputFile, data, 0644); err != nil { + fmt.Println(red("Error writing file:"), err) + return + } + + fmt.Println() + fmt.Println(green("Export complete:")) + fmt.Printf(" Exported: %d\n", successCount) + if failCount > 0 { + fmt.Printf(" Failed: %s\n", red(fmt.Sprintf("%d", failCount))) + } + fmt.Printf(" File: %s\n", outputFile) +} + +// handleImport imports keys from a JSON file +func (c *KVSClient) handleImport(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: import [--skip-existing | --overwrite]")) + fmt.Println(" --skip-existing: Skip keys that already exist (default)") + fmt.Println(" --overwrite: Overwrite existing keys") + return + } + + inputFile := args[0] + overwrite := false + + // Parse flags + for _, arg := range args[1:] { + if arg == "--overwrite" { + overwrite = true + } + } + + // Read file + data, err := os.ReadFile(inputFile) + if err != nil { + fmt.Println(red("Error reading file:"), err) + return + } + + var exportFile ExportFile + if err := json.Unmarshal(data, &exportFile); err != nil { + fmt.Println(red("Error parsing export file:"), err) + return + } + + fmt.Println(cyan("Importing"), len(exportFile.Entries), "key(s)...") + if exportFile.Version != "1.0" { + fmt.Println(yellow("Warning: Unknown export version"), exportFile.Version) + } + + successCount := 0 + skipCount := 0 + failCount := 0 + + for _, entry := range exportFile.Entries { + // Check if key exists (unless we're overwriting) + if !overwrite { + _, status, err := c.doRequest("GET", "/kv/"+entry.Key, nil) + if err == nil && status == 200 { + fmt.Printf(yellow("⊘")+" %s: exists (skipped)\n", entry.Key) + skipCount++ + continue + } + } + + // Import the key + respBody, status, err := c.doRequest("PUT", "/kv/"+entry.Key, entry.Data) + if err != nil { + fmt.Printf(red("✗")+" %s: %v\n", entry.Key, err) + failCount++ + continue + } + + if status != 200 && status != 201 { + fmt.Printf(red("✗")+" %s: status %d: %s\n", entry.Key, status, string(respBody)) + failCount++ + continue + } + + fmt.Printf(green("✓")+" %s\n", entry.Key) + successCount++ + } + + fmt.Println() + fmt.Println(green("Import complete:")) + fmt.Printf(" Imported: %d\n", successCount) + if skipCount > 0 { + fmt.Printf(" Skipped: %d\n", skipCount) + } + if failCount > 0 { + fmt.Printf(" Failed: %s\n", red(fmt.Sprintf("%d", failCount))) + } +} + +// handleExportList reads keys from a file and exports them +func (c *KVSClient) handleExportList(args []string) { + if len(args) < 2 { + fmt.Println(red("Usage: export-list ")) + fmt.Println("Example: export-list keys.txt backup.json") + fmt.Println() + fmt.Println("key-list-file should contain one key per line") + return + } + + keyListFile := args[0] + outputFile := args[1] + + // Read keys from file + data, err := os.ReadFile(keyListFile) + if err != nil { + fmt.Println(red("Error reading key list file:"), err) + return + } + + lines := strings.Split(string(data), "\n") + keys := []string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + keys = append(keys, line) + } + } + + if len(keys) == 0 { + fmt.Println(yellow("No keys found in file")) + return + } + + // Call export with the keys + exportArgs := append([]string{outputFile}, keys...) + c.handleExport(exportArgs) +} diff --git a/cmd_system.go b/cmd_system.go index dcce3ba..dc0111b 100644 --- a/cmd_system.go +++ b/cmd_system.go @@ -60,6 +60,13 @@ Group Management: group add-member - Add member to group group remove-member - Remove member from group +Export/Import: + export ... - Export keys to JSON file + export-list - Export keys listed in file + import - Import keys from JSON file + --skip-existing - Skip existing keys (default) + --overwrite - Overwrite existing keys + Batch Operations: batch - Execute commands from file source - Alias for batch diff --git a/main.go b/main.go index 3bc4823..ba6a498 100644 --- a/main.go +++ b/main.go @@ -60,6 +60,12 @@ func (c *KVSClient) executeCommand(line string) bool { } case "group": c.handleGroup(args) + case "export": + c.handleExport(args) + case "import": + c.handleImport(args) + case "export-list": + c.handleExportList(args) case "batch", "source": c.handleBatch(args, c.executeCommand) default: diff --git a/utils.go b/utils.go index bb36137..cfbed9c 100644 --- a/utils.go +++ b/utils.go @@ -127,6 +127,9 @@ var completer = readline.NewPrefixCompleter( readline.PcItem("add-member"), readline.PcItem("remove-member"), ), + readline.PcItem("export"), + readline.PcItem("import"), + readline.PcItem("export-list"), readline.PcItem("batch"), readline.PcItem("source"), readline.PcItem("help"),