feat: import/export for config and backups

- Add export_config/import_config/export_backup/import_backup to ServerStore
- Add 4 buttons row in Setup tab UI with file dialogs
- Add i18n keys for EN/RU/ZH (16 keys each)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-23 15:37:53 -05:00
parent b0b7d263fb
commit 9baafc9a36
3 changed files with 220 additions and 0 deletions

View File

@@ -187,6 +187,23 @@ _EN = {
"restore_confirm": "Restore from '{name}'?\nCurrent data will be overwritten.", "restore_confirm": "Restore from '{name}'?\nCurrent data will be overwritten.",
"restored": "Restored from: {name}", "restored": "Restored from: {name}",
"restore_failed": "Restore failed: {e}", "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", "select_servers_json": "Select servers.json",
# TOTP / 2FA # TOTP / 2FA
@@ -394,6 +411,23 @@ _RU = {
"restore_confirm": "Восстановить из '{name}'?\nТекущие данные будут перезаписаны.", "restore_confirm": "Восстановить из '{name}'?\nТекущие данные будут перезаписаны.",
"restored": "Восстановлено из: {name}", "restored": "Восстановлено из: {name}",
"restore_failed": "Ошибка восстановления: {e}", "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", "select_servers_json": "Выберите servers.json",
# TOTP / 2FA # TOTP / 2FA
@@ -601,6 +635,23 @@ _ZH = {
"restore_confirm": "'{name}' 恢复?\n当前数据将被覆盖。", "restore_confirm": "'{name}' 恢复?\n当前数据将被覆盖。",
"restored": "已从 {name} 恢复", "restored": "已从 {name} 恢复",
"restore_failed": "恢复失败:{e}", "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", "select_servers_json": "选择servers.json",
# TOTP / 2FA # TOTP / 2FA

View File

@@ -237,6 +237,80 @@ class ServerStore:
self._notify() self._notify()
log.info(f"Restored from: {filename}") 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 ────────────────────────────────────── # ── Observer ──────────────────────────────────────
def _notify(self): def _notify(self):

View File

@@ -5,6 +5,7 @@ Includes configuration path management and backup/restore.
import os import os
import threading import threading
from datetime import datetime
from tkinter import filedialog, messagebox from tkinter import filedialog, messagebox
import customtkinter as ctk import customtkinter as ctk
from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key 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") 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 # Log
self.log = ctk.CTkTextbox(self, height=150, font=ctk.CTkFont(family="Consolas", size=11), state="disabled") 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)) self.log.pack(fill="both", expand=True, padx=20, pady=(5, 20))
@@ -279,3 +308,69 @@ class SetupTab(ctk.CTkFrame):
else: else:
self._backup_menu.configure(values=[t("no_backups")]) self._backup_menu.configure(values=[t("no_backups")])
self._backup_var.set(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))