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:
51
core/i18n.py
51
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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user