Skip to content

Commit c39e19b

Browse files
committed
feat: Integrate multi-user backup, organize, dedupe, and license scan features
1 parent 5622060 commit c39e19b

File tree

1 file changed

+134
-101
lines changed

1 file changed

+134
-101
lines changed

Windows Backup/windows_backup.py

Lines changed: 134 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -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}\nLog: {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}\nDestination: {target_dir}\nLog: {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}\nLog: {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\nRestart 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.\nLog: {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.\nLog: {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+
# -----------------------------------------------------------------------------
923956
if __name__ == "__main__":
924957
app = QApplication(sys.argv)
925958
window = MainWindow()
926959
window.show()
927-
app.exec()
960+
sys.exit(app.exec())

0 commit comments

Comments
 (0)