44from tkinter import ttk , filedialog , messagebox
55
66from . import (library , organize , dupes , fixtags , importer , journal , playlists ,
7- auth , paths , settings , updates , wizard )
7+ auth , paths , settings , updates , wizard , session )
88from .common import human_size
99
1010APP = "Sortero"
@@ -90,6 +90,7 @@ def __init__(self):
9090 self ._build_menu ()
9191 self ._build_header ()
9292 self ._build_tabs ()
93+ self ._build_banner ()
9394 self ._build_footer ()
9495 self .after (250 , self ._first_run )
9596
@@ -113,6 +114,19 @@ def _build_menu(self):
113114 command = lambda : paths .reveal (paths .data_dir ()))
114115 menubar .add_cascade (label = "File" , menu = filem )
115116
117+ testm = tk .Menu (menubar , tearoff = 0 )
118+ testm .add_command (label = "Start Testing Session…" , command = self .testing_start )
119+ testm .add_separator ()
120+ testm .add_command (label = "Keep All Changes (delete backup)…" ,
121+ command = self .testing_commit )
122+ testm .add_command (label = "Undo Everything in This Session…" ,
123+ command = self .testing_revert )
124+ testm .add_separator ()
125+ testm .add_command (label = "Save Backup As…" , command = self .testing_export )
126+ testm .add_command (label = "Load Backup and Undo…" , command = self .testing_load )
127+ menubar .add_cascade (label = "Testing" , menu = testm )
128+ self .testm = testm
129+
116130 helpm = tk .Menu (menubar , tearoff = 0 , name = "help" )
117131 helpm .add_command (label = "Setup Wizard…" , command = self .run_wizard )
118132 helpm .add_separator ()
@@ -131,6 +145,141 @@ def _build_menu(self):
131145 self .bind_all ("<Command-r>" if paths .IS_MAC else "<Control-r>" ,
132146 lambda e : self .scan ())
133147
148+ # -- testing mode ------------------------------------------------------
149+ def _build_banner (self ):
150+ self .banner = tk .Frame (self , bg = "#8a5a00" )
151+ self .banner_label = tk .Label (self .banner , bg = "#8a5a00" , fg = "white" ,
152+ font = ("Helvetica" , 12 , "bold" ), pady = 6 )
153+ self .banner_label .pack (side = "left" , padx = 12 )
154+ tk .Button (self .banner , text = "Keep changes" , command = self .testing_commit ,
155+ highlightbackground = "#8a5a00" ).pack (side = "right" , padx = 6 , pady = 4 )
156+ tk .Button (self .banner , text = "Undo everything" , command = self .testing_revert ,
157+ highlightbackground = "#8a5a00" ).pack (side = "right" , pady = 4 )
158+ self .refresh_banner ()
159+
160+ def refresh_banner (self ):
161+ sess = session .active ()
162+ if not sess :
163+ self .banner .pack_forget ()
164+ return
165+ s = session .summary (sess )
166+ self .banner_label .configure (
167+ text = f"TESTING MODE — { s ['operations' ]} operations recorded "
168+ f"({ s ['moves' ]} moves, { s ['tags' ]} tag edits). "
169+ f"Nothing is permanent until you keep it." )
170+ self .banner .pack (fill = "x" , before = self .nb )
171+
172+ def testing_start (self ):
173+ d = self .require_root ()
174+ if not d :
175+ return
176+ if session .active ():
177+ messagebox .showinfo (APP , "A testing session is already running." )
178+ return
179+ if not messagebox .askyesno (
180+ APP , "Start a testing session?\n \n "
181+ "Everything you do from now on is recorded into one restore "
182+ "point, saved continuously as a .bak file. You can undo the "
183+ "whole lot in one go, or keep it all when you're happy." ):
184+ return
185+ sess = session .start (d )
186+ session .export (sess )
187+ self .refresh_banner ()
188+ self .log (f"testing session started: { sess ['id' ]} " )
189+ messagebox .showinfo (APP , "Testing mode is on.\n \n Backup: "
190+ f"{ session .default_bak_path (sess )} " )
191+
192+ def testing_commit (self ):
193+ sess = session .active ()
194+ if not sess :
195+ messagebox .showinfo (APP , "No testing session is running." )
196+ return
197+ s = session .summary (sess )
198+ if not messagebox .askyesno (
199+ APP , f"Keep all { s ['operations' ]} operations "
200+ f"({ s ['moves' ]} moves, { s ['tags' ]} tag edits)?\n \n "
201+ "The combined backup file is deleted. Individual operations "
202+ "stay in History and can still be undone one at a time." ):
203+ return
204+ session .commit (sess , log = self .log )
205+ self .refresh_banner ()
206+ self .tab_history .refresh ()
207+ messagebox .showinfo (APP , "Changes kept. Testing mode is off." )
208+
209+ def testing_revert (self ):
210+ sess = session .active ()
211+ if not sess :
212+ messagebox .showinfo (APP , "No testing session is running." )
213+ return
214+ s = session .summary (sess )
215+ if not messagebox .askyesno (
216+ APP , f"Undo everything in this session?\n \n "
217+ f"{ s ['moves' ]} moves and { s ['tags' ]} tag edits will be reversed, "
218+ "putting your collection back as it was when testing started." ):
219+ return
220+
221+ def work (progress , log ):
222+ return session .revert_active (log = log )
223+
224+ def done (res ):
225+ ok , fail = res
226+ self .refresh_banner ()
227+ self .tab_history .refresh ()
228+ messagebox .showinfo (APP , f"Reverted { ok } operations."
229+ + (f"\n { fail } failed." if fail else "" ))
230+ self .scan ()
231+
232+ self .task .run (work , done , "Undoing session" )
233+
234+ def testing_export (self ):
235+ sess = session .active ()
236+ if not sess :
237+ messagebox .showinfo (APP , "No testing session is running." )
238+ return
239+ p = filedialog .asksaveasfilename (
240+ title = "Save Sortero backup" , defaultextension = ".bak" ,
241+ initialfile = f"sortero-{ sess ['id' ]} .bak" ,
242+ filetypes = [("Sortero backup" , "*.bak" )])
243+ if p :
244+ session .export (sess , p )
245+ self .log (f"backup saved: { p } " )
246+ messagebox .showinfo (APP , f"Backup saved to\n { p } " )
247+
248+ def testing_load (self ):
249+ p = filedialog .askopenfilename (title = "Load a Sortero backup" ,
250+ filetypes = [("Sortero backup" , "*.bak" ),
251+ ("All files" , "*" )])
252+ if not p :
253+ return
254+ try :
255+ data = session .load (p )
256+ except Exception as e :
257+ messagebox .showerror (APP , str (e ))
258+ return
259+ d = session .describe (data )
260+ import time as _t
261+ when = _t .strftime ("%Y-%m-%d %H:%M" , _t .localtime (d ["started" ])) if d ["started" ] else "unknown"
262+ if not messagebox .askyesno (
263+ APP , f"Undo everything in this backup?\n \n "
264+ f"Recorded: { when } \n Collection: { d ['root' ]} \n "
265+ f"{ d ['operations' ]} operations — { d ['moves' ]} moves, "
266+ f"{ d ['tags' ]} tag edits.\n \n "
267+ "Files are moved back and tags restored to their old values." ):
268+ return
269+
270+ def work (progress , log ):
271+ return session .revert_backup (data , log = log )
272+
273+ def done (res ):
274+ ok , fail = res
275+ self .refresh_banner ()
276+ self .tab_history .refresh ()
277+ messagebox .showinfo (APP , f"Reverted { ok } operations."
278+ + (f"\n { fail } failed." if fail else "" ))
279+ self .scan ()
280+
281+ self .task .run (work , done , "Restoring from backup" )
282+
134283 def _first_run (self ):
135284 def after_wizard (root_dir ):
136285 if root_dir :
@@ -252,6 +401,7 @@ def done(res):
252401 self .tab_import , self .tab_needs , self .tab_playlists ):
253402 t .invalidate ()
254403 self .log (f"Scanned { len (self .recs )} files in { d } " )
404+ self .refresh_banner ()
255405
256406 self .task .run (work , done , "Scanning library" )
257407
0 commit comments