34
34
import shutil
35
35
import uuid
36
36
import webbrowser
37
- from time import sleep
37
+ from time import sleep , time
38
+ from datetime import datetime
39
+ from threading import Thread
38
40
from uuid import uuid4
41
+ import zipfile
39
42
40
43
import openshot # Python module for libopenshot (required video editing module installed separately)
41
44
from PyQt5 .QtCore import (
@@ -487,6 +490,9 @@ def save_project(self, file_path):
487
490
s = app .get_settings ()
488
491
app .updates .save_history (app .project , s .get ("history-limit" ))
489
492
493
+ # Save recovery file first
494
+ self .save_recovery (file_path )
495
+
490
496
# Save project to file
491
497
app .project .save (file_path )
492
498
@@ -502,6 +508,72 @@ def save_project(self, file_path):
502
508
log .error ("Couldn't save project %s" , file_path , exc_info = 1 )
503
509
QMessageBox .warning (self , _ ("Error Saving Project" ), str (ex ))
504
510
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
+
505
577
def open_project (self , file_path , clear_thumbnails = True ):
506
578
""" Open a project from a file path, and refresh the screen """
507
579
@@ -682,7 +754,6 @@ def auto_save_project(self):
682
754
import time
683
755
684
756
app = get_app ()
685
- s = app .get_settings ()
686
757
687
758
# Get current filepath (if any)
688
759
file_path = app .project .current_filepath
@@ -694,40 +765,6 @@ def auto_save_project(self):
694
765
# Append .osp if needed
695
766
if not file_path .endswith (".osp" ):
696
767
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 )
731
768
732
769
# Save project
733
770
log .info ("Auto save project file: %s" , file_path )
@@ -2795,7 +2832,6 @@ def load_recent_menu(self):
2795
2832
2796
2833
def time_ago_string (self , timestamp ):
2797
2834
""" Returns a friendly time difference string for the given timestamp. """
2798
- from datetime import datetime
2799
2835
delta = datetime .now () - datetime .fromtimestamp (timestamp )
2800
2836
seconds = delta .total_seconds ()
2801
2837
@@ -2813,27 +2849,33 @@ def time_ago_string(self, timestamp):
2813
2849
2814
2850
def load_restore_menu (self ):
2815
2851
""" 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
2826
2853
2827
2854
# Add Restore Previous Version menu (after Open File)
2828
2855
if not self .restore_menu :
2829
2856
# Create a new restore menu
2830
2857
self .restore_menu = self .menuFile .addMenu (QIcon .fromTheme ("edit-undo" ), _ ("Recovery" ))
2858
+ self .restore_menu .aboutToShow .connect (self .populate_restore_menu )
2831
2859
self .menuFile .insertMenu (self .actionRecoveryProjects , self .restore_menu )
2832
- else :
2833
- # Clear the existing children
2834
- self .restore_menu .clear ()
2835
2860
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
+
2837
2879
# Show just a placeholder menu, if we have no recovery files
2838
2880
if not recovery_files :
2839
2881
self .restore_menu .addAction (_ ("No Previous Versions Available" )).setDisabled (True )
@@ -2856,19 +2898,37 @@ def load_restore_menu(self):
2856
2898
continue
2857
2899
2858
2900
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 ()
2861
2903
current_filepath = app .project .current_filepath if app .project else None
2904
+ _ = get_app ()._tr
2862
2905
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 )
2872
2932
2873
2933
def remove_recent_project (self , file_path ):
2874
2934
"""Remove a project from the Recent menu if OpenShot can't find it"""
0 commit comments