Aggiunge GUI di configurazione
This commit is contained in:
@@ -46,6 +46,15 @@ python -m bakrest.tray_app
|
|||||||
|
|
||||||
La tray app legge lo stato prodotto dal servizio e offre il comando `Backup e spegni`. Se il backup fallisce, lo spegnimento non viene eseguito.
|
La tray app legge lo stato prodotto dal servizio e offre il comando `Backup e spegni`. Se il backup fallisce, lo spegnimento non viene eseguito.
|
||||||
|
|
||||||
|
## Avvio GUI configurazione
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -e .
|
||||||
|
python -m bakrest.config_app
|
||||||
|
```
|
||||||
|
|
||||||
|
La GUI permette di modificare la configurazione e vedere la coda dei file in attesa di backup. I file in coda restano persistenti anche se il computer viene spento senza eseguire il backup.
|
||||||
|
|
||||||
## Installazione servizio Windows
|
## Installazione servizio Windows
|
||||||
|
|
||||||
Da PowerShell avviato come amministratore:
|
Da PowerShell avviato come amministratore:
|
||||||
|
|||||||
@@ -72,7 +72,12 @@ La GUI di configurazione deve permettere di:
|
|||||||
- configurare il comando o percorso di `rsync`;
|
- configurare il comando o percorso di `rsync`;
|
||||||
- visualizzare lo stato corrente;
|
- visualizzare lo stato corrente;
|
||||||
- aprire i log giornalieri;
|
- aprire i log giornalieri;
|
||||||
- eventualmente eseguire un test di raggiungibilita' del server.
|
- eventualmente eseguire un test di raggiungibilita' del server;
|
||||||
|
- visualizzare l'elenco dei file in coda per il backup;
|
||||||
|
- rimuovere singoli file dalla coda;
|
||||||
|
- proporre, quando si rimuove un file dalla coda, se rimuovere solo il file o escludere dal backup la cartella che lo contiene.
|
||||||
|
|
||||||
|
Se un computer viene spento senza usare `Backup e spegni`, i file gia' registrati come `pending` devono restare nel registro cambiamenti e accumularsi con quelli del giorno successivo, in modo da poter essere sincronizzati al backup successivo.
|
||||||
|
|
||||||
## Backup
|
## Backup
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ description = "Windows watchdog service and tray backup utility."
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"customtkinter>=5.2.2",
|
||||||
"pillow>=10.0.0",
|
"pillow>=10.0.0",
|
||||||
"pystray>=0.19.5",
|
"pystray>=0.19.5",
|
||||||
|
"tomli-w>=1.0.0",
|
||||||
"watchdog>=4.0.0",
|
"watchdog>=4.0.0",
|
||||||
"pywin32>=306; platform_system == 'Windows'",
|
"pywin32>=306; platform_system == 'Windows'",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
from .default_config import DEFAULT_CONFIG_TOML
|
from .default_config import DEFAULT_CONFIG_TOML
|
||||||
from .paths import default_config_path, expand_path
|
from .paths import default_config_path, expand_path
|
||||||
|
|
||||||
@@ -78,10 +80,22 @@ def copy_example_config(destination: Path) -> None:
|
|||||||
|
|
||||||
def load_config(path: Path | None = None) -> AppConfig:
|
def load_config(path: Path | None = None) -> AppConfig:
|
||||||
config_path = ensure_default_config(path)
|
config_path = ensure_default_config(path)
|
||||||
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
data = load_config_data(config_path)
|
||||||
return parse_config(data, config_path)
|
return parse_config(data, config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_data(path: Path | None = None) -> dict[str, Any]:
|
||||||
|
config_path = ensure_default_config(path)
|
||||||
|
return tomllib.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def save_config_data(data: dict[str, Any], path: Path | None = None) -> Path:
|
||||||
|
config_path = path or default_config_path()
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path.write_text(tomli_w.dumps(data), encoding="utf-8")
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
|
def parse_config(data: dict[str, Any], path: Path) -> AppConfig:
|
||||||
service = data.get("service", {})
|
service = data.get("service", {})
|
||||||
tray = data.get("tray", {})
|
tray = data.get("tray", {})
|
||||||
|
|||||||
343
src/bakrest/config_app.py
Normal file
343
src/bakrest/config_app.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog, messagebox
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from .config import load_config, load_config_data, save_config_data
|
||||||
|
from .paths import default_config_path
|
||||||
|
from .registry import ChangeEvent, ChangeRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class BakRestConfigApp(ctk.CTk):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
ctk.set_appearance_mode("system")
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
self.title("Bak&Rest Config")
|
||||||
|
self.geometry("980x680")
|
||||||
|
self.minsize(860, 560)
|
||||||
|
|
||||||
|
self.config_path = default_config_path()
|
||||||
|
self.data = load_config_data(self.config_path)
|
||||||
|
self.app_config = load_config(self.config_path)
|
||||||
|
self.registry = ChangeRegistry(self.app_config.storage.change_registry)
|
||||||
|
self.pending_events: list[ChangeEvent] = []
|
||||||
|
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
self.header = ctk.CTkFrame(self, corner_radius=0)
|
||||||
|
self.header.grid(row=0, column=0, sticky="ew")
|
||||||
|
self.header.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.path_label = ctk.CTkLabel(
|
||||||
|
self.header,
|
||||||
|
text=f"Configurazione: {self.config_path}",
|
||||||
|
anchor="w",
|
||||||
|
)
|
||||||
|
self.path_label.grid(row=0, column=0, sticky="ew", padx=16, pady=10)
|
||||||
|
|
||||||
|
self.tabs = ctk.CTkTabview(self)
|
||||||
|
self.tabs.grid(row=1, column=0, sticky="nsew", padx=16, pady=(8, 16))
|
||||||
|
self.general_tab = self.tabs.add("Configurazione")
|
||||||
|
self.queue_tab = self.tabs.add("Coda backup")
|
||||||
|
|
||||||
|
self._build_general_tab()
|
||||||
|
self._build_queue_tab()
|
||||||
|
self._load_values()
|
||||||
|
self._refresh_queue()
|
||||||
|
|
||||||
|
def _build_general_tab(self) -> None:
|
||||||
|
self.general_tab.grid_columnconfigure(0, weight=1)
|
||||||
|
self.general_tab.grid_columnconfigure(1, weight=1)
|
||||||
|
self.general_tab.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
left = ctk.CTkFrame(self.general_tab)
|
||||||
|
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8), pady=8)
|
||||||
|
left.grid_columnconfigure(0, weight=1)
|
||||||
|
left.grid_rowconfigure(1, weight=1)
|
||||||
|
left.grid_rowconfigure(3, weight=1)
|
||||||
|
|
||||||
|
right = ctk.CTkFrame(self.general_tab)
|
||||||
|
right.grid(row=0, column=1, sticky="nsew", padx=(8, 0), pady=8)
|
||||||
|
right.grid_columnconfigure(0, weight=1)
|
||||||
|
right.grid_rowconfigure(1, weight=1)
|
||||||
|
right.grid_rowconfigure(3, weight=1)
|
||||||
|
|
||||||
|
self.include_dirs = _ListEditor(left, "Cartelle monitorate", browse_dirs=True)
|
||||||
|
self.include_dirs.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=12, pady=12)
|
||||||
|
|
||||||
|
self.extensions = _ListEditor(right, "Estensioni incluse")
|
||||||
|
self.extensions.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=12, pady=12)
|
||||||
|
|
||||||
|
self.exclude_dirs = _ListEditor(left, "Cartelle escluse", browse_dirs=True)
|
||||||
|
self.exclude_dirs.grid(row=2, column=0, rowspan=2, sticky="nsew", padx=12, pady=12)
|
||||||
|
|
||||||
|
self.exclude_patterns = _ListEditor(right, "Pattern esclusi")
|
||||||
|
self.exclude_patterns.grid(row=2, column=0, rowspan=2, sticky="nsew", padx=12, pady=12)
|
||||||
|
|
||||||
|
backup_frame = ctk.CTkFrame(self.general_tab)
|
||||||
|
backup_frame.grid(row=1, column=0, columnspan=2, sticky="ew", padx=0, pady=(0, 8))
|
||||||
|
backup_frame.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
self.server_host_var = tk.StringVar()
|
||||||
|
self.server_port_var = tk.StringVar()
|
||||||
|
self.remote_destination_var = tk.StringVar()
|
||||||
|
self.rsync_path_var = tk.StringVar()
|
||||||
|
self.shutdown_on_success_var = tk.BooleanVar()
|
||||||
|
|
||||||
|
_label_entry(backup_frame, "Server", self.server_host_var, 0)
|
||||||
|
_label_entry(backup_frame, "Porta TCP", self.server_port_var, 1)
|
||||||
|
_label_entry(backup_frame, "Destinazione remota", self.remote_destination_var, 2)
|
||||||
|
_label_entry(backup_frame, "Percorso rsync", self.rsync_path_var, 3)
|
||||||
|
|
||||||
|
shutdown_check = ctk.CTkCheckBox(
|
||||||
|
backup_frame,
|
||||||
|
text="Spegni il PC se il backup termina correttamente",
|
||||||
|
variable=self.shutdown_on_success_var,
|
||||||
|
)
|
||||||
|
shutdown_check.grid(row=4, column=0, columnspan=2, sticky="w", padx=12, pady=8)
|
||||||
|
|
||||||
|
actions = ctk.CTkFrame(self.general_tab, fg_color="transparent")
|
||||||
|
actions.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 8))
|
||||||
|
actions.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
save_button = ctk.CTkButton(actions, text="Salva configurazione", command=self._save_config)
|
||||||
|
save_button.grid(row=0, column=1, padx=8, pady=4)
|
||||||
|
|
||||||
|
reload_button = ctk.CTkButton(actions, text="Ricarica", command=self._reload_config)
|
||||||
|
reload_button.grid(row=0, column=2, padx=8, pady=4)
|
||||||
|
|
||||||
|
def _build_queue_tab(self) -> None:
|
||||||
|
self.queue_tab.grid_columnconfigure(0, weight=1)
|
||||||
|
self.queue_tab.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
toolbar = ctk.CTkFrame(self.queue_tab, fg_color="transparent")
|
||||||
|
toolbar.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
|
||||||
|
toolbar.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.queue_count_label = ctk.CTkLabel(toolbar, text="File in coda: 0", anchor="w")
|
||||||
|
self.queue_count_label.grid(row=0, column=0, sticky="ew", padx=4)
|
||||||
|
|
||||||
|
refresh_button = ctk.CTkButton(toolbar, text="Aggiorna", command=self._refresh_queue)
|
||||||
|
refresh_button.grid(row=0, column=1, padx=4)
|
||||||
|
|
||||||
|
remove_button = ctk.CTkButton(toolbar, text="Rimuovi selezionato", command=self._remove_selected_pending)
|
||||||
|
remove_button.grid(row=0, column=2, padx=4)
|
||||||
|
|
||||||
|
list_frame = ctk.CTkFrame(self.queue_tab)
|
||||||
|
list_frame.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
|
||||||
|
list_frame.grid_columnconfigure(0, weight=1)
|
||||||
|
list_frame.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.queue_list = tk.Listbox(list_frame, activestyle="dotbox", exportselection=False)
|
||||||
|
self.queue_list.grid(row=0, column=0, sticky="nsew", padx=(8, 0), pady=8)
|
||||||
|
|
||||||
|
scrollbar = ctk.CTkScrollbar(list_frame, command=self.queue_list.yview)
|
||||||
|
scrollbar.grid(row=0, column=1, sticky="ns", padx=(4, 8), pady=8)
|
||||||
|
self.queue_list.configure(yscrollcommand=scrollbar.set)
|
||||||
|
|
||||||
|
def _load_values(self) -> None:
|
||||||
|
watch = self.data.setdefault("watch", {})
|
||||||
|
backup = self.data.setdefault("backup", {})
|
||||||
|
server_check = backup.setdefault("server_check", {})
|
||||||
|
|
||||||
|
self.include_dirs.set_items(watch.get("include_dirs", []))
|
||||||
|
self.extensions.set_items(watch.get("include_extensions", []))
|
||||||
|
self.exclude_dirs.set_items(watch.get("exclude_dirs", []))
|
||||||
|
self.exclude_patterns.set_items(watch.get("exclude_patterns", []))
|
||||||
|
|
||||||
|
self.server_host_var.set(str(backup.get("server_host", "")))
|
||||||
|
self.server_port_var.set(str(server_check.get("port", 22)))
|
||||||
|
self.remote_destination_var.set(str(backup.get("remote_destination", "")))
|
||||||
|
self.rsync_path_var.set(str(backup.get("rsync_path", "rsync")))
|
||||||
|
self.shutdown_on_success_var.set(bool(backup.get("shutdown_on_success", True)))
|
||||||
|
|
||||||
|
def _save_config(self) -> None:
|
||||||
|
watch = self.data.setdefault("watch", {})
|
||||||
|
backup = self.data.setdefault("backup", {})
|
||||||
|
server_check = backup.setdefault("server_check", {})
|
||||||
|
|
||||||
|
watch["include_dirs"] = self.include_dirs.items()
|
||||||
|
watch["include_extensions"] = self.extensions.items()
|
||||||
|
watch["exclude_dirs"] = self.exclude_dirs.items()
|
||||||
|
watch["exclude_patterns"] = self.exclude_patterns.items()
|
||||||
|
|
||||||
|
backup["server_host"] = self.server_host_var.get().strip()
|
||||||
|
backup["remote_destination"] = self.remote_destination_var.get().strip()
|
||||||
|
backup["rsync_path"] = self.rsync_path_var.get().strip() or "rsync"
|
||||||
|
backup["shutdown_on_success"] = bool(self.shutdown_on_success_var.get())
|
||||||
|
server_check["port"] = _safe_int(self.server_port_var.get(), 22)
|
||||||
|
|
||||||
|
save_config_data(self.data, self.config_path)
|
||||||
|
self.app_config = load_config(self.config_path)
|
||||||
|
self.registry = ChangeRegistry(self.app_config.storage.change_registry)
|
||||||
|
messagebox.showinfo("Bak&Rest", "Configurazione salvata.")
|
||||||
|
|
||||||
|
def _reload_config(self) -> None:
|
||||||
|
self.data = load_config_data(self.config_path)
|
||||||
|
self.app_config = load_config(self.config_path)
|
||||||
|
self.registry = ChangeRegistry(self.app_config.storage.change_registry)
|
||||||
|
self._load_values()
|
||||||
|
self._refresh_queue()
|
||||||
|
|
||||||
|
def _refresh_queue(self) -> None:
|
||||||
|
self.pending_events = self.registry.list_pending()
|
||||||
|
self.queue_list.delete(0, tk.END)
|
||||||
|
for event in self.pending_events:
|
||||||
|
self.queue_list.insert(tk.END, f"{event.event_type} | {event.path}")
|
||||||
|
self.queue_count_label.configure(text=f"File in coda: {len(self.pending_events)}")
|
||||||
|
|
||||||
|
def _remove_selected_pending(self) -> None:
|
||||||
|
selection = self.queue_list.curselection()
|
||||||
|
if not selection:
|
||||||
|
messagebox.showinfo("Bak&Rest", "Seleziona un file dalla coda.")
|
||||||
|
return
|
||||||
|
|
||||||
|
event = self.pending_events[selection[0]]
|
||||||
|
dialog = RemovePendingDialog(self, event.path)
|
||||||
|
self.wait_window(dialog)
|
||||||
|
if dialog.choice == "file":
|
||||||
|
self.registry.mark_ignored(event.path)
|
||||||
|
elif dialog.choice == "folder":
|
||||||
|
folder = str(Path(event.path).parent)
|
||||||
|
self._add_exclude_dir(folder)
|
||||||
|
self.registry.mark_status(self._pending_paths_inside(folder), "ignored")
|
||||||
|
save_config_data(self.data, self.config_path)
|
||||||
|
self.exclude_dirs.set_items(self.data.setdefault("watch", {}).get("exclude_dirs", []))
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
self._refresh_queue()
|
||||||
|
|
||||||
|
def _add_exclude_dir(self, folder: str) -> None:
|
||||||
|
watch = self.data.setdefault("watch", {})
|
||||||
|
exclude_dirs = list(watch.get("exclude_dirs", []))
|
||||||
|
if folder not in exclude_dirs:
|
||||||
|
exclude_dirs.append(folder)
|
||||||
|
watch["exclude_dirs"] = exclude_dirs
|
||||||
|
|
||||||
|
def _pending_paths_inside(self, folder: str) -> list[str]:
|
||||||
|
folder_path = Path(folder)
|
||||||
|
paths: list[str] = []
|
||||||
|
for event in self.pending_events:
|
||||||
|
try:
|
||||||
|
Path(event.path).relative_to(folder_path)
|
||||||
|
paths.append(event.path)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
class RemovePendingDialog(ctk.CTkToplevel):
|
||||||
|
def __init__(self, parent: ctk.CTk, file_path: str) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.choice: str | None = None
|
||||||
|
self.title("Rimuovi dalla coda")
|
||||||
|
self.geometry("520x190")
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.transient(parent)
|
||||||
|
self.grab_set()
|
||||||
|
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
label = ctk.CTkLabel(
|
||||||
|
self,
|
||||||
|
text="Vuoi rimuovere solo il file o isolare dal backup la cartella che lo contiene?",
|
||||||
|
wraplength=470,
|
||||||
|
justify="left",
|
||||||
|
)
|
||||||
|
label.grid(row=0, column=0, sticky="ew", padx=18, pady=(18, 8))
|
||||||
|
|
||||||
|
path_label = ctk.CTkLabel(self, text=file_path, wraplength=470, justify="left")
|
||||||
|
path_label.grid(row=1, column=0, sticky="ew", padx=18, pady=(0, 16))
|
||||||
|
|
||||||
|
buttons = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
buttons.grid(row=2, column=0, sticky="e", padx=12, pady=12)
|
||||||
|
|
||||||
|
ctk.CTkButton(buttons, text="Solo file", command=lambda: self._close("file")).grid(row=0, column=0, padx=6)
|
||||||
|
ctk.CTkButton(buttons, text="Isola cartella", command=lambda: self._close("folder")).grid(row=0, column=1, padx=6)
|
||||||
|
ctk.CTkButton(buttons, text="Annulla", command=lambda: self._close(None)).grid(row=0, column=2, padx=6)
|
||||||
|
|
||||||
|
def _close(self, choice: str | None) -> None:
|
||||||
|
self.choice = choice
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class _ListEditor(ctk.CTkFrame):
|
||||||
|
def __init__(self, parent: ctk.CTkBaseClass, title: str, browse_dirs: bool = False) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.browse_dirs = browse_dirs
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
label = ctk.CTkLabel(self, text=title, anchor="w")
|
||||||
|
label.grid(row=0, column=0, columnspan=3, sticky="ew", padx=10, pady=(8, 4))
|
||||||
|
|
||||||
|
self.listbox = tk.Listbox(self, activestyle="dotbox", exportselection=False)
|
||||||
|
self.listbox.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=10, pady=(0, 8))
|
||||||
|
|
||||||
|
self.entry_var = tk.StringVar()
|
||||||
|
entry = ctk.CTkEntry(self, textvariable=self.entry_var)
|
||||||
|
entry.grid(row=2, column=0, sticky="ew", padx=(10, 4), pady=(0, 10))
|
||||||
|
|
||||||
|
add_button = ctk.CTkButton(self, text="+", width=42, command=self._add)
|
||||||
|
add_button.grid(row=2, column=1, padx=4, pady=(0, 10))
|
||||||
|
|
||||||
|
remove_button = ctk.CTkButton(self, text="-", width=42, command=self._remove)
|
||||||
|
remove_button.grid(row=2, column=2, padx=(4, 10), pady=(0, 10))
|
||||||
|
|
||||||
|
if browse_dirs:
|
||||||
|
browse_button = ctk.CTkButton(self, text="Sfoglia", command=self._browse)
|
||||||
|
browse_button.grid(row=3, column=0, columnspan=3, sticky="ew", padx=10, pady=(0, 10))
|
||||||
|
|
||||||
|
def set_items(self, items: list[str]) -> None:
|
||||||
|
self.listbox.delete(0, tk.END)
|
||||||
|
for item in items:
|
||||||
|
self.listbox.insert(tk.END, item)
|
||||||
|
|
||||||
|
def items(self) -> list[str]:
|
||||||
|
return [str(self.listbox.get(index)) for index in range(self.listbox.size())]
|
||||||
|
|
||||||
|
def _add(self) -> None:
|
||||||
|
value = self.entry_var.get().strip()
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
if value not in self.items():
|
||||||
|
self.listbox.insert(tk.END, value)
|
||||||
|
self.entry_var.set("")
|
||||||
|
|
||||||
|
def _remove(self) -> None:
|
||||||
|
selection = self.listbox.curselection()
|
||||||
|
if selection:
|
||||||
|
self.listbox.delete(selection[0])
|
||||||
|
|
||||||
|
def _browse(self) -> None:
|
||||||
|
directory = filedialog.askdirectory()
|
||||||
|
if directory:
|
||||||
|
self.entry_var.set(directory)
|
||||||
|
self._add()
|
||||||
|
|
||||||
|
|
||||||
|
def _label_entry(parent: ctk.CTkFrame, label: str, variable: tk.StringVar, row: int) -> None:
|
||||||
|
ctk.CTkLabel(parent, text=label, anchor="w").grid(row=row, column=0, sticky="w", padx=12, pady=6)
|
||||||
|
ctk.CTkEntry(parent, textvariable=variable).grid(row=row, column=1, sticky="ew", padx=12, pady=6)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
app = BakRestConfigApp()
|
||||||
|
app.mainloop()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -77,6 +77,9 @@ class ChangeRegistry:
|
|||||||
)
|
)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
|
def mark_ignored(self, path: str) -> None:
|
||||||
|
self.mark_status([path], "ignored")
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
with closing(self._connect()) as connection:
|
with closing(self._connect()) as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
|
|||||||
14
tests/test_config_save.py
Normal file
14
tests/test_config_save.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bakrest.config import load_config_data, save_config_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_config_data_round_trips(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.toml"
|
||||||
|
data = {"watch": {"include_dirs": [str(tmp_path)], "include_extensions": [".jpg"]}}
|
||||||
|
|
||||||
|
save_config_data(data, path)
|
||||||
|
|
||||||
|
assert load_config_data(path) == data
|
||||||
26
tests/test_registry.py
Normal file
26
tests/test_registry.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bakrest.registry import ChangeEvent, ChangeRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_changes_persist_until_marked(tmp_path: Path) -> None:
|
||||||
|
db_path = tmp_path / "changes.sqlite"
|
||||||
|
first = ChangeRegistry(db_path)
|
||||||
|
first.record(ChangeEvent(path=str(tmp_path / "image.jpg"), event_type="modified"))
|
||||||
|
|
||||||
|
second = ChangeRegistry(db_path)
|
||||||
|
|
||||||
|
assert second.pending_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_ignored_removes_from_pending_queue(tmp_path: Path) -> None:
|
||||||
|
db_path = tmp_path / "changes.sqlite"
|
||||||
|
registry = ChangeRegistry(db_path)
|
||||||
|
path = str(tmp_path / "image.jpg")
|
||||||
|
registry.record(ChangeEvent(path=path, event_type="modified"))
|
||||||
|
|
||||||
|
registry.mark_ignored(path)
|
||||||
|
|
||||||
|
assert registry.pending_count() == 0
|
||||||
Reference in New Issue
Block a user