Commit b8f99e6e authored by Jacek Sowiński's avatar Jacek Sowiński

Add possibility for creating backups of todolist

Backups containing whole todolist and archive can be now saved after
execution of each "read-write" command. Furthermore this change creates
base for eventual "revert" command.

Backups are safely stored and indexed in our own JSON-based format which
is compatible with python2.x and python3.x. We also use zlib compression
to minimize size of backup file. Path of the backup file is always
relative to the todo file, so backups from different todo files won't
mix up.

User can configure number of stored backups with new config option -
"backup_count". Any positive number will tell topydo to store that very
number of backups. Setting "backup_count" to 0 will completely turn off
backup functionality.
parent 4936565d
......@@ -6,6 +6,7 @@ default_command = ls
; archive_filename = done.txt
colors = 1
identifiers = linenumber ; or: text
backup_count = 5
[add]
auto_creation_date = 1
......
......@@ -25,6 +25,7 @@ from six import PY2
from six.moves import input
MAIN_OPTS = "ac:d:ht:v"
READ_ONLY_COMMANDS = ('List', 'ListContext', 'ListProject')
def usage():
......@@ -104,6 +105,7 @@ from topydo.commands.SortCommand import SortCommand
from topydo.lib import TodoFile
from topydo.lib import TodoList
from topydo.lib import TodoListBase
from topydo.lib.ChangeSet import ChangeSet
from topydo.lib.Utils import escape_ansi
......@@ -119,6 +121,7 @@ class CLIApplicationBase(object):
self.todolist = TodoList.TodoList([])
self.todofile = None
self.do_archive = True
self.backup = None
def _usage(self):
usage()
......@@ -170,6 +173,9 @@ class CLIApplicationBase(object):
archive_file = TodoFile.TodoFile(config().archive())
archive = TodoListBase.TodoListBase(archive_file.read())
if self.backup:
self.backup.add_archive(archive)
if archive:
command = ArchiveCommand(self.todolist, archive)
command.execute()
......@@ -194,6 +200,11 @@ class CLIApplicationBase(object):
Execute a subcommand with arguments. p_command is a class (not an
object).
"""
cmds_wo_backup = tuple(cmd + 'Command' for cmd in READ_ONLY_COMMANDS
if config().backup_count() > 0 and p_command and not p_command.__module__.endswith(cmds_wo_backup):
call = [p_command.__module__.lower()[16:-7]] + p_args # strip "topydo.commands" and "Command"
self.backup = ChangeSet(self.todolist, p_call=call)
command = p_command(
p_args,
self.todolist,
......@@ -222,7 +233,12 @@ class CLIApplicationBase(object):
if config().keep_sorted():
self._execute(SortCommand, [])
if self.backup:
self.backup.save(self.todolist)
self.todofile.write(self.todolist.print_todos())
self.backup = None
def run(self):
raise NotImplementedError
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2014 - 2015 Bram Schoenmakers <me@bramschoenmakers.nl>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
""" This module serves for managing todo and archive changesets. """
import json
import time
import zlib
from copy import deepcopy
from hashlib import sha1
from os import path
from topydo.lib.Config import config
from topydo.lib.TodoList import TodoList
def hash_todolist(p_todolist):
""" Calculates hash for TodoList.TodoList object. """
todolist_hash = sha1(p_todolist.print_todos().encode('utf-8')).hexdigest()
return todolist_hash
def get_backup_path():
""" Returns full path and filename of backup file """
dirname, filename = path.split(path.splitext(config().todotxt())[0])
filename = '.' + filename + '.bak'
return path.join(dirname, filename)
class ChangeSet(object):
""" Class for operations related with backup management. """
def __init__(self, p_todolist=None, p_archive=None, p_call=[]):
self.todolist = deepcopy(p_todolist)
self.archive = deepcopy(p_archive)
self.timestamp = str(int(time.time()))
self.call = ' '.join(p_call)
try:
self.json_file = open(get_backup_path(), 'r+b')
except IOError:
self.json_file = open(get_backup_path(), 'w+b')
self._read()
def _read(self):
"""
Reads backup file from json_file property and sets backup_dict property
with data decompressed and deserialized from that file. If no usable
data is found backup_dict is set to the empty dict.
"""
self.json_file.seek(0)
try:
data = zlib.decompress(self.json_file.read())
self.backup_dict = json.loads(data.decode('utf-8'))
except (EOFError, zlib.error):
self.backup_dict = {}
def _write(self):
"""
Writes data from backup_dict property in serialized and compressed form
to backup file pointed in json_file property.
"""
self.json_file.seek(0)
self.json_file.truncate()
dump = json.dumps(self.backup_dict)
dump_c = zlib.compress(dump.encode('utf-8'))
self.json_file.write(dump_c)
def add_archive(self, p_archive):
""" Sets deep copy of p_archive as archive attribute. """
self.archive = deepcopy(p_archive)
def add_todolist(self, p_todolist):
""" Sets deep copy of p_todolist as todolist attribute. """
self.todolist = deepcopy(p_todolist)
def save(self, p_todolist):
"""
Saves a tuple with archive, todolist and command with its arguments
into the backup file with unix timestamp as the key. Tuple is then
indexed in backup file with combination of hash calculated from
p_todolist and unix timestamp. Backup file is closed afterwards.
"""
self._trim()
current_hash = hash_todolist(p_todolist)
list_todo = (self.todolist.print_todos()+'\n').splitlines(True)
list_archive = (self.archive.print_todos()+'\n').splitlines(True)
self.backup_dict[self.timestamp] = (list_todo, list_archive, self.call)
index = self._get_index()
index.insert(0, (self.timestamp, current_hash))
self._save_index(index)
self._write()
self.close()
def delete(self, p_timestamp=None):
""" Removes backup from the backup file. """
timestamp = p_timestamp or self.timestamp
index = self._get_index()
try:
del self.backup_dict[timestamp]
index.remove(index[[change[0] for change in index].index(timestamp)])
self._save_index(index)
self._write()
except KeyError:
pass
def _get_index(self):
try:
index = self.backup_dict['index']
except KeyError:
self.backup_dict['index'] = []
index = self.backup_dict['index']
return index
def _save_index(self, p_index):
"""
Saves index of backups supplied in p_index into the backup_file
property with 'index' as the key.
"""
self.backup_dict['index'] = p_index
def _trim(self):
"""
Removes oldest backups that exceed the limit configured in backup_count
option.
"""
index = self._get_index()
backup_limit = config().backup_count() - 1
for changeset in index[backup_limit:]:
self.delete(changeset[0])
def get_backup(self, p_todolist):
"""
Retrieves a backup for p_todolist from backup file and sets todolist,
archive and call attributes to appropriate data from it.
"""
change_hash = hash_todolist(p_todolist)
index = self._get_index()
self.timestamp = index[[change[1] for change in index].index(change_hash)][0]
d = self.backup_dict[self.timestamp]
self.todolist = TodoList(d[0])
self.archive = TodoList(d[1])
self.call = d[2]
def apply(self, p_todolist, p_archive):
""" Applies backup on supplied p_todolist. """
if self.todolist:
p_todolist.replace(self.todolist.todos())
if self.archive:
p_archive.replace(self.archive.todos())
def close(self):
""" Closes backup file. """
self.json_file.close()
......@@ -57,6 +57,7 @@ class _Config:
'filename': 'todo.txt',
'archive_filename': 'done.txt',
'identifiers': 'linenumber',
'backup_count': '5',
# add
'auto_creation_date': '1',
......@@ -139,6 +140,15 @@ class _Config:
def identifiers(self):
return self.cp.get('topydo', 'identifiers')
def backup_count(self):
try:
value = self.cp.getint('topydo', 'backup_count')
if value < 0:
value = 0
return value
except ValueError:
return int(self.defaults['backup_count'])
def list_limit(self):
try:
return self.cp.getint('ls', 'list_limit')
......@@ -256,7 +266,6 @@ class _Config:
except ValueError:
return self.defaults['auto_creation_date'] == '1'
def config(p_path=None, p_overrides=None):
"""
Retrieve the config instance.
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment