@@ -780,148 +780,181 @@ def run_deduplication(self):
780780 report_btn = action_box .addButton ("Save Report" , QMessageBox .ButtonRole .ActionRole )
781781 cancel_btn = action_box .addButton (QMessageBox .StandardButton .Cancel )
782782 action_box .exec ()
783+
784+ log_file = new_log_file (directory , "dedupe" )
783785 clicked = action_box .clickedButton ()
784786 if clicked == delete_btn :
787+ total_deleted = 0
785788 for _ , paths in duplicates .items ():
786789 for path in paths [1 :]:
787790 try :
788791 os .remove (path )
792+ total_deleted += 1
793+ log_line (log_file , f"DELETED { path } " )
789794 except Exception as e :
790- QMessageBox . warning ( self , "Delete Error" , f"Failed to delete { path } : { e } " )
791- QMessageBox .information (self , "Done" , "Duplicates deleted. " )
795+ log_line ( log_file , f"ERROR deleting { path } : { e } " )
796+ QMessageBox .information (self , "Done" , f "Duplicates deleted: { total_deleted } \n Log: { log_file } " )
792797 elif clicked == move_btn :
793798 target_dir = QFileDialog .getExistingDirectory (self , "Select Directory to Move Duplicates To" )
794799 if not target_dir :
795800 return
801+ moved = 0
796802 for _ , paths in duplicates .items ():
797803 for path in paths [1 :]:
798804 try :
799- target_path = os .path .join (target_dir , os .path .basename (path ))
805+ base = os .path .basename (path )
806+ target_path = os .path .join (target_dir , base )
807+ if os .path .exists (target_path ):
808+ stem , ext = os .path .splitext (base )
809+ counter = 1
810+ while os .path .exists (target_path ):
811+ target_path = os .path .join (target_dir , f"{ stem } _{ counter } { ext } " )
812+ counter += 1
800813 os .rename (path , target_path )
814+ moved += 1
815+ log_line (log_file , f"MOVED { path } -> { target_path } " )
801816 except Exception as e :
802- QMessageBox . warning ( self , "Move Error" , f"Failed to move { path } : { e } " )
803- QMessageBox .information (self , "Done" , "Duplicates moved. " )
817+ log_line ( log_file , f"ERROR moving { path } : { e } " )
818+ QMessageBox .information (self , "Done" , f "Duplicates moved: { moved } \n Destination: { target_dir } \n Log: { log_file } " )
804819 elif clicked == report_btn :
805820 report_path , _ = QFileDialog .getSaveFileName (self , "Save Duplicates Report" , "duplicates_report.json" , "JSON Files (*.json);;All Files (*)" )
806821 if not report_path :
807822 return
808823 self .generate_report (duplicates , report_path )
809- QMessageBox .information (self , "Done" , f"Report saved to { report_path } " )
824+ log_line (log_file , f"REPORT saved to { report_path } " )
825+ QMessageBox .information (self , "Done" , f"Report saved to { report_path } \n Log: { log_file } " )
810826 else :
811827 QMessageBox .information (self , "No Action" , "No action taken." )
812828
813-
814- def get_file_hash (self , filepath ):
815- """Return the MD5 hash of a file."""
816- hasher = hashlib .md5 ()
817- with open (filepath , 'rb' ) as f :
818- buf = f .read ()
819- hasher .update (buf )
820- return hasher .hexdigest ()
821-
822829 def find_duplicates (self , directory , min_size = 0 , file_extensions = None ):
823- """Find duplicate files in a directory, with optional file type filtering."""
824830 hashes = {}
825831 duplicates = {}
826- for dirpath , dirnames , filenames in os .walk (directory ):
832+ visited = set ()
833+ for dirpath , dirnames , filenames in os .walk (directory , followlinks = False ):
834+ real_dir = os .path .realpath (dirpath )
835+ if real_dir in visited :
836+ continue
837+ visited .add (real_dir )
827838 for filename in filenames :
828839 if file_extensions and not filename .lower ().endswith (tuple (file_extensions )):
829- continue # Skip files that don't match the extensions
830-
840+ continue
831841 filepath = os .path .join (dirpath , filename )
832- if os .path .getsize (filepath ) >= min_size :
833- file_hash = self .get_file_hash (filepath )
834- if file_hash in hashes :
835- duplicates .setdefault (file_hash , []).append (filepath )
836- # Also ensure the original file is in the duplicates list
837- if hashes [file_hash ] not in duplicates [file_hash ]:
838- duplicates [file_hash ].append (hashes [file_hash ])
839- else :
840- hashes [file_hash ] = filepath
841-
842+ try :
843+ size_ok = os .path .getsize (filepath ) >= min_size
844+ except Exception :
845+ size_ok = False
846+ if not size_ok :
847+ continue
848+ try :
849+ file_hash = chunked_file_hash (filepath )
850+ except Exception :
851+ continue
852+ if file_hash in hashes :
853+ duplicates .setdefault (file_hash , []).append (filepath )
854+ if hashes [file_hash ] not in duplicates [file_hash ]:
855+ duplicates [file_hash ].append (hashes [file_hash ])
856+ else :
857+ hashes [file_hash ] = filepath
842858 return {k : v for k , v in duplicates .items () if len (v ) > 1 }
843-
844-
859+
845860 def generate_report (self , duplicates , report_path ):
846- """Generate a report of duplicate files in JSON format."""
847- with open (report_path , 'w' ) as report_file :
861+ with open (report_path , 'w' , encoding = 'utf-8' ) as report_file :
848862 json .dump (duplicates , report_file , indent = 4 )
849- print (f"Report generated: { report_path } " )
850863
851- def check_source_folders (self ):
852- """Enable buttons only if source exists and has files; set tooltips if disabled"""
853- for btn , name in self .backup_buttons :
854- src , patterns , dest = self .backup_sources [name ]
855- folder_exists = os .path .exists (src )
856- has_files = folder_exists and any (
857- any (f .lower ().endswith (p .lstrip ("*" ).lower ()) for f in files for p in patterns )
858- for _ , _ , files in os .walk (src )
859- )
860-
861- if folder_exists and has_files :
862- if not btn .isEnabled ():
863- btn .setEnabled (True )
864- btn .setToolTip (f"Backup files from { src } → { dest } " )
864+ # ------------------------------------------------------------------
865+ # License keys scanning (uses LicenseScanWorker, elevation prompt on Windows)
866+ # ------------------------------------------------------------------
867+ def scan_for_license_keys (self ):
868+ # If Windows, check admin and offer elevation
869+ if sys .platform .startswith ('win' ):
870+ if not self ._is_user_admin ():
871+ resp = QMessageBox .question (
872+ self ,
873+ "Administrator Privileges Required" ,
874+ "Scanning the registry works best with administrator rights.\n \n Restart this app as Administrator now?" ,
875+ QMessageBox .StandardButton .Yes | QMessageBox .StandardButton .No ,
876+ )
877+ if resp == QMessageBox .StandardButton .Yes :
878+ self ._restart_as_admin ()
879+ return # Current process will continue only if elevation failed
880+ # else continue without admin (limited scan)
881+
882+ # Start worker
883+ root_for_logs = self .backup_location or os .getcwd ()
884+ self .lic_worker = LicenseScanWorker (root_for_logs )
885+
886+ # Progress dialog (we'll use total = number of root paths/files)
887+ self .lic_prog = QProgressDialog ("Scanning for license keys..." , "Cancel" , 0 , 100 , self )
888+ self .lic_prog .setWindowModality (Qt .WindowModality .ApplicationModal )
889+ self .lic_prog .setAutoClose (False )
890+ self .lic_prog .setAutoReset (False )
891+ self .lic_prog .setMinimumDuration (0 )
892+ self .lic_prog .setValue (0 )
893+
894+ def on_progress (done , total , label ):
895+ total = max (1 , total )
896+ pct = int ((done / total ) * 100 )
897+ pct = min (100 , max (0 , pct ))
898+ self .lic_prog .setValue (pct )
899+ self .lic_prog .setLabelText (f"{ label } — { pct } %" )
900+
901+ def on_finished (results : dict , log_file : str ):
902+ self .lic_prog .close ()
903+ if results :
904+ # Offer to save report
905+ save_path , _ = QFileDialog .getSaveFileName (self , "Save License Keys Report" , "licenses.json" , "JSON Files (*.json);;All Files (*)" )
906+ if save_path :
907+ with open (save_path , 'w' , encoding = 'utf-8' ) as f :
908+ json .dump (results , f , indent = 4 )
909+ QMessageBox .information (self , "License Scan Complete" , f"Found { len (results )} potential keys.\n Log: { log_file } " )
865910 else :
866- if btn .isEnabled ():
867- btn .setEnabled (False )
868- if not folder_exists :
869- btn .setToolTip (f"Source folder not found: { src } " )
870- else :
871- btn .setToolTip (f"No files found in { src } to backup" )
911+ QMessageBox .information (self , "License Scan Complete" , f"No obvious license keys found.\n Log: { log_file } " )
872912
913+ def on_failed (msg : str ):
914+ self .lic_prog .close ()
915+ QMessageBox .warning (self , "License Scan Failed" , msg )
873916
874- def start_backup (self , source_dir , patterns , destination_name ):
875- import getpass
876- import os
877- from PySide6 .QtWidgets import QProgressDialog , QMessageBox
878- user_root = os .path .expandvars (r"C:\Users" ) if sys .platform .startswith ("win" ) else "/Users"
879- current_user = getpass .getuser ()
880- users = self .selected_users if self .selected_users else [current_user ]
881- for user in users :
882- user_source_dir = source_dir .replace (get_user_path (), os .path .join (user_root , user ))
883- if not self .backup_location :
884- QMessageBox .warning (self , "Error" , "Please set a backup location first." )
885- return
886- if not os .path .exists (user_source_dir ):
887- QMessageBox .warning (self , "Error" , f"Source folder not found: { user_source_dir } " )
888- continue
889- destination_root = os .path .join (self .backup_location , user , destination_name )
890- os .makedirs (destination_root , exist_ok = True )
891- self .progress_dialog = QProgressDialog (f"Backing up { destination_name } for { user } ..." , "Cancel" , 0 , 100 , self )
892- self .progress_dialog .setWindowModality (Qt .WindowModality .WindowModal )
893- self .progress_dialog .setMinimumWidth (420 )
894- self .progress_dialog .setValue (0 )
895- self .worker = BackupWorker (user_source_dir , destination_root , patterns )
896- self .worker .progress_update .connect (self .update_progress )
897- self .worker .finished .connect (self .backup_finished )
898- self .progress_dialog .canceled .connect (self .worker .cancel )
899- total_files , _ = self .worker .count_files ()
900- self .progress_dialog .setMaximum (total_files if total_files > 0 else 1 )
901- self .worker .start ()
902-
903- def update_progress (self , value , filename , file_size , copied_size , speed ):
904- copied_mb = copied_size / (1024 * 1024 )
905- total_mb = self .worker .total_size / (1024 * 1024 ) if self .worker .total_size else 0
906- speed_mb = speed / (1024 * 1024 )
907- self .progress_dialog .setValue (value )
908- self .progress_dialog .setLabelText (
909- f"Copying: { filename } \n "
910- f"File size: { file_size / 1024 :.1f} KB\n "
911- f"Copied: { copied_mb :.2f} / { total_mb :.2f} MB\n "
912- f"Speed: { speed_mb :.2f} MB/s"
913- )
917+ def on_cancel ():
918+ self .lic_worker .cancel ()
914919
915- def backup_finished (self , files_copied , success , message ):
916- self .progress_dialog .close ()
917- if success :
918- QMessageBox .information (self , "Success" , f"{ message } \n { files_copied } files backed up." )
919- else :
920- QMessageBox .warning (self , "Backup" , message )
920+ self .lic_worker .progress .connect (on_progress )
921+ self .lic_worker .finished .connect (on_finished )
922+ self .lic_worker .failed .connect (on_failed )
923+ self .lic_prog .canceled .connect (on_cancel )
921924
925+ self .lic_worker .start ()
926+ self .lic_prog .show ()
922927
928+ # ---- Elevation helpers (Windows) ----
929+ def _is_user_admin (self ) -> bool :
930+ if not sys .platform .startswith ('win' ):
931+ return False
932+ try :
933+ import ctypes
934+ return bool (ctypes .windll .shell32 .IsUserAnAdmin ())
935+ except Exception :
936+ return False
937+
938+ def _restart_as_admin (self ):
939+ try :
940+ import ctypes , sys
941+ params = ' ' .join ([f'"{ a } "' for a in sys .argv ])
942+ # Attempt to relaunch the same interpreter with the same script and args
943+ r = ctypes .windll .shell32 .ShellExecuteW (None , "runas" , sys .executable , params , None , 1 )
944+ if r <= 32 :
945+ QMessageBox .warning (self , "Elevation" , "Failed to restart as Administrator. You can still run a limited scan." )
946+ else :
947+ # Successfully launched elevated instance; exit current
948+ QApplication .instance ().quit ()
949+ except Exception as e :
950+ QMessageBox .warning (self , "Elevation" , f"Could not request elevation: { e } " )
951+
952+
953+ # -----------------------------------------------------------------------------
954+ # Entrypoint
955+ # -----------------------------------------------------------------------------
923956if __name__ == "__main__" :
924957 app = QApplication (sys .argv )
925958 window = MainWindow ()
926959 window .show ()
927- app .exec ()
960+ sys . exit ( app .exec () )
0 commit comments