Files
gramps/gramps/plugins/db/bsddb/undoredo.py
2019-05-12 10:21:16 +01:00

549 lines
16 KiB
Python

#
# Gramps - a GTK+/GNOME based genealogy program
#
# Copyright (C) 2004-2006 Donald N. Allingham
# Copyright (C) 2011 Tim G L Lyons
#
# 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 2 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
"""
Exports the DbUndo class for managing Gramps transactions
undos and redos.
"""
#-------------------------------------------------------------------------
#
# Standard python modules
#
#-------------------------------------------------------------------------
import time, os
import pickle
from collections import deque
try:
from bsddb3 import db
except:
# FIXME: make this more abstract to deal with other backends
class db:
DBRunRecoveryError = 0
DBAccessError = 0
DBPageNotFoundError = 0
DBInvalidArgError = 0
from gramps.gen.const import GRAMPS_LOCALE as glocale
_ = glocale.translation.gettext
#-------------------------------------------------------------------------
#
# Gramps modules
#
#-------------------------------------------------------------------------
from gramps.gen.db.dbconst import (REFERENCE_KEY, KEY_TO_NAME_MAP, TXNDEL,
TXNADD, TXNUPD)
from . import BSDDBTxn
from gramps.gen.errors import DbError
#-------------------------------------------------------------------------
#
# Local Constants
#
#-------------------------------------------------------------------------
DBERRS = (db.DBRunRecoveryError, db.DBAccessError,
db.DBPageNotFoundError, db.DBInvalidArgError)
_SIGBASE = ('person', 'family', 'source', 'event', 'media',
'place', 'repository', 'reference', 'note', 'tag', 'citation')
#-------------------------------------------------------------------------
#
# DbUndo class
#
#-------------------------------------------------------------------------
class DbUndo:
"""
Base class for the Gramps undo/redo manager. Needs to be subclassed
for use with a real backend.
"""
__slots__ = ('undodb', 'db', 'mapbase', 'undo_history_timestamp',
'txn', 'undoq', 'redoq')
def __init__(self, grampsdb):
"""
Class constructor. Set up main instance variables
"""
self.db = grampsdb
self.undoq = deque()
self.redoq = deque()
self.undo_history_timestamp = time.time()
self.txn = None
# N.B. the databases have to be in the same order as the numbers in
# xxx_KEY in gen/db/dbconst.py
self.mapbase = (
self.db.person_map,
self.db.family_map,
self.db.source_map,
self.db.event_map,
self.db.media_map,
self.db.place_map,
self.db.repository_map,
self.db.reference_map,
self.db.note_map,
self.db.tag_map,
self.db.citation_map,
)
def clear(self):
"""
Clear the undo/redo list (but not the backing storage)
"""
self.undoq.clear()
self.redoq.clear()
self.undo_history_timestamp = time.time()
self.txn = None
def __enter__(self, value):
"""
Context manager method to establish the context
"""
self.open(value)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""
Context manager method to finish the context
"""
if exc_type is None:
self.close()
return exc_type is None
def open(self, value):
"""
Open the backing storage. Needs to be overridden in the derived
class.
"""
raise NotImplementedError
def close(self):
"""
Close the backing storage. Needs to be overridden in the derived
class.
"""
raise NotImplementedError
def append(self, value):
"""
Add a new entry on the end. Needs to be overridden in the derived
class.
"""
raise NotImplementedError
def __getitem__(self, index):
"""
Returns an entry by index number. Needs to be overridden in the
derived class.
"""
raise NotImplementedError
def __setitem__(self, index, value):
"""
Set an entry to a value. Needs to be overridden in the derived class.
"""
raise NotImplementedError
def __len__(self):
"""
Returns the number of entries. Needs to be overridden in the derived
class.
"""
raise NotImplementedError
def commit(self, txn, msg):
"""
Commit the transaction to the undo/redo database. "txn" should be
an instance of Gramps transaction class
"""
txn.set_description(msg)
txn.timestamp = time.time()
self.undoq.append(txn)
def undo(self, update_history=True):
"""
Undo a previously committed transaction
"""
if self.db.readonly or self.undo_count == 0:
return False
return self.__undo(update_history)
def redo(self, update_history=True):
"""
Redo a previously committed, then undone, transaction
"""
if self.db.readonly or self.redo_count == 0:
return False
return self.__redo(update_history)
def undoredo(func):
"""
Decorator function to wrap undo and redo operations within a bsddb
transaction. It also catches bsddb errors and raises an exception
as appropriate
"""
def try_(self, *args, **kwargs):
try:
with BSDDBTxn(self.db.env) as txn:
self.txn = self.db.txn = txn.txn
status = func(self, *args, **kwargs)
if not status:
txn.abort()
self.db.txn = None
return status
except DBERRS as msg:
self.db._log_error()
raise DbError(msg)
return try_
@undoredo
def __undo(self, update_history=True):
"""
Access the last committed transaction, and revert the data to the
state before the transaction was committed.
"""
txn = self.undoq.pop()
self.redoq.append(txn)
transaction = txn
subitems = transaction.get_recnos(reverse=True)
# sigs[obj_type][trans_type]
sigs = [[[] for trans_type in range(3)] for key in range(11)]
# Process all records in the transaction
for record_id in subitems:
(key, trans_type, handle, old_data, new_data) = \
pickle.loads(self.undodb[record_id])
if key == REFERENCE_KEY:
self.undo_reference(old_data, handle, self.mapbase[key])
else:
self.undo_data(old_data, handle, self.mapbase[key])
handle = handle.decode('utf-8')
sigs[key][trans_type].append(handle)
# now emit the signals
self.undo_sigs(sigs, True)
# Notify listeners
if self.db.undo_callback:
if self.undo_count > 0:
self.db.undo_callback(_("_Undo %s")
% self.undoq[-1].get_description())
else:
self.db.undo_callback(None)
if self.db.redo_callback:
self.db.redo_callback(_("_Redo %s")
% transaction.get_description())
if update_history and self.db.undo_history_callback:
self.db.undo_history_callback()
return True
@undoredo
def __redo(self, db=None, update_history=True):
"""
Access the last undone transaction, and revert the data to the state
before the transaction was undone.
"""
txn = self.redoq.pop()
self.undoq.append(txn)
transaction = txn
subitems = transaction.get_recnos()
# sigs[obj_type][trans_type]
sigs = [[[] for trans_type in range(3)] for key in range(11)]
# Process all records in the transaction
for record_id in subitems:
(key, trans_type, handle, old_data, new_data) = \
pickle.loads(self.undodb[record_id])
if key == REFERENCE_KEY:
self.undo_reference(new_data, handle, self.mapbase[key])
else:
self.undo_data(new_data, handle, self.mapbase[key])
handle = handle.decode('utf-8')
sigs[key][trans_type].append(handle)
# now emit the signals
self.undo_sigs(sigs, False)
# Notify listeners
if self.db.undo_callback:
self.db.undo_callback(_("_Undo %s")
% transaction.get_description())
if self.db.redo_callback:
if self.redo_count > 1:
new_transaction = self.redoq[-2]
self.db.redo_callback(_("_Redo %s")
% new_transaction.get_description())
else:
self.db.redo_callback(None)
if update_history and self.db.undo_history_callback:
self.db.undo_history_callback()
return True
def undo_reference(self, data, handle, db_map):
"""
Helper method to undo a reference map entry
"""
try:
if data is None:
db_map.delete(handle, txn=self.txn)
else:
db_map.put(handle, data, txn=self.txn)
except DBERRS as msg:
self.db._log_error()
raise DbError(msg)
def undo_data(self, data, handle, db_map):
"""
Helper method to undo/redo the changes made
"""
try:
if data is None:
db_map.delete(handle, txn=self.txn)
else:
db_map.put(handle, data, txn=self.txn)
except DBERRS as msg:
self.db._log_error()
raise DbError(msg)
def undo_sigs(self, sigs, undo):
"""
Helper method to undo/redo the signals for changes made
We want to do deletes and adds first
Note that if 'undo' we swap emits
"""
for trans_type in [TXNDEL, TXNADD, TXNUPD]:
for obj_type in range(11):
handles = sigs[obj_type][trans_type]
if handles:
if not undo and trans_type == TXNDEL \
or undo and trans_type == TXNADD:
typ = '-delete'
else:
# don't update a handle if its been deleted, and note
# that 'deleted' handles are in the 'add' list if we
# are undoing
handles = [handle for handle in handles
if handle not in
sigs[obj_type][TXNADD if undo else TXNDEL]]
if ((not undo) and trans_type == TXNADD) \
or (undo and trans_type == TXNDEL):
typ = '-add'
else: # TXNUPD
typ = '-update'
if handles:
self.db.emit(KEY_TO_NAME_MAP[obj_type] + typ,
(handles,))
undo_count = property(lambda self:len(self.undoq))
redo_count = property(lambda self:len(self.redoq))
class DbUndoList(DbUndo):
"""
Implementation of the Gramps undo database using a Python list
"""
def __init__(self, grampsdb):
"""
Class constructor
"""
super(DbUndoList, self).__init__(grampsdb)
self.undodb = []
def open(self):
"""
A list does not need to be opened
"""
pass
def close(self):
"""
Close the list by resetting it to empty
"""
self.undodb = []
self.clear()
def append(self, value):
"""
Add an entry on the end of the list
"""
self.undodb.append(value)
return len(self.undodb)-1
def __getitem__(self, index):
"""
Return an item at the specified index
"""
return self.undodb[index]
def __setitem__(self, index, value):
"""
Set an item at the speficied index to the given value
"""
self.undodb[index] = value
def __iter__(self):
"""
Iterator
"""
for item in self.undodb:
yield item
def __len__(self):
"""
Return number of entries in the list
"""
return len(self.undodb)
class DbUndoBSDDB(DbUndo):
"""
Class constructor for Gramps undo/redo database using a bsddb recno
database as the backing store.
"""
def __init__(self, grampsdb, path):
"""
Class constructor
"""
super(DbUndoBSDDB, self).__init__(grampsdb)
self.undodb = db.DB()
self.path = path
def open(self):
"""
Open the undo/redo database
"""
path = self.path
self.undodb.open(path, db.DB_RECNO, db.DB_CREATE)
def close(self):
"""
Close the undo/redo database
"""
self.undodb.close()
self.undodb = None
self.mapbase = None
self.db = None
try:
os.remove(self.path)
except OSError:
pass
self.clear()
def append(self, value):
"""
Add an entry on the end of the database
"""
return self.undodb.append(value)
def __len__(self):
"""
Returns the number of entries in the database
"""
x = self.undodb.stat()['nkeys']
y = len(self.undodb)
assert x == y
return x
def __getitem__(self, index):
"""
Returns the entry stored at the specified index
"""
return self.undodb.get(index)
def __setitem__(self, index, value):
"""
Sets the entry stored at the specified index to the value given.
"""
self.undodb.put(index, value)
def __iter__(self):
"""
Iterator
"""
cursor = self.undodb.cursor()
data = cursor.first()
while data:
yield data
data = next(cursor)
def testundo():
class T:
def __init__(self):
self.msg = ''
self.timetstamp = 0
def set_description(self, msg):
self.msg = msg
class D:
def __init__(self):
self.person_map = {}
self.family_map = {}
self.source_map = {}
self.event_map = {}
self.media_map = {}
self.place_map = {}
self.note_map = {}
self.tag_map = {}
self.repository_map = {}
self.reference_map = {}
print("list tests")
undo = DbUndoList(D())
print(undo.append('foo'))
print(undo.append('bar'))
print(undo[0])
undo[0] = 'foobar'
print(undo[0])
print("len", len(undo))
print("iter")
for data in undo:
print(data)
print()
print("bsddb tests")
undo = DbUndoBSDDB(D(), '/tmp/testundo')
undo.open()
print(undo.append('foo'))
print(undo.append('fo2'))
print(undo.append('fo3'))
print(undo[1])
undo[1] = 'bar'
print(undo[1])
for data in undo:
print(data)
print("len", len(undo))
print("test commit")
undo.commit(T(), msg="test commit")
undo.close()
if __name__ == '__main__':
testundo()