diff --git a/core/i18n.py b/core/i18n.py index e0ef717..85b8758 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -187,6 +187,23 @@ _EN = { "restore_confirm": "Restore from '{name}'?\nCurrent data will be overwritten.", "restored": "Restored from: {name}", "restore_failed": "Restore failed: {e}", + "export_config": "Export Config", + "import_config": "Import Config", + "export_backup": "Export Backup", + "import_backup": "Import Backup", + "export_config_title": "Export Configuration", + "export_config_ok": "Config exported to: {path}", + "export_config_failed": "Export failed: {e}", + "import_config_title": "Import Configuration", + "import_config_confirm": "Import will replace all current servers.\nContinue?", + "import_config_ok": "Config imported from: {path}", + "import_config_failed": "Import failed: {e}", + "export_backup_title": "Export Backup", + "export_backup_ok": "Backup exported to: {path}", + "export_backup_failed": "Backup export failed: {e}", + "import_backup_title": "Import Backup", + "import_backup_ok": "Backup imported: {name}", + "import_backup_failed": "Backup import failed: {e}", "select_servers_json": "Select servers.json", # TOTP / 2FA @@ -394,6 +411,23 @@ _RU = { "restore_confirm": "Восстановить из '{name}'?\nТекущие данные будут перезаписаны.", "restored": "Восстановлено из: {name}", "restore_failed": "Ошибка восстановления: {e}", + "export_config": "Экспорт конфига", + "import_config": "Импорт конфига", + "export_backup": "Экспорт бэкапа", + "import_backup": "Импорт бэкапа", + "export_config_title": "Экспорт конфигурации", + "export_config_ok": "Конфиг экспортирован в: {path}", + "export_config_failed": "Ошибка экспорта: {e}", + "import_config_title": "Импорт конфигурации", + "import_config_confirm": "Импорт заменит все текущие серверы.\nПродолжить?", + "import_config_ok": "Конфиг импортирован из: {path}", + "import_config_failed": "Ошибка импорта: {e}", + "export_backup_title": "Экспорт бэкапа", + "export_backup_ok": "Бэкап экспортирован в: {path}", + "export_backup_failed": "Ошибка экспорта бэкапа: {e}", + "import_backup_title": "Импорт бэкапа", + "import_backup_ok": "Бэкап импортирован: {name}", + "import_backup_failed": "Ошибка импорта бэкапа: {e}", "select_servers_json": "Выберите servers.json", # TOTP / 2FA @@ -601,6 +635,23 @@ _ZH = { "restore_confirm": "从 '{name}' 恢复?\n当前数据将被覆盖。", "restored": "已从 {name} 恢复", "restore_failed": "恢复失败:{e}", + "export_config": "导出配置", + "import_config": "导入配置", + "export_backup": "导出备份", + "import_backup": "导入备份", + "export_config_title": "导出配置", + "export_config_ok": "配置已导出到:{path}", + "export_config_failed": "导出失败:{e}", + "import_config_title": "导入配置", + "import_config_confirm": "导入将替换所有当前服务器。\n是否继续?", + "import_config_ok": "配置已从 {path} 导入", + "import_config_failed": "导入失败:{e}", + "export_backup_title": "导出备份", + "export_backup_ok": "备份已导出到:{path}", + "export_backup_failed": "备份导出失败:{e}", + "import_backup_title": "导入备份", + "import_backup_ok": "备份已导入:{name}", + "import_backup_failed": "备份导入失败:{e}", "select_servers_json": "选择servers.json", # TOTP / 2FA diff --git a/core/server_store.py b/core/server_store.py index b1fec19..5081da6 100644 --- a/core/server_store.py +++ b/core/server_store.py @@ -237,6 +237,80 @@ class ServerStore: self._notify() log.info(f"Restored from: {filename}") + # ── Import / Export ────────────────────────────── + + def export_config(self, dest_path: str) -> str: + text = json.dumps(self._data, indent=2, ensure_ascii=False) + with open(dest_path, "w", encoding="utf-8") as f: + f.write(text) + log.info(f"Config exported to: {dest_path}") + return dest_path + + def import_config(self, src_path: str): + if not os.path.exists(src_path): + raise FileNotFoundError(f"File not found: {src_path}") + with open(src_path, "rb") as f: + raw = f.read() + try: + if is_encrypted(raw): + text = decrypt(raw) + data = json.loads(text) + else: + data = json.loads(raw.decode("utf-8")) + except Exception as e: + raise ValueError(f"Invalid config file: {e}") + if not isinstance(data, dict) or not isinstance(data.get("servers"), list): + raise ValueError("Invalid config structure: missing 'servers' list") + self._data = data + self._save() + self._notify() + log.info(f"Config imported from: {src_path}") + + def export_backup(self, filename: str, dest_path: str) -> str: + src = os.path.join(BACKUP_DIR, filename) + if not os.path.exists(src): + raise FileNotFoundError(f"Backup not found: {filename}") + with open(src, "rb") as f: + raw = f.read() + if is_encrypted(raw): + text = decrypt(raw) + data = json.loads(text) + else: + data = json.loads(raw.decode("utf-8")) + with open(dest_path, "w", encoding="utf-8") as f: + json.dump(data, indent=2, ensure_ascii=False, fp=f) + log.info(f"Backup exported to: {dest_path}") + return dest_path + + def import_backup(self, src_path: str) -> str: + if not os.path.exists(src_path): + raise FileNotFoundError(f"File not found: {src_path}") + with open(src_path, "rb") as f: + raw = f.read() + try: + if is_encrypted(raw): + text = decrypt(raw) + data = json.loads(text) + else: + data = json.loads(raw.decode("utf-8")) + except Exception as e: + raise ValueError(f"Invalid backup file: {e}") + if not isinstance(data, dict) or not isinstance(data.get("servers"), list): + raise ValueError("Invalid backup structure: missing 'servers' list") + name = os.path.basename(src_path) + dest = os.path.join(BACKUP_DIR, name) + if os.path.exists(dest): + stem, ext = os.path.splitext(name) + suffix = datetime.now().strftime("_%H%M%S") + name = stem + suffix + ext + dest = os.path.join(BACKUP_DIR, name) + os.makedirs(BACKUP_DIR, exist_ok=True) + encrypted = encrypt(json.dumps(data, indent=2, ensure_ascii=False)) + with open(dest, "wb") as f: + f.write(encrypted) + log.info(f"Backup imported: {name}") + return name + # ── Observer ────────────────────────────────────── def _notify(self): diff --git a/gui/tabs/setup_tab.py b/gui/tabs/setup_tab.py index cf41fb2..27f3d62 100644 --- a/gui/tabs/setup_tab.py +++ b/gui/tabs/setup_tab.py @@ -5,6 +5,7 @@ Includes configuration path management and backup/restore. import os import threading +from datetime import datetime from tkinter import filedialog, messagebox import customtkinter as ctk from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key @@ -171,6 +172,34 @@ class SetupTab(ctk.CTkFrame): ) self.restore_btn.pack(side="left") + # Import/Export row + ie_row = ctk.CTkFrame(config_frame, fg_color="transparent") + ie_row.pack(fill="x", padx=15, pady=(0, 10)) + + self.export_config_btn = ctk.CTkButton( + ie_row, text=t("export_config"), width=120, fg_color="#6b7280", + command=self._export_config + ) + self.export_config_btn.pack(side="left", padx=(0, 5)) + + self.import_config_btn = ctk.CTkButton( + ie_row, text=t("import_config"), width=120, fg_color="#6b7280", + command=self._import_config + ) + self.import_config_btn.pack(side="left", padx=5) + + self.export_backup_btn = ctk.CTkButton( + ie_row, text=t("export_backup"), width=120, fg_color="#6b7280", + command=self._export_backup + ) + self.export_backup_btn.pack(side="left", padx=5) + + self.import_backup_btn = ctk.CTkButton( + ie_row, text=t("import_backup"), width=120, fg_color="#6b7280", + command=self._import_backup + ) + self.import_backup_btn.pack(side="left", padx=5) + # Log self.log = ctk.CTkTextbox(self, height=150, font=ctk.CTkFont(family="Consolas", size=11), state="disabled") self.log.pack(fill="both", expand=True, padx=20, pady=(5, 20)) @@ -279,3 +308,69 @@ class SetupTab(ctk.CTkFrame): else: self._backup_menu.configure(values=[t("no_backups")]) self._backup_var.set(t("no_backups")) + + # ── Import / Export handlers ───────────────────── + + def _export_config(self): + default_name = f"servers_export_{datetime.now().strftime('%Y-%m-%d')}.json" + path = filedialog.asksaveasfilename( + title=t("export_config_title"), + defaultextension=".json", + initialfile=default_name, + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + if not path: + return + try: + self.store.export_config(path) + self._log(t("export_config_ok").format(path=path)) + except Exception as e: + self._log(t("export_config_failed").format(e=e)) + + def _import_config(self): + path = filedialog.askopenfilename( + title=t("import_config_title"), + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + if not path: + return + if not messagebox.askyesno(t("import_config_title"), t("import_config_confirm")): + return + try: + self.store.import_config(path) + self._log(t("import_config_ok").format(path=path)) + except Exception as e: + self._log(t("import_config_failed").format(e=e)) + + def _export_backup(self): + selected = self._backup_var.get() + if not selected or selected in (t("select_backup"), t("no_backups")): + self._log(t("no_backup_selected")) + return + path = filedialog.asksaveasfilename( + title=t("export_backup_title"), + defaultextension=".json", + initialfile=selected, + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + if not path: + return + try: + self.store.export_backup(selected, path) + self._log(t("export_backup_ok").format(path=path)) + except Exception as e: + self._log(t("export_backup_failed").format(e=e)) + + def _import_backup(self): + path = filedialog.askopenfilename( + title=t("import_backup_title"), + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + if not path: + return + try: + name = self.store.import_backup(path) + self._refresh_backups() + self._log(t("import_backup_ok").format(name=name)) + except Exception as e: + self._log(t("import_backup_failed").format(e=e))