Skip to content

Commit 18739a6

Browse files
committed
Adding recovery menu items to restore previous project versions. Also, saving new recovery projects on ALL saves. Moving saving recovery to a separate thread to not block the UI. Zipping up all recovery projects to save space, and adding new schedule to keep a certain # of recent vs historical project files.
1 parent eaaa286 commit 18739a6

File tree

1 file changed

+122
-62
lines changed

1 file changed

+122
-62
lines changed

src/windows/main_window.py

+122-62
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@
3434
import shutil
3535
import uuid
3636
import webbrowser
37-
from time import sleep
37+
from time import sleep, time
38+
from datetime import datetime
39+
from threading import Thread
3840
from uuid import uuid4
41+
import zipfile
3942

4043
import openshot # Python module for libopenshot (required video editing module installed separately)
4144
from PyQt5.QtCore import (
@@ -487,6 +490,9 @@ def save_project(self, file_path):
487490
s = app.get_settings()
488491
app.updates.save_history(app.project, s.get("history-limit"))
489492

493+
# Save recovery file first
494+
self.save_recovery(file_path)
495+
490496
# Save project to file
491497
app.project.save(file_path)
492498

@@ -502,6 +508,72 @@ def save_project(self, file_path):
502508
log.error("Couldn't save project %s", file_path, exc_info=1)
503509
QMessageBox.warning(self, _("Error Saving Project"), str(ex))
504510

511+
def save_recovery(self, file_path):
512+
"""Saves the project and manages recovery files based on configured limits."""
513+
app = get_app()
514+
s = app.get_settings()
515+
max_files = s.get("recovery-limit")
516+
daily_limit = int(max_files * 0.7)
517+
historical_limit = max_files - daily_limit # Remaining for previous days
518+
519+
folder_path, file_name = os.path.split(file_path)
520+
file_name, file_ext = os.path.splitext(file_name)
521+
522+
timestamp = int(time())
523+
recovery_filename = f"{timestamp}-{file_name}.zip"
524+
recovery_path = os.path.join(info.RECOVERY_PATH, recovery_filename)
525+
526+
def create_recovery():
527+
try:
528+
with zipfile.ZipFile(recovery_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
529+
zipf.write(file_path, os.path.basename(file_path))
530+
log.debug(f"Zipped recovery file created: {recovery_path}")
531+
self.manage_recovery_files(daily_limit, historical_limit, file_name)
532+
except Exception as e:
533+
log.error(f"Failed to create zipped recovery file {recovery_path}: {e}")
534+
535+
# Run the recovery process in a thread
536+
Thread(target=create_recovery, daemon=True).start()
537+
538+
def manage_recovery_files(self, daily_limit, historical_limit, file_name):
539+
"""Ensures recovery files adhere to the configured daily and historical limits."""
540+
recovery_files = sorted(
541+
((f, os.path.getmtime(os.path.join(info.RECOVERY_PATH, f)))
542+
for f in os.listdir(info.RECOVERY_PATH) if f.endswith(".zip") or f.endswith(".osp")),
543+
key=lambda x: x[1],
544+
reverse=True
545+
)
546+
547+
project_recovery_files = [
548+
(f, mod_time) for f, mod_time in recovery_files
549+
if f"-{file_name}" in f
550+
]
551+
552+
daily_files = []
553+
historical_files = set()
554+
retained_files = set()
555+
now = datetime.now()
556+
557+
for f, mod_time in project_recovery_files:
558+
mod_datetime = datetime.fromtimestamp(mod_time)
559+
560+
if mod_datetime.date() == now.date() and len(daily_files) < daily_limit:
561+
daily_files.append(f)
562+
retained_files.add(f)
563+
elif mod_datetime.date() < now.date() and len(historical_files) < historical_limit:
564+
if mod_datetime.date() not in historical_files:
565+
historical_files.add(mod_datetime.date())
566+
retained_files.add(f)
567+
568+
for f, _ in project_recovery_files:
569+
if f not in retained_files:
570+
file_path = os.path.join(info.RECOVERY_PATH, f)
571+
try:
572+
os.unlink(file_path)
573+
log.info(f"Deleted excess recovery file: {file_path}")
574+
except Exception as e:
575+
log.error(f"Failed to delete file {file_path}: {e}")
576+
505577
def open_project(self, file_path, clear_thumbnails=True):
506578
""" Open a project from a file path, and refresh the screen """
507579

@@ -682,7 +754,6 @@ def auto_save_project(self):
682754
import time
683755

684756
app = get_app()
685-
s = app.get_settings()
686757

687758
# Get current filepath (if any)
688759
file_path = app.project.current_filepath
@@ -694,40 +765,6 @@ def auto_save_project(self):
694765
# Append .osp if needed
695766
if not file_path.endswith(".osp"):
696767
file_path = "%s.osp" % file_path
697-
folder_path, file_name = os.path.split(file_path)
698-
file_name, file_ext = os.path.splitext(file_name)
699-
700-
# Make copy of unsaved project file in 'recovery' folder
701-
recover_path_with_timestamp = os.path.join(
702-
info.RECOVERY_PATH, "%d-%s.osp" % (int(time.time()), file_name))
703-
if os.path.exists(file_path):
704-
shutil.copy(file_path, recover_path_with_timestamp)
705-
else:
706-
log.warning(
707-
"Existing project *.osp file not found during recovery process: %s",
708-
file_path)
709-
710-
# Find any recovery file older than X auto-saves
711-
old_backup_files = []
712-
backup_file_count = 0
713-
for backup_filename in reversed(sorted(os.listdir(info.RECOVERY_PATH))):
714-
if ".osp" in backup_filename:
715-
backup_file_count += 1
716-
if backup_file_count > s.get("recovery-limit"):
717-
old_backup_files.append(os.path.join(info.RECOVERY_PATH, backup_filename))
718-
719-
# Delete recovery files which are 'too old'
720-
for backup_filepath in old_backup_files:
721-
try:
722-
if os.path.exists(backup_filepath):
723-
os.unlink(backup_filepath)
724-
log.info(f"Deleted backup file: {backup_filepath}")
725-
else:
726-
log.warning(f"File not found: {backup_filepath}")
727-
except PermissionError:
728-
log.warning(f"Permission denied: {backup_filepath}. Unable to delete.")
729-
except Exception as e:
730-
log.error(f"Error deleting file {backup_filepath}: {e}", exc_info=True)
731768

732769
# Save project
733770
log.info("Auto save project file: %s", file_path)
@@ -2795,7 +2832,6 @@ def load_recent_menu(self):
27952832

27962833
def time_ago_string(self, timestamp):
27972834
""" Returns a friendly time difference string for the given timestamp. """
2798-
from datetime import datetime
27992835
delta = datetime.now() - datetime.fromtimestamp(timestamp)
28002836
seconds = delta.total_seconds()
28012837

@@ -2813,27 +2849,33 @@ def time_ago_string(self, timestamp):
28132849

28142850
def load_restore_menu(self):
28152851
""" Clear and load the list of restore version menu items """
2816-
recovery_dir = info.RECOVERY_PATH
2817-
_ = get_app()._tr # Get translation function
2818-
app = get_app() # Get the application instance
2819-
2820-
# Get list of recovery files in the directory that match the current project
2821-
current_filepath = app.project.current_filepath if app.project else None
2822-
recovery_files = [
2823-
f for f in os.listdir(recovery_dir)
2824-
if f.endswith(".osp") and current_filepath and f.split("-", 1)[1].startswith(os.path.basename(current_filepath).replace(".osp", ""))
2825-
]
2852+
_ = get_app()._tr
28262853

28272854
# Add Restore Previous Version menu (after Open File)
28282855
if not self.restore_menu:
28292856
# Create a new restore menu
28302857
self.restore_menu = self.menuFile.addMenu(QIcon.fromTheme("edit-undo"), _("Recovery"))
2858+
self.restore_menu.aboutToShow.connect(self.populate_restore_menu)
28312859
self.menuFile.insertMenu(self.actionRecoveryProjects, self.restore_menu)
2832-
else:
2833-
# Clear the existing children
2834-
self.restore_menu.clear()
28352860

2836-
# Add recovery files to menu
2861+
def populate_restore_menu(self):
2862+
"""Clear and re-Add the restore project menu items as needed"""
2863+
app = get_app()
2864+
_ = get_app()._tr
2865+
current_filepath = app.project.current_filepath if app.project else None
2866+
2867+
# Clear the existing children
2868+
self.restore_menu.clear()
2869+
2870+
# Get a list of recovery files matching the current project
2871+
recovery_files = []
2872+
if current_filepath:
2873+
recovery_dir = info.RECOVERY_PATH
2874+
recovery_files = [
2875+
f for f in os.listdir(recovery_dir)
2876+
if (f.endswith(".osp") or f.endswith(".zip")) and "-" in f and current_filepath and f.split("-", 1)[1].startswith(os.path.basename(current_filepath).replace(".osp", ""))
2877+
]
2878+
28372879
# Show just a placeholder menu, if we have no recovery files
28382880
if not recovery_files:
28392881
self.restore_menu.addAction(_("No Previous Versions Available")).setDisabled(True)
@@ -2856,19 +2898,37 @@ def load_restore_menu(self):
28562898
continue
28572899

28582900
def restore_version_clicked(self, file_path):
2859-
""" Handle restoring a previous version when a menu item is clicked """
2860-
app = get_app() # Get the application instance
2901+
"""Restore a previous project file from the recovery folder"""
2902+
app = get_app()
28612903
current_filepath = app.project.current_filepath if app.project else None
2904+
_ = get_app()._tr
28622905

2863-
if current_filepath:
2864-
# Copy the recovery file to the current project folder without overwriting existing project
2865-
project_folder = os.path.dirname(current_filepath)
2866-
new_file_path = os.path.join(project_folder, os.path.basename(file_path))
2867-
log.info(f"Recover project from {file_path} to {new_file_path}")
2868-
shutil.copy(file_path, new_file_path)
2869-
2870-
# Emit signal to open the copied project file
2871-
self.OpenProjectSignal.emit(new_file_path)
2906+
try:
2907+
# Rename the original project file
2908+
recovered_filename = os.path.splitext(os.path.basename(current_filepath))[0] + f"-{int(time())}-backup.osp"
2909+
recovered_filepath = os.path.join(os.path.dirname(current_filepath), recovered_filename)
2910+
if os.path.exists(current_filepath):
2911+
shutil.move(current_filepath, recovered_filepath)
2912+
log.info(f"Backup current project to: {recovered_filepath}")
2913+
2914+
# Unzip if the selected recovery file is a .zip file
2915+
if file_path.endswith(".zip"):
2916+
with zipfile.ZipFile(file_path, 'r') as zipf:
2917+
# Extract over top original project *.osp file
2918+
zipf.extractall(os.path.dirname(current_filepath))
2919+
extracted_files = zipf.namelist()
2920+
if len(extracted_files) != 1:
2921+
raise ValueError("Unexpected number of files in recovery zip.")
2922+
else:
2923+
# Replace the original *.osp project file with the recovery file *.osp
2924+
shutil.copyfile(file_path, current_filepath)
2925+
log.info(f"Recovery file `{file_path}` restored to: `{current_filepath}`")
2926+
2927+
# Open the recovered project
2928+
self.OpenProjectSignal.emit(current_filepath)
2929+
2930+
except Exception as ex:
2931+
log.error(f"Error recovering project from `{file_path}` to `{current_filepath}`: {ex}", exc_info=True)
28722932

28732933
def remove_recent_project(self, file_path):
28742934
"""Remove a project from the Recent menu if OpenShot can't find it"""

0 commit comments

Comments
 (0)