Skip to content

Commit ba65314

Browse files
authored
Implement autosave (#851)
1. Pref to Save backup file when user saves manually. Backup file has extension `.bak` 2. Pref to autosave file every N minutes. When autosaved, backup files with extensions `.bk1` and `.bk2` are created. `.bk2` is a copy of the old `.bk1`, and `.bk1` is a copy of the old file, just before the new version is saved. 3. Note that although the user can force a manual save even if they have made no edits, an autosave will not happen if no edits are made. 4. Also, no autosave is done when the currently loaded file has no name, i.e. has never been saved. 5. Default autosave time period is 5 minutes. User can choose between 1 and 60 minutes. Fixes #839
1 parent 21c4c04 commit ba65314

4 files changed

Lines changed: 90 additions & 9 deletions

File tree

src/guiguts/application.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,9 @@ def initialize_preferences(self) -> None:
476476
preferences.set_default(PrefKey.EBOOKMAKER_EPUB3, True)
477477
preferences.set_default(PrefKey.EBOOKMAKER_KINDLE, False)
478478
preferences.set_default(PrefKey.EBOOKMAKER_KF8, False)
479+
preferences.set_default(PrefKey.BACKUPS_ENABLED, True)
480+
preferences.set_default(PrefKey.AUTOSAVE_ENABLED, True)
481+
preferences.set_default(PrefKey.AUTOSAVE_INTERVAL, 5)
479482

480483
# Check all preferences have a default
481484
for pref_key in PrefKey:

src/guiguts/file.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def __init__(
119119
self.page_details = PageDetails()
120120
self.project_dict = ProjectDict()
121121
self.mainwindow: Optional[MainWindow] = None
122+
self.autosave_id = ""
122123

123124
@property
124125
def filename(self) -> str:
@@ -276,28 +277,63 @@ def load_file(self, filename: str) -> None:
276277
self.project_dict.load(filename)
277278
# Load complete, so set filename (including side effects)
278279
self.filename = filename
280+
self.reset_autosave()
279281
# After loading, may need to show image
280282
if preferences.get(PrefKey.AUTO_IMAGE):
281283
self.image_dir_check()
282284
self.auto_image_check()
283285

284-
def save_file(self) -> str:
286+
def save_file(self, autosave: bool = False) -> str:
285287
"""Save the current file.
286288
289+
Args:
290+
autosave: True if method was called via autosave
291+
287292
Returns:
288-
Current filename or None if save is cancelled
293+
Current filename or "" if save is cancelled
289294
"""
290-
if self.filename:
291-
maintext().do_save(self.filename)
292-
self.save_bin(self.filename)
293-
return self.filename
294-
return self.save_as_file()
295+
# If there's no filename, need to do Save As, but if called
296+
# via autosave, do nothing
297+
if not self.filename:
298+
return "" if autosave else self.save_as_file()
299+
# If we have a filename, then need to do appropriate backups
300+
301+
def get_backup_names(ext: str) -> tuple[str, str]:
302+
"""Get backup names for filename and bin file."""
303+
return f"{self.filename}{ext}", f"{bin_name(self.filename)}{ext}"
304+
305+
binfile_name = bin_name(self.filename)
306+
if autosave:
307+
# Don't autosave if nothing changed - just reschedule
308+
if not maintext().is_modified():
309+
self.reset_autosave()
310+
return ""
311+
backup2_file, backup2_bin = get_backup_names(".bk2")
312+
backup1_file, backup1_bin = get_backup_names(".bk1")
313+
if os.path.exists(backup1_file):
314+
os.replace(backup1_file, backup2_file)
315+
if os.path.exists(backup1_bin):
316+
os.replace(backup1_bin, backup2_bin)
317+
if os.path.exists(self.filename):
318+
os.replace(self.filename, backup1_file)
319+
if os.path.exists(binfile_name):
320+
os.replace(binfile_name, backup1_bin)
321+
elif preferences.get(PrefKey.BACKUPS_ENABLED):
322+
backup_file, backup_bin = get_backup_names(".bak")
323+
if os.path.exists(self.filename):
324+
os.replace(self.filename, backup_file)
325+
if os.path.exists(binfile_name):
326+
os.replace(binfile_name, backup_bin)
327+
maintext().do_save(self.filename)
328+
self.save_bin(self.filename)
329+
self.reset_autosave()
330+
return self.filename
295331

296332
def save_as_file(self) -> str:
297333
"""Save current text as new file.
298334
299335
Returns:
300-
Chosen filename or None if save is cancelled
336+
Chosen filename or "" if save is cancelled
301337
"""
302338
# If no current extension, or ".txt" extension, set extension to ".html"
303339
# if it's an HTML file. Similarly for text files, otherwise leave alone.
@@ -384,6 +420,18 @@ def check_save(self) -> bool:
384420
return False
385421
return True
386422

423+
def reset_autosave(self) -> None:
424+
"""Clear any autosave timer, and start a fresh one if the
425+
Preference is turned on."""
426+
if self.autosave_id:
427+
root().after_cancel(self.autosave_id)
428+
self.autosave_id = ""
429+
if preferences.get(PrefKey.AUTOSAVE_ENABLED):
430+
self.autosave_id = root().after(
431+
preferences.get(PrefKey.AUTOSAVE_INTERVAL) * 1000 * 60,
432+
lambda: self.save_file(autosave=True),
433+
)
434+
387435
def load_bin(self, basename: str) -> bool:
388436
"""Load bin file associated with current file.
389437

src/guiguts/misc_dialogs.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import regex as re
1212

13+
from guiguts.file import the_file
1314
from guiguts.maintext import maintext
1415
from guiguts.mainwindow import ScrolledReadOnlyText
1516
from guiguts.preferences import (
@@ -304,7 +305,6 @@ def add_label_spinbox(
304305
PrefKey.TEXT_LINE_SPACING,
305306
"Additional line spacing in text windows",
306307
)
307-
308308
add_label_spinbox(
309309
advance_frame,
310310
1,
@@ -318,6 +318,33 @@ def add_label_spinbox(
318318
variable=PersistentBoolean(PrefKey.HIGHLIGHT_CURSOR_LINE),
319319
).grid(column=0, row=2, sticky="NEW", pady=5)
320320

321+
backup_btn = ttk.Checkbutton(
322+
advance_frame,
323+
text="Keep Backup Before Saving",
324+
variable=PersistentBoolean(PrefKey.BACKUPS_ENABLED),
325+
)
326+
backup_btn.grid(column=0, row=3, sticky="E", pady=(10, 0))
327+
ToolTip(backup_btn, "Backup file will have '.bak' extension")
328+
ttk.Checkbutton(
329+
advance_frame,
330+
text="Enable Auto Save Every",
331+
variable=PersistentBoolean(PrefKey.AUTOSAVE_ENABLED),
332+
command=the_file().reset_autosave,
333+
).grid(column=0, row=4, sticky="E")
334+
spinbox = ttk.Spinbox(
335+
advance_frame,
336+
textvariable=PersistentInt(PrefKey.AUTOSAVE_INTERVAL),
337+
from_=1,
338+
to=60,
339+
width=3,
340+
)
341+
spinbox.grid(column=1, row=4, sticky="EW", padx=5)
342+
ToolTip(
343+
spinbox,
344+
"Autosave your file (with '.bk1', '.bk2' extensions) after this number of minutes",
345+
)
346+
ttk.Label(advance_frame, text="Minutes").grid(column=2, row=4, sticky="EW")
347+
321348
notebook.bind(
322349
"<<NotebookTabChanged>>",
323350
lambda _: preferences.set(

src/guiguts/preferences.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ class PrefKey(StrEnum):
116116
EBOOKMAKER_EPUB3 = auto()
117117
EBOOKMAKER_KINDLE = auto()
118118
EBOOKMAKER_KF8 = auto()
119+
BACKUPS_ENABLED = auto()
120+
AUTOSAVE_ENABLED = auto()
121+
AUTOSAVE_INTERVAL = auto()
119122

120123

121124
class Preferences:

0 commit comments

Comments
 (0)