627 lines
31 KiB
Python
627 lines
31 KiB
Python
# RepoSync/gui/main_window.py
|
|
|
|
"""
|
|
Main window and application logic for the RepoSync GUI.
|
|
Organizes the UI into separate tabs for different functionalities and
|
|
manages server profiles and settings dynamically.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import threading
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
from tkinter.scrolledtext import ScrolledText
|
|
from pathlib import Path
|
|
|
|
# Custom module imports
|
|
from ..config.profile_manager import ProfileManager
|
|
from .profile_dialog import ProfileDialog
|
|
from ..core.gitea_client import GiteaClient
|
|
from ..core.git_manager import GitManager
|
|
from ..core.sync_manager import SyncManager, SyncState, robust_rmtree,WikiInitializationRequiredError
|
|
from ..utility import logger as logger_util
|
|
|
|
def format_size(size_kb: int) -> str:
|
|
"""Formats size in kilobytes to a human-readable string (KB, MB, GB)."""
|
|
if not isinstance(size_kb, (int, float)) or size_kb <= 0:
|
|
return "N/A"
|
|
if size_kb < 1024:
|
|
return f"{size_kb} KB"
|
|
elif size_kb < 1024 * 1024:
|
|
return f"{size_kb / 1024:.2f} MB"
|
|
else:
|
|
return f"{size_kb / (1024 * 1024):.2f} GB"
|
|
|
|
def format_bytes(size_bytes: int) -> str:
|
|
"""Formats size in bytes to a human-readable string (B, KB, MB, GB)."""
|
|
if not isinstance(size_bytes, (int, float)) or size_bytes < 0:
|
|
return "0 B"
|
|
if size_bytes < 1024:
|
|
return f"{size_bytes} B"
|
|
return format_size(size_bytes // 1024)
|
|
|
|
|
|
class BaseTab(ttk.Frame):
|
|
"""Base class for our application tabs to reduce code duplication."""
|
|
def __init__(self, parent, app_instance, tab_name, **kwargs):
|
|
super().__init__(parent, **kwargs)
|
|
self.app = app_instance
|
|
self.logger = logging.getLogger(tab_name)
|
|
self.active_client = None
|
|
self.selected_profile_name = tk.StringVar()
|
|
self.sync_bundle_path = None
|
|
|
|
def create_profile_selector(self, parent_frame, label_text):
|
|
"""Creates the profile selection Combobox and status label."""
|
|
selector_frame = ttk.Frame(parent_frame)
|
|
ttk.Label(selector_frame, text=label_text).pack(side="left", padx=(0, 5))
|
|
self.profile_combo = ttk.Combobox(selector_frame, textvariable=self.selected_profile_name, state="readonly", width=25)
|
|
self.profile_combo.pack(side="left")
|
|
self.profile_combo.bind("<<ComboboxSelected>>", self._on_profile_select)
|
|
self.status_label = ttk.Label(selector_frame, text="Status: Not Connected", foreground="red")
|
|
self.status_label.pack(side="left", padx=10)
|
|
return selector_frame
|
|
|
|
def update_profile_list(self):
|
|
"""Updates the list of profiles in the Combobox."""
|
|
profile_names = self.app.profile_manager.get_profile_names()
|
|
self.profile_combo['values'] = sorted(profile_names)
|
|
if not profile_names:
|
|
self.selected_profile_name.set("")
|
|
self._update_status_label(False, "No profiles configured.")
|
|
|
|
def update_bundle_path(self):
|
|
"""Updates the bundle path from the main application's setting."""
|
|
new_path_str = self.app.bundle_path_var.get()
|
|
self.sync_bundle_path = Path(new_path_str) if new_path_str else None
|
|
|
|
def _on_profile_select(self, event=None):
|
|
"""Handles profile selection and initializes the appropriate client."""
|
|
profile_name = self.selected_profile_name.get()
|
|
if not profile_name:
|
|
self.active_client = None
|
|
self._update_status_label(False)
|
|
return
|
|
profile_data = self.app.profile_manager.get_profile(profile_name)
|
|
if not profile_data:
|
|
self.active_client = None
|
|
self.logger.error(f"Could not load data for profile '{profile_name}'.")
|
|
self._update_status_label(False, "Profile data missing.")
|
|
return
|
|
service_type = profile_data.get("type", "gitea")
|
|
if service_type == "gitea":
|
|
try:
|
|
self.active_client = GiteaClient(api_url=profile_data["url"], token=profile_data["token"], logger=logging.getLogger(f"GiteaClient({profile_name})"))
|
|
self._update_status_label(True, f"Selected: {profile_data['url']}")
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to initialize GiteaClient for '{profile_name}': {e}")
|
|
self._update_status_label(False, f"Error initializing client.")
|
|
else:
|
|
self.logger.error(f"Unsupported service type '{service_type}' for profile '{profile_name}'.")
|
|
self._update_status_label(False, f"Unsupported type: {service_type}")
|
|
|
|
def _update_status_label(self, is_connected, message=""):
|
|
"""Updates the status label text and color."""
|
|
if is_connected:
|
|
self.status_label.config(text=message or "Status: Connected", foreground="green")
|
|
else:
|
|
self.status_label.config(text=message or "Status: Not Connected", foreground="red")
|
|
|
|
|
|
class ExportTab(BaseTab):
|
|
"""Tab for fetching repos and wikis from a server and exporting them."""
|
|
def __init__(self, parent, app_instance, **kwargs):
|
|
super().__init__(parent, app_instance, "ExportTab", **kwargs)
|
|
self.repositories = []
|
|
self.repo_data_map = {}
|
|
self.selection_count_var = tk.StringVar(value="Selected: 0")
|
|
self.selection_size_var = tk.StringVar(value="Total Est. Size: 0 KB")
|
|
self.total_export_size = 0
|
|
self._create_widgets()
|
|
self.update_profile_list()
|
|
|
|
def _create_widgets(self):
|
|
top_frame = ttk.Frame(self, padding="5")
|
|
top_frame.pack(side="top", fill="x")
|
|
selector_frame = self.create_profile_selector(top_frame, "Export from Server:")
|
|
selector_frame.pack(side="left")
|
|
self.fetch_button = ttk.Button(top_frame, text="Fetch Items", command=self._start_repo_fetch)
|
|
self.fetch_button.pack(side="left", padx=10)
|
|
self.fetch_button.state(['disabled'])
|
|
self.export_button = ttk.Button(top_frame, text="Export Selected", command=self._start_export)
|
|
self.export_button.pack(side="left", padx=5)
|
|
self.export_button.state(['disabled'])
|
|
|
|
tree_frame = ttk.Frame(self)
|
|
tree_frame.pack(expand=True, fill="both", pady=5)
|
|
|
|
columns = ("name", "status", "type", "size", "description")
|
|
self.repo_tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="extended")
|
|
self.repo_tree.heading("name", text="Name")
|
|
self.repo_tree.heading("status", text="Status")
|
|
self.repo_tree.heading("type", text="Type")
|
|
self.repo_tree.heading("size", text="Size (est.)")
|
|
self.repo_tree.heading("description", text="Description")
|
|
self.repo_tree.column("name", width=250, stretch=tk.NO)
|
|
self.repo_tree.column("status", width=120, stretch=tk.NO, anchor="center")
|
|
self.repo_tree.column("type", width=80, stretch=tk.NO, anchor="center")
|
|
self.repo_tree.column("size", width=100, stretch=tk.NO, anchor="e")
|
|
self.repo_tree.column("description", width=400)
|
|
|
|
for state in SyncState:
|
|
self.repo_tree.tag_configure(state.name, background=self._get_color_for_state(state))
|
|
|
|
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.repo_tree.yview)
|
|
self.repo_tree.configure(yscrollcommand=vsb.set)
|
|
vsb.pack(side="right", fill="y")
|
|
self.repo_tree.pack(side="left", expand=True, fill="both")
|
|
|
|
self.repo_tree.bind("<<TreeviewSelect>>", self._update_selection_info)
|
|
|
|
info_frame = ttk.LabelFrame(self, text="Selection & Legend", padding="5")
|
|
info_frame.pack(side="bottom", fill="x", pady=(5, 0))
|
|
|
|
selection_buttons_frame = ttk.Frame(info_frame)
|
|
selection_buttons_frame.pack(side="left", padx=5, anchor="n")
|
|
ttk.Button(selection_buttons_frame, text="Select Modified", command=self._select_modified).pack(side="top", fill="x")
|
|
ttk.Button(selection_buttons_frame, text="Select All", command=self._select_all).pack(side="top", fill="x", pady=2)
|
|
ttk.Button(selection_buttons_frame, text="Select None", command=self._deselect_all).pack(side="top", fill="x")
|
|
|
|
selection_info_frame = ttk.Frame(info_frame)
|
|
selection_info_frame.pack(side="left", padx=10, anchor="n")
|
|
ttk.Label(selection_info_frame, textvariable=self.selection_count_var).pack(anchor="w")
|
|
ttk.Label(selection_info_frame, textvariable=self.selection_size_var).pack(anchor="w")
|
|
|
|
# --- MODIFIED PART FOR ALIGNMENT ---
|
|
legend_frame = ttk.Frame(info_frame, padding=(10, 0))
|
|
legend_frame.pack(side="right", padx=10, anchor="n") # Changed side to "right"
|
|
ttk.Label(legend_frame, text="Legend:").pack(anchor="e", pady=1) # Changed anchor to "e" (East)
|
|
ttk.Label(legend_frame, text="To Export (New/Modified)", background=self._get_color_for_state(SyncState.AHEAD)).pack(anchor="e", pady=1)
|
|
ttk.Label(legend_frame, text="Synchronized", background=self._get_color_for_state(SyncState.IDENTICAL)).pack(anchor="e", pady=1)
|
|
ttk.Label(legend_frame, text="Orphaned (Not on Server)", background=self._get_color_for_state(SyncState.ORPHANED_BUNDLE)).pack(anchor="e", pady=1)
|
|
|
|
def _get_color_for_state(self, state):
|
|
return {
|
|
SyncState.AHEAD: "lightgreen",
|
|
SyncState.NEW_REPO: "lightgreen",
|
|
SyncState.IDENTICAL: "white",
|
|
SyncState.ORPHANED_BUNDLE: "khaki"
|
|
}.get(state, "white")
|
|
|
|
def _on_profile_select(self, event=None):
|
|
super()._on_profile_select(event)
|
|
self.fetch_button.state(['!disabled'] if self.active_client else ['disabled'])
|
|
self.export_button.state(['disabled'])
|
|
self.repo_tree.delete(*self.repo_tree.get_children())
|
|
self._update_selection_info()
|
|
|
|
def _select_all(self):
|
|
self.repo_tree.selection_set(self.repo_tree.get_children())
|
|
|
|
def _deselect_all(self):
|
|
self.repo_tree.selection_set([])
|
|
|
|
def _select_modified(self):
|
|
# Correctly clear the previous selection before making a new one
|
|
self.repo_tree.selection_set([])
|
|
for iid in self.repo_tree.get_children():
|
|
repo = self.repo_data_map.get(iid)
|
|
if repo and repo.get("state") in [SyncState.AHEAD, SyncState.NEW_REPO]:
|
|
self.repo_tree.selection_add(iid)
|
|
|
|
def _update_selection_info(self, event=None):
|
|
selected_iids = self.repo_tree.selection()
|
|
count = len(selected_iids)
|
|
total_size_kb = sum(self.repo_data_map[iid].get("size_kb", 0) for iid in selected_iids if iid in self.repo_data_map)
|
|
self.selection_count_var.set(f"Selected: {count}")
|
|
self.selection_size_var.set(f"Total Est. Size: {format_size(total_size_kb)}")
|
|
|
|
def _start_repo_fetch(self):
|
|
if not self.active_client or not self.sync_bundle_path: return
|
|
self.logger.info(f"Fetching items list from '{self.selected_profile_name.get()}'...")
|
|
self.fetch_button.state(['disabled']); self.export_button.state(['disabled'])
|
|
self.repo_tree.delete(*self.repo_tree.get_children())
|
|
threading.Thread(target=self._load_repositories_thread, daemon=True).start()
|
|
|
|
def _load_repositories_thread(self):
|
|
try:
|
|
# 1. Get all repos from server and filter out non-existent wikis
|
|
initial_repos = self.active_client.get_repositories()
|
|
self.logger.info(f"Received {len(initial_repos)} items. Verifying wiki existence...")
|
|
|
|
verified_repos = []
|
|
for repo in initial_repos:
|
|
if repo.get("is_wiki"):
|
|
if repo.get("clone_url") and self.app.git_manager.check_remote_exists(repo["clone_url"], self.active_client.token):
|
|
verified_repos.append(repo)
|
|
else:
|
|
verified_repos.append(repo)
|
|
|
|
# 2. Compare the verified server list with the local manifest
|
|
sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager"))
|
|
self.repositories = sync_manager.compare_server_with_manifest(verified_repos)
|
|
self.repo_data_map = {repo['name']: repo for repo in self.repositories}
|
|
|
|
# 3. Update UI
|
|
self.app.root.after(10, self._update_repo_treeview)
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to fetch and compare items: {e}", exc_info=True)
|
|
self.app.root.after(10, lambda: messagebox.showerror("Fetch Error", f"Failed to fetch items:\n{e}"))
|
|
finally:
|
|
self.app.root.after(10, lambda: self.fetch_button.state(['!disabled']))
|
|
|
|
def _update_repo_treeview(self):
|
|
self.repo_tree.delete(*self.repo_tree.get_children())
|
|
sorted_repos = sorted(self.repositories, key=lambda x: x['name'].replace(" (Wiki)", " (Wiki)Z"))
|
|
for repo in sorted_repos:
|
|
repo_type = "Wiki" if repo.get("is_wiki") else "Repository"
|
|
state = repo.get("state", SyncState.ERROR)
|
|
self.repo_tree.insert("", "end", values=(
|
|
repo["name"],
|
|
state.name,
|
|
repo_type,
|
|
format_size(repo.get("size_kb", 0)),
|
|
repo.get("description", "")
|
|
), iid=repo["name"], tags=(state.name,))
|
|
self.logger.info(f"Displayed {len(self.repositories)} items with sync status.")
|
|
self.export_button.state(['!disabled'] if self.repositories else ['disabled'])
|
|
self._update_selection_info()
|
|
|
|
def _start_export(self):
|
|
selected_iids = self.repo_tree.selection()
|
|
if not selected_iids: return
|
|
if not self.sync_bundle_path: return
|
|
repos_to_export = [self.repo_data_map[iid] for iid in selected_iids if iid in self.repo_data_map]
|
|
|
|
sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager"))
|
|
self.logger.info(f"Starting export for {len(repos_to_export)} selected items...")
|
|
self.export_button.state(['disabled']); self.fetch_button.state(['disabled'])
|
|
self.total_export_size = 0
|
|
self.app.status_bar_progress.config(value=0)
|
|
self.app.status_bar_label.config(text="Starting export...")
|
|
|
|
thread = threading.Thread(target=self._export_thread, args=(sync_manager, repos_to_export), daemon=True)
|
|
thread.start()
|
|
|
|
def _progress_callback(self, current, total, bundle_size_bytes):
|
|
progress_percent = (current / total) * 100
|
|
self.total_export_size += bundle_size_bytes
|
|
self.app.status_bar_progress.config(value=progress_percent)
|
|
self.app.status_bar_label.config(text=f"Exporting {current}/{total}...")
|
|
self.app.status_bar_size_label.config(text=f"Total Size: {format_bytes(self.total_export_size)}")
|
|
|
|
def _export_thread(self, sync_manager, repos_to_export):
|
|
try:
|
|
callback = lambda c, t, s: self.app.root.after(0, self._progress_callback, c, t, s)
|
|
sync_manager.export_repositories_to_bundles(repos_to_export, progress_callback=callback)
|
|
self.app.root.after(0, lambda: messagebox.showinfo("Success", "Export completed successfully."))
|
|
except Exception as e:
|
|
self.logger.error(f"Export failed: {e}", exc_info=True)
|
|
self.app.root.after(0, lambda: messagebox.showerror("Export Error", f"An error occurred: {e}"))
|
|
finally:
|
|
self.app.root.after(0, lambda: self.app.status_bar_label.config(text="Export finished."))
|
|
self.app.root.after(0, lambda: self.export_button.state(['!disabled']))
|
|
self.app.root.after(0, lambda: self.fetch_button.state(['!disabled']))
|
|
# Refresh the view to show the new 'IDENTICAL' state
|
|
self.app.root.after(100, self._start_repo_fetch)
|
|
|
|
|
|
class ImportTab(BaseTab):
|
|
def __init__(self, parent, app_instance, **kwargs):
|
|
super().__init__(parent, app_instance, "ImportTab", **kwargs)
|
|
self.comparison_results = []
|
|
self.repo_data_map = {}
|
|
self.selection_count_var = tk.StringVar(value="Selected: 0")
|
|
self.selection_size_var = tk.StringVar(value="Total Size: 0 B")
|
|
self._create_widgets()
|
|
self.update_profile_list()
|
|
|
|
def _create_widgets(self):
|
|
top_frame = ttk.Frame(self, padding="5")
|
|
top_frame.pack(side="top", fill="x")
|
|
selector_frame = self.create_profile_selector(top_frame, "Import to Server:")
|
|
selector_frame.pack(side="left")
|
|
self.check_status_button = ttk.Button(top_frame, text="Check Bundle Status", command=self._start_bundle_check)
|
|
self.check_status_button.pack(side="left", padx=10)
|
|
self.check_status_button.state(['disabled'])
|
|
self.import_button = ttk.Button(top_frame, text="Import Selected", command=self._start_import)
|
|
self.import_button.pack(side="left", padx=5)
|
|
self.import_button.state(['disabled'])
|
|
|
|
tree_frame = ttk.Frame(self)
|
|
tree_frame.pack(expand=True, fill="both", pady=5)
|
|
|
|
columns = ("name", "status", "size")
|
|
self.bundle_tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="extended")
|
|
self.bundle_tree.heading("name", text="Repository Name")
|
|
self.bundle_tree.heading("status", text="Sync Status")
|
|
self.bundle_tree.heading("size", text="Bundle Size")
|
|
self.bundle_tree.column("name", width=300)
|
|
self.bundle_tree.column("status", width=150, anchor="center")
|
|
self.bundle_tree.column("size", width=100, anchor="e")
|
|
|
|
for state in SyncState:
|
|
self.bundle_tree.tag_configure(state.name, background=self._get_color_for_state(state))
|
|
|
|
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.bundle_tree.yview)
|
|
self.bundle_tree.configure(yscrollcommand=vsb.set)
|
|
vsb.pack(side="right", fill="y")
|
|
self.bundle_tree.pack(side="left", expand=True, fill="both")
|
|
|
|
self.bundle_tree.bind("<<TreeviewSelect>>", self._update_selection_info)
|
|
|
|
info_frame = ttk.LabelFrame(self, text="Selection Info", padding="5")
|
|
info_frame.pack(side="bottom", fill="x", pady=(5, 0))
|
|
selection_buttons_frame = ttk.Frame(info_frame)
|
|
selection_buttons_frame.pack(side="left", padx=5)
|
|
ttk.Button(selection_buttons_frame, text="Select All", command=self._select_all).pack(side="left")
|
|
ttk.Button(selection_buttons_frame, text="Select None", command=self._deselect_all).pack(side="left", padx=5)
|
|
ttk.Label(info_frame, textvariable=self.selection_count_var).pack(side="left", padx=10)
|
|
ttk.Label(info_frame, textvariable=self.selection_size_var).pack(side="left", padx=10)
|
|
|
|
def _get_color_for_state(self, state):
|
|
return {
|
|
SyncState.BEHIND: "lightblue", # BEHIND means bundle is ahead of remote
|
|
SyncState.NEW_REPO: "lightgreen",
|
|
SyncState.AHEAD: "khaki", # AHEAD means remote is ahead of bundle
|
|
SyncState.DIVERGED: "salmon",
|
|
SyncState.ERROR: "lightgrey",
|
|
SyncState.IDENTICAL: "white"
|
|
}.get(state, "white")
|
|
|
|
def _select_all(self):
|
|
self.bundle_tree.selection_set(self.bundle_tree.get_children())
|
|
|
|
def _deselect_all(self):
|
|
self.bundle_tree.selection_set([])
|
|
|
|
def _update_selection_info(self, event=None):
|
|
selected_iids = self.bundle_tree.selection()
|
|
count = len(selected_iids)
|
|
total_size_bytes = sum(self.repo_data_map[iid]["bundle_info"].get("bundle_size_bytes", 0) for iid in selected_iids if iid in self.repo_data_map)
|
|
self.selection_count_var.set(f"Selected: {count}")
|
|
self.selection_size_var.set(f"Total Size: {format_bytes(total_size_bytes)}")
|
|
|
|
def _on_profile_select(self, event=None):
|
|
super()._on_profile_select(event)
|
|
self.check_status_button.state(['!disabled'] if self.active_client else ['disabled'])
|
|
self.import_button.state(['disabled'])
|
|
self.bundle_tree.delete(*self.bundle_tree.get_children())
|
|
self._update_selection_info()
|
|
|
|
def _start_bundle_check(self):
|
|
if not self.active_client or not self.sync_bundle_path:
|
|
return
|
|
sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager"))
|
|
self.logger.info("Checking bundle status...")
|
|
self.check_status_button.state(['disabled'])
|
|
threading.Thread(target=self._check_status_thread, args=(sync_manager,), daemon=True).start()
|
|
|
|
def _check_status_thread(self, sync_manager):
|
|
try:
|
|
self.comparison_results = sync_manager.compare_bundles_with_remote()
|
|
self.repo_data_map = {repo['name']: repo for repo in self.comparison_results}
|
|
self.app.root.after(10, self._update_bundle_treeview)
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to compare bundle status: {e}", exc_info=True)
|
|
self.app.root.after(10, lambda: messagebox.showerror("Comparison Error", f"Failed to compare bundle status:\n{e}"))
|
|
finally:
|
|
self.app.root.after(10, lambda: self.check_status_button.state(['!disabled']))
|
|
|
|
def _update_bundle_treeview(self):
|
|
self.bundle_tree.delete(*self.bundle_tree.get_children())
|
|
for result in self.comparison_results:
|
|
bundle_size_bytes = result.get("bundle_info", {}).get("bundle_size_bytes", 0)
|
|
self.bundle_tree.insert("", "end", values=(
|
|
result["name"],
|
|
result["state"].name,
|
|
format_bytes(bundle_size_bytes)
|
|
), tags=(result["state"].name,), iid=result["name"])
|
|
self.import_button.state(['!disabled'] if self.comparison_results else ['disabled'])
|
|
self._update_selection_info()
|
|
|
|
def _start_import(self):
|
|
selected_iids = self.bundle_tree.selection()
|
|
if not selected_iids: return
|
|
|
|
# For import, we want repos where the bundle is ahead (BEHIND) or new
|
|
repos_to_import = [r for r in self.comparison_results if r["name"] in selected_iids and r["state"] in [SyncState.BEHIND, SyncState.NEW_REPO]]
|
|
if not repos_to_import:
|
|
messagebox.showwarning("Invalid Selection", "Please select repositories that are 'BEHIND' or 'NEW_REPO' to import.")
|
|
return
|
|
|
|
sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager"))
|
|
self.logger.info(f"Starting import for {len(repos_to_import)} repositories...")
|
|
|
|
self.import_button.state(['disabled']); self.check_status_button.state(['disabled'])
|
|
self.app.status_bar_progress.config(value=0)
|
|
self.app.status_bar_label.config(text="Starting import...")
|
|
self.app.status_bar_size_label.config(text="")
|
|
|
|
thread = threading.Thread(target=self._import_thread, args=(sync_manager, repos_to_import), daemon=True)
|
|
thread.start()
|
|
|
|
def _progress_callback(self, current, total):
|
|
progress_percent = (current / total) * 100
|
|
self.app.status_bar_progress.config(value=progress_percent)
|
|
self.app.status_bar_label.config(text=f"Importing {current}/{total}...")
|
|
|
|
def _import_thread(self, sync_manager, repos_to_import):
|
|
try:
|
|
callback = lambda c, t: self.app.root.after(0, self._progress_callback, c, t)
|
|
sync_manager.import_repositories_from_bundles(repos_to_import, progress_callback=callback)
|
|
self.app.root.after(0, lambda: messagebox.showinfo("Success", "Import process completed."))
|
|
except WikiInitializationRequiredError as e:
|
|
self.logger.warning(f"Wiki initialization required for {e.repo_name}.")
|
|
repo_name = e.repo_name
|
|
base_repo_name = repo_name.removesuffix(" (Wiki)").strip()
|
|
# Mostra un messaggio informativo specifico
|
|
self.app.root.after(0, lambda: messagebox.showinfo(
|
|
"Azione Manuale Richiesta",
|
|
f"L'importazione della wiki '{repo_name}' richiede un'azione manuale:\n\n"
|
|
"1. Vai al tuo server Gitea e apri la pagina del repository principale:\n"
|
|
f" '{base_repo_name}'\n\n"
|
|
"2. Clicca sulla scheda 'Wiki'.\n\n"
|
|
"3. Clicca sul pulsante 'Crea la prima pagina'.\n\n"
|
|
"Una volta creata la pagina, torna su RepoSync e riesegui l'importazione per questa wiki."
|
|
))
|
|
except Exception as e:
|
|
self.logger.error(f"Import failed: {e}", exc_info=True)
|
|
self.app.root.after(0, lambda: messagebox.showerror("Import Error", f"An error occurred: {e}"))
|
|
finally:
|
|
self.app.root.after(0, lambda: self.app.status_bar_label.config(text="Import finished."))
|
|
self.app.root.after(0, lambda: self.import_button.state(['!disabled']))
|
|
self.app.root.after(0, lambda: self.check_status_button.state(['!disabled']))
|
|
# Aggiorna la vista per mostrare che lo stato non è cambiato
|
|
self.app.root.after(100, self._start_bundle_check)
|
|
|
|
|
|
class Application:
|
|
"""The main class for the RepoSync graphical user interface."""
|
|
def __init__(self):
|
|
self.root = tk.Tk()
|
|
self.root.title("RepoSync - Offline Repository Synchronization")
|
|
self.root.geometry("1200x800")
|
|
|
|
self.profile_manager = ProfileManager()
|
|
self.git_manager = None
|
|
self.bundle_path_var = tk.StringVar()
|
|
|
|
self._create_menu()
|
|
self._create_main_widgets()
|
|
self._setup_logging()
|
|
self._init_backend()
|
|
|
|
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
|
|
|
|
def _create_menu(self):
|
|
menubar = tk.Menu(self.root)
|
|
self.root.config(menu=menubar)
|
|
|
|
file_menu = tk.Menu(menubar, tearoff=0)
|
|
file_menu.add_command(label="Configure Profiles...", command=self._open_profile_dialog)
|
|
file_menu.add_separator()
|
|
file_menu.add_command(label="Exit", command=self._on_closing)
|
|
menubar.add_cascade(label="File", menu=file_menu)
|
|
|
|
def _create_main_widgets(self):
|
|
top_frame = ttk.Frame(self.root, padding="10")
|
|
top_frame.pack(side="top", fill="x", expand=False)
|
|
|
|
main_frame = ttk.Frame(self.root, padding=(10, 0, 10, 10))
|
|
main_frame.pack(expand=True, fill="both")
|
|
|
|
bundle_path_frame = ttk.LabelFrame(top_frame, text="Bundle Folder Path", padding="5")
|
|
bundle_path_frame.pack(side="top", fill="x")
|
|
|
|
self.bundle_path_entry = ttk.Entry(bundle_path_frame, textvariable=self.bundle_path_var, state="readonly", width=100)
|
|
self.bundle_path_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
|
|
browse_button = ttk.Button(bundle_path_frame, text="Browse...", command=self._browse_bundle_path)
|
|
browse_button.pack(side="left")
|
|
reset_button = ttk.Button(bundle_path_frame, text="Reset", command=self._reset_bundle_folder)
|
|
reset_button.pack(side="left", padx=(5, 0))
|
|
|
|
paned_window = ttk.PanedWindow(main_frame, orient=tk.VERTICAL)
|
|
paned_window.pack(expand=True, fill="both")
|
|
|
|
notebook = ttk.Notebook(paned_window)
|
|
paned_window.add(notebook, weight=3)
|
|
|
|
self.export_tab = ExportTab(notebook, self, padding="10")
|
|
self.import_tab = ImportTab(notebook, self, padding="10")
|
|
notebook.add(self.export_tab, text="Export from Server")
|
|
notebook.add(self.import_tab, text="Import to Server")
|
|
|
|
log_frame = ttk.LabelFrame(paned_window, text="Logs", padding="5")
|
|
paned_window.add(log_frame, weight=1)
|
|
|
|
self.log_widget = ScrolledText(log_frame, state=tk.DISABLED, wrap=tk.WORD, height=10)
|
|
self.log_widget.pack(expand=True, fill="both")
|
|
|
|
status_bar = ttk.Frame(self.root, padding=(5, 2))
|
|
status_bar.pack(side="bottom", fill="x")
|
|
|
|
self.status_bar_label = ttk.Label(status_bar, text="Ready")
|
|
self.status_bar_label.pack(side="left")
|
|
self.status_bar_size_label = ttk.Label(status_bar, text="")
|
|
self.status_bar_size_label.pack(side="right")
|
|
self.status_bar_progress = ttk.Progressbar(status_bar, orient='horizontal', mode='determinate')
|
|
self.status_bar_progress.pack(side="right", fill="x", expand=True, padx=10)
|
|
|
|
def _browse_bundle_path(self):
|
|
initial_dir = self.bundle_path_var.get() or str(Path.home())
|
|
new_path = filedialog.askdirectory(title="Select Bundle Folder", initialdir=initial_dir)
|
|
if new_path:
|
|
self.bundle_path_var.set(new_path)
|
|
self.profile_manager.set_setting("bundle_path", new_path)
|
|
self.logger.info(f"Bundle path updated to: {new_path}")
|
|
self.export_tab.update_bundle_path()
|
|
self.import_tab.update_bundle_path()
|
|
|
|
def _reset_bundle_folder(self):
|
|
bundle_path_str = self.bundle_path_var.get()
|
|
if not bundle_path_str:
|
|
messagebox.showwarning("No Folder Selected", "Please select a bundle folder first.", parent=self.root)
|
|
return
|
|
|
|
if not messagebox.askyesno(
|
|
"Confirm Reset",
|
|
f"Are you sure you want to permanently delete all contents of the folder:\n\n{bundle_path_str}\n\nThis will remove all exported bundles and the manifest file. This action cannot be undone.",
|
|
parent=self.root
|
|
):
|
|
return
|
|
|
|
try:
|
|
bundle_path = Path(bundle_path_str)
|
|
if bundle_path.exists():
|
|
self.logger.info(f"Resetting bundle folder: {bundle_path}")
|
|
# Use robust_rmtree to handle potential permission issues
|
|
robust_rmtree(bundle_path)
|
|
# Recreate the folder so it's ready for use
|
|
os.makedirs(bundle_path, exist_ok=True)
|
|
self.logger.info("Bundle folder has been cleared.")
|
|
messagebox.showinfo("Reset Complete", "The bundle folder has been successfully cleared.", parent=self.root)
|
|
else:
|
|
self.logger.warning("Attempted to reset a folder that does not exist.")
|
|
|
|
# Refresh the UI to reflect the reset state
|
|
self.export_tab._on_profile_select()
|
|
self.import_tab._on_profile_select()
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to reset bundle folder: {e}", exc_info=True)
|
|
messagebox.showerror("Reset Error", f"An error occurred while clearing the folder:\n{e}", parent=self.root)
|
|
|
|
def _setup_logging(self):
|
|
log_config = {"level": logging.INFO, "enable_console": True}
|
|
logger_util.setup_logging(self.log_widget, self.root, log_config)
|
|
self.logger = logging.getLogger(__name__)
|
|
|
|
def _init_backend(self):
|
|
self.logger.info("Initializing backend components...")
|
|
self.git_manager = GitManager(logger=logging.getLogger("GitManager"))
|
|
initial_bundle_path = self.profile_manager.get_setting("bundle_path")
|
|
self.bundle_path_var.set(initial_bundle_path)
|
|
self.export_tab.update_bundle_path()
|
|
self.import_tab.update_bundle_path()
|
|
self.logger.info("Backend components initialized.")
|
|
|
|
def _open_profile_dialog(self):
|
|
dialog = ProfileDialog(self.root, self.profile_manager)
|
|
self.root.wait_window(dialog)
|
|
self.update_all_profile_comboboxes()
|
|
|
|
def update_all_profile_comboboxes(self):
|
|
self.export_tab.update_profile_list()
|
|
self.import_tab.update_profile_list()
|
|
|
|
def _on_closing(self):
|
|
self.logger.info("Application is closing.")
|
|
#logger_util.shutdown_logging()
|
|
self.root.destroy()
|
|
|
|
def run(self):
|
|
self.logger.info("Starting RepoSync application...")
|
|
self.root.mainloop() |