Code

Export and import now include journals (incompatible with export < 0.7)
authorrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Fri, 2 Apr 2004 05:58:45 +0000 (05:58 +0000)
committerrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Fri, 2 Apr 2004 05:58:45 +0000 (05:58 +0000)
Need to check setting of activity in RDBMS imports.

Metakit import is quite possibly very busted in setjournal() - I didn't
even try to figure how to *clear the previous journal* for the journal
being imported.

git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@2246 57a73879-2fb5-44c3-a270-3262357dd7e2

CHANGES.txt
roundup/admin.py
roundup/backends/back_anydbm.py
roundup/backends/back_metakit.py
roundup/backends/rdbms_common.py

index 0b321a824549bd066482f130b6c6329322870eee..e04a1cc5296c4027f40e45d297100d20a7e1bd2e 100644 (file)
@@ -8,6 +8,7 @@ Fixed:
   places (thanks Toby Sargeant)
 - MySQL and Postgresql use BOOL/BOOLEAN for Boolean types
 - OTK generation was busted (thanks Stuart D. Gathman)
+- export and import now include journals (incompatible with export < 0.7)
 
 
 2004-03-27 0.7.0b2
index e89f8643df326727cf15b1142a8be69d564fa09b..6c3502e5062b61a93fc2da9ed140c5c3bfb676ba 100644 (file)
@@ -16,7 +16,7 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: admin.py,v 1.63 2004-03-21 23:39:08 richard Exp $
+# $Id: admin.py,v 1.64 2004-04-02 05:58:43 richard Exp $
 
 '''Administration commands for maintaining Roundup trackers.
 '''
@@ -1029,8 +1029,10 @@ Command help:
         # do all the classes specified
         for classname in classes:
             cl = self.get_class(classname)
+
             f = open(os.path.join(dir, classname+'.csv'), 'w')
             writer = rcsv.writer(f, rcsv.colon_separated)
+
             properties = cl.getprops()
             propnames = properties.keys()
             propnames.sort()
@@ -1038,15 +1040,18 @@ Command help:
             fields.append('is retired')
             writer.writerow(fields)
 
-            # all nodes for this class (not using list() 'cos it doesn't
-            # include retired nodes)
-
-            for nodeid in self.db.getclass(classname).getnodeids():
-                # get the regular props
-                writer.writerow (cl.export_list(propnames, nodeid))
+            # all nodes for this class
+            for nodeid in cl.getnodeids():
+                writer.writerow(cl.export_list(propnames, nodeid))
 
             # close this file
             f.close()
+
+            # export the journals
+            jf = open(os.path.join(dir, classname+'-journals.csv'), 'w')
+            journals = rcsv.writer(jf, rcsv.colon_separated)
+            map(journals.writerow, cl.export_journals())
+            jf.close()
         return 0
 
     def do_import(self, args):
@@ -1054,8 +1059,8 @@ Command help:
         Import a database from the directory containing CSV files, one per
         class to import.
 
-        The files must define the same properties as the class (including having
-        a "header" line with those property names.)
+        The files must define the same properties as the class (including
+        having a "header" line with those property names.)
 
         The imported nodes will have the same nodeid as defined in the
         import file, thus replacing any existing content.
@@ -1071,33 +1076,37 @@ Command help:
         from roundup import hyperdb
 
         for file in os.listdir(args[0]):
+            classname, ext = os.path.splitext(file)
             # we only care about CSV files
-            if not file.endswith('.csv'):
+            if ext != '.csv' or classname.endswith('-journals'):
                 continue
 
-            f = open(os.path.join(args[0], file))
-
-            # get the classname
-            classname = os.path.splitext(file)[0]
+            cl = self.get_class(classname)
 
             # ensure that the properties and the CSV file headings match
-            cl = self.get_class(classname)
+            f = open(os.path.join(args[0], file))
             reader = rcsv.reader(f, rcsv.colon_separated)
             file_props = None
             maxid = 1
-
             # loop through the file and create a node for each entry
             for r in reader:
                 if file_props is None:
                     file_props = r
                     continue
-
                 # do the import and figure the current highest nodeid
                 maxid = max(maxid, int(cl.import_list(file_props, r)))
+            f.close()
+
+            # import the journals
+            f = open(os.path.join(args[0], classname + '-journals.csv'))
+            reader = rcsv.reader(f, rcsv.colon_separated)
+            cl.import_journals(reader)
+            f.close()
 
             # set the id counter
             print 'setting', classname, maxid+1
             self.db.setid(classname, str(maxid+1))
+
         return 0
 
     def do_pack(self, args):
index 6fb115d5ae1da2555e7cec64177affc3060259cf..0d3d15f0a9ac98479678267208b2b5738339272c 100644 (file)
@@ -15,7 +15,7 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-#$Id: back_anydbm.py,v 1.139 2004-03-19 04:47:59 richard Exp $
+#$Id: back_anydbm.py,v 1.140 2004-04-02 05:58:43 richard Exp $
 '''This module defines a backend that saves the hyperdatabase in a
 database chosen by anydbm. It is guaranteed to always be available in python
 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
@@ -487,6 +487,14 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         self.transactions.append((self.doSaveJournal, (classname, nodeid,
             action, params, creator, creation)))
 
+    def setjournal(self, classname, nodeid, journal):
+        '''Set the journal to the "journal" list.'''
+        if __debug__:
+            print >>hyperdb.DEBUG, 'setjournal', (self, classname, nodeid,
+                journal)
+        self.transactions.append((self.doSetJournal, (classname, nodeid,
+            journal)))
+
     def getjournal(self, classname, nodeid):
         ''' get the journal for id
 
@@ -685,6 +693,17 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
         db[nodeid] = marshal.dumps(l)
 
+    def doSetJournal(self, classname, nodeid, journal):
+        l = []
+        for nodeid, journaldate, journaltag, action, params in journal:
+            # serialise the parameters now if necessary
+            if isinstance(params, type({})):
+                if action in ('set', 'create'):
+                    params = self.serialise(classname, params)
+            l.append((nodeid, journaldate, journaltag, action, params))
+        db = self.getCachedJournalDB(classname)
+        db[nodeid] = marshal.dumps(l)
+
     def doDestroyNode(self, classname, nodeid):
         if __debug__:
             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
@@ -926,103 +945,6 @@ class Class(hyperdb.Class):
 
         return newid
 
-    def export_list(self, propnames, nodeid):
-        ''' Export a node - generate a list of CSV-able data in the order
-            specified by propnames for the given node.
-        '''
-        properties = self.getprops()
-        l = []
-        for prop in propnames:
-            proptype = properties[prop]
-            value = self.get(nodeid, prop)
-            # "marshal" data where needed
-            if value is None:
-                pass
-            elif isinstance(proptype, hyperdb.Date):
-                value = value.get_tuple()
-            elif isinstance(proptype, hyperdb.Interval):
-                value = value.get_tuple()
-            elif isinstance(proptype, hyperdb.Password):
-                value = str(value)
-            l.append(repr(value))
-
-        # append retired flag
-        l.append(repr(self.is_retired(nodeid)))
-
-        return l
-
-    def import_list(self, propnames, proplist):
-        ''' Import a node - all information including "id" is present and
-            should not be sanity checked. Triggers are not triggered. The
-            journal should be initialised using the "creator" and "created"
-            information.
-
-            Return the nodeid of the node imported.
-        '''
-        if self.db.journaltag is None:
-            raise DatabaseError, 'Database open read-only'
-        properties = self.getprops()
-
-        # make the new node's property map
-        d = {}
-        newid = None
-        for i in range(len(propnames)):
-            # Figure the property for this column
-            propname = propnames[i]
-
-            # Use eval to reverse the repr() used to output the CSV
-            value = eval(proplist[i])
-
-            # "unmarshal" where necessary
-            if propname == 'id':
-                newid = value
-                continue
-            elif propname == 'is retired':
-                # is the item retired?
-                if int(value):
-                    d[self.db.RETIRED_FLAG] = 1
-                continue
-            elif value is None:
-                d[propname] = None
-                continue
-
-            prop = properties[propname]
-            if isinstance(prop, hyperdb.Date):
-                value = date.Date(value)
-            elif isinstance(prop, hyperdb.Interval):
-                value = date.Interval(value)
-            elif isinstance(prop, hyperdb.Password):
-                pwd = password.Password()
-                pwd.unpack(value)
-                value = pwd
-            d[propname] = value
-
-        # get a new id if necessary
-        if newid is None:
-            newid = self.db.newid(self.classname)
-
-        # add the node and journal
-        self.db.addnode(self.classname, newid, d)
-
-        # extract the journalling stuff and nuke it
-        if d.has_key('creator'):
-            creator = d['creator']
-            del d['creator']
-        else:
-            creator = None
-        if d.has_key('creation'):
-            creation = d['creation']
-            del d['creation']
-        else:
-            creation = None
-        if d.has_key('activity'):
-            del d['activity']
-        if d.has_key('actor'):
-            del d['actor']
-        self.db.addjournal(self.classname, newid, 'create', {}, creator,
-            creation)
-        return newid
-
     def get(self, nodeid, propname, default=_marker, cache=1):
         '''Get the value of a property on an existing node of this class.
 
@@ -1993,6 +1915,147 @@ class Class(hyperdb.Class):
         for react in self.reactors[action]:
             react(self.db, self, nodeid, oldvalues)
 
+    #
+    # import / export support
+    #
+    def export_list(self, propnames, nodeid):
+        ''' Export a node - generate a list of CSV-able data in the order
+            specified by propnames for the given node.
+        '''
+        properties = self.getprops()
+        l = []
+        for prop in propnames:
+            proptype = properties[prop]
+            value = self.get(nodeid, prop)
+            # "marshal" data where needed
+            if value is None:
+                pass
+            elif isinstance(proptype, hyperdb.Date):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Interval):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Password):
+                value = str(value)
+            l.append(repr(value))
+
+        # append retired flag
+        l.append(repr(self.is_retired(nodeid)))
+
+        return l
+
+    def import_list(self, propnames, proplist):
+        ''' Import a node - all information including "id" is present and
+            should not be sanity checked. Triggers are not triggered. The
+            journal should be initialised using the "creator" and "created"
+            information.
+
+            Return the nodeid of the node imported.
+        '''
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        properties = self.getprops()
+
+        # make the new node's property map
+        d = {}
+        newid = None
+        for i in range(len(propnames)):
+            # Figure the property for this column
+            propname = propnames[i]
+
+            # Use eval to reverse the repr() used to output the CSV
+            value = eval(proplist[i])
+
+            # "unmarshal" where necessary
+            if propname == 'id':
+                newid = value
+                continue
+            elif propname == 'is retired':
+                # is the item retired?
+                if int(value):
+                    d[self.db.RETIRED_FLAG] = 1
+                continue
+            elif value is None:
+                d[propname] = None
+                continue
+
+            prop = properties[propname]
+            if isinstance(prop, hyperdb.Date):
+                value = date.Date(value)
+            elif isinstance(prop, hyperdb.Interval):
+                value = date.Interval(value)
+            elif isinstance(prop, hyperdb.Password):
+                pwd = password.Password()
+                pwd.unpack(value)
+                value = pwd
+            d[propname] = value
+
+        # get a new id if necessary
+        if newid is None:
+            newid = self.db.newid(self.classname)
+
+        # add the node and journal
+        self.db.addnode(self.classname, newid, d)
+        return newid
+
+    def export_journals(self):
+        '''Export a class's journal - generate a list of lists of
+        CSV-able data:
+
+            nodeid, date, user, action, params
+
+        No heading here - the columns are fixed.
+        '''
+        properties = self.getprops()
+        r = []
+        for nodeid in self.getnodeids():
+            for nodeid, date, user, action, params in self.history(nodeid):
+                date = date.get_tuple()
+                if action == 'set':
+                    for propname, value in params.items():
+                        prop = properties[propname]
+                        # make sure the params are eval()'able
+                        if value is None:
+                            pass
+                        elif isinstance(prop, Date):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Interval):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Password):
+                            value = str(value)
+                        params[propname] = value
+                l = [nodeid, date, user, action, params]
+                r.append(map(repr, l))
+        return r
+
+    def import_journals(self, entries):
+        '''Import a class's journal.
+        
+        Uses setjournal() to set the journal for each item.'''
+        properties = self.getprops()
+        d = {}
+        for l in entries:
+            l = map(eval, l)
+            nodeid, date, user, action, params = l
+            r = d.setdefault(nodeid, [])
+            if action == 'set':
+                for propname, value in params.items():
+                    prop = properties[propname]
+                    if value is None:
+                        pass
+                    elif isinstance(prop, Date):
+                        value = date.Date(value)
+                    elif isinstance(prop, Interval):
+                        value = date.Interval(value)
+                    elif isinstance(prop, Password):
+                        pwd = password.Password()
+                        pwd.unpack(value)
+                        value = pwd
+                    params[propname] = value
+            r.append((nodeid, date.Date(date), user, action, params))
+
+        for nodeid, l in d.items():
+            self.db.setjournal(self.classname, nodeid, l)
+
 class FileClass(Class, hyperdb.FileClass):
     '''This class defines a large chunk of data. To support this, it has a
        mandatory String property "content" which is typically saved off
index 17a47e9e02ab3c0af1a167289e912d6fe70df8b0..1de4f530ece1d3b4e572c164e2805e1307f1b6b7 100755 (executable)
@@ -1,4 +1,4 @@
-# $Id: back_metakit.py,v 1.69 2004-03-24 05:39:47 richard Exp $
+# $Id: back_metakit.py,v 1.70 2004-04-02 05:58:45 richard Exp $
 '''Metakit backend for Roundup, originally by Gordon McMillan.
 
 Known Current Bugs:
@@ -211,6 +211,21 @@ class _Database(hyperdb.Database, roundupdb.Database):
                          action=action,
                          user = creator,
                          params = marshal.dumps(params))
+
+    def setjournal(self, tablenm, nodeid, journal):
+        '''Set the journal to the "journal" list.'''
+        tblid = self.tables.find(name=tablenm)
+        if tblid == -1:
+            tblid = self.tables.append(name=tablenm)
+        for nodeid, date, user, action, params in journal:
+            # tableid:I,nodeid:I,date:I,user:I,action:I,params:B
+            self.hist.append(tableid=tblid,
+                             nodeid=int(nodeid),
+                             date=date,
+                             action=action,
+                             user=user,
+                             params=marshal.dumps(params))
+
     def getjournal(self, tablenm, nodeid):
         ''' get the journal for id
         '''
@@ -1434,108 +1449,6 @@ class Class(hyperdb.Class):
                 self.db.indexer.add_text((self.classname, nodeid, prop),
                                 str(self.get(nodeid, prop)))
 
-    def export_list(self, propnames, nodeid):
-        ''' Export a node - generate a list of CSV-able data in the order
-            specified by propnames for the given node.
-        '''
-        properties = self.getprops()
-        l = []
-        for prop in propnames:
-            proptype = properties[prop]
-            value = self.get(nodeid, prop)
-            # "marshal" data where needed
-            if value is None:
-                pass
-            elif isinstance(proptype, hyperdb.Date):
-                value = value.get_tuple()
-            elif isinstance(proptype, hyperdb.Interval):
-                value = value.get_tuple()
-            elif isinstance(proptype, hyperdb.Password):
-                value = str(value)
-            l.append(repr(value))
-
-        # append retired flag
-        l.append(repr(self.is_retired(nodeid)))
-
-        return l
-        
-    def import_list(self, propnames, proplist):
-        ''' Import a node - all information including "id" is present and
-            should not be sanity checked. Triggers are not triggered. The
-            journal should be initialised using the "creator" and "creation"
-            information.
-
-            Return the nodeid of the node imported.
-        '''
-        if self.db.journaltag is None:
-            raise hyperdb.DatabaseError, 'Database open read-only'
-        properties = self.getprops()
-
-        d = {}
-        view = self.getview(READWRITE)
-        for i in range(len(propnames)):
-            value = eval(proplist[i])
-            if not value:
-                continue
-
-            propname = propnames[i]
-            if propname == 'id':
-                newid = value = int(value)
-            elif propname == 'is retired':
-                # is the item retired?
-                if int(value):
-                    d['_isdel'] = 1
-                continue
-            elif value is None:
-                d[propname] = None
-                continue
-
-            prop = properties[propname]
-            if isinstance(prop, hyperdb.Date):
-                value = int(calendar.timegm(value))
-            elif isinstance(prop, hyperdb.Interval):
-                value = date.Interval(value).serialise()
-            elif isinstance(prop, hyperdb.Number):
-                value = int(value)
-            elif isinstance(prop, hyperdb.Boolean):
-                value = int(value)
-            elif isinstance(prop, hyperdb.Link) and value:
-                value = int(value)
-            elif isinstance(prop, hyperdb.Multilink):
-                # we handle multilinks separately
-                continue
-            d[propname] = value
-
-        # possibly make a new node
-        if not d.has_key('id'):
-            d['id'] = newid = self.maxid
-            self.maxid += 1
-
-        # save off the node
-        view.append(d)
-
-        # fix up multilinks
-        ndx = view.find(id=newid)
-        row = view[ndx]
-        for i in range(len(propnames)):
-            value = eval(proplist[i])
-            propname = propnames[i]
-            if propname == 'is retired':
-                continue
-            prop = properties[propname]
-            if not isinstance(prop, hyperdb.Multilink):
-                continue
-            sv = getattr(row, propname)
-            for entry in value:
-                sv.append((int(entry),))
-
-        self.db.dirty = 1
-        creator = d.get('creator', 0)
-        creation = d.get('creation', 0)
-        self.db.addjournal(self.classname, str(newid), _CREATE, {}, creator,
-            creation)
-        return newid
-
     # --- used by Database
     def _commit(self):
         ''' called post commit of the DB.
@@ -1644,6 +1557,166 @@ class Class(hyperdb.Class):
         tablename = "_%s.%s"%(self.classname, self.key)
         return self.db._db.view("_%s" % tablename).ordered(1)
 
+    #
+    # import / export
+    #
+    def export_list(self, propnames, nodeid):
+        ''' Export a node - generate a list of CSV-able data in the order
+            specified by propnames for the given node.
+        '''
+        properties = self.getprops()
+        l = []
+        for prop in propnames:
+            proptype = properties[prop]
+            value = self.get(nodeid, prop)
+            # "marshal" data where needed
+            if value is None:
+                pass
+            elif isinstance(proptype, hyperdb.Date):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Interval):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Password):
+                value = str(value)
+            l.append(repr(value))
+
+        # append retired flag
+        l.append(repr(self.is_retired(nodeid)))
+
+        return l
+        
+    def import_list(self, propnames, proplist):
+        ''' Import a node - all information including "id" is present and
+            should not be sanity checked. Triggers are not triggered. The
+            journal should be initialised using the "creator" and "creation"
+            information.
+
+            Return the nodeid of the node imported.
+        '''
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+        properties = self.getprops()
+
+        d = {}
+        view = self.getview(READWRITE)
+        for i in range(len(propnames)):
+            value = eval(proplist[i])
+            if not value:
+                continue
+
+            propname = propnames[i]
+            if propname == 'id':
+                newid = value = int(value)
+            elif propname == 'is retired':
+                # is the item retired?
+                if int(value):
+                    d['_isdel'] = 1
+                continue
+            elif value is None:
+                d[propname] = None
+                continue
+
+            prop = properties[propname]
+            if isinstance(prop, hyperdb.Date):
+                value = int(calendar.timegm(value))
+            elif isinstance(prop, hyperdb.Interval):
+                value = date.Interval(value).serialise()
+            elif isinstance(prop, hyperdb.Number):
+                value = int(value)
+            elif isinstance(prop, hyperdb.Boolean):
+                value = int(value)
+            elif isinstance(prop, hyperdb.Link) and value:
+                value = int(value)
+            elif isinstance(prop, hyperdb.Multilink):
+                # we handle multilinks separately
+                continue
+            d[propname] = value
+
+        # possibly make a new node
+        if not d.has_key('id'):
+            d['id'] = newid = self.maxid
+            self.maxid += 1
+
+        # save off the node
+        view.append(d)
+
+        # fix up multilinks
+        ndx = view.find(id=newid)
+        row = view[ndx]
+        for i in range(len(propnames)):
+            value = eval(proplist[i])
+            propname = propnames[i]
+            if propname == 'is retired':
+                continue
+            prop = properties[propname]
+            if not isinstance(prop, hyperdb.Multilink):
+                continue
+            sv = getattr(row, propname)
+            for entry in value:
+                sv.append((int(entry),))
+
+        self.db.dirty = 1
+        return newid
+
+    def export_journals(self):
+        '''Export a class's journal - generate a list of lists of
+        CSV-able data:
+
+            nodeid, date, user, action, params
+
+        No heading here - the columns are fixed.
+        '''
+        properties = self.getprops()
+        r = []
+        for nodeid in self.getnodeids():
+            for nodeid, date, user, action, params in self.history(nodeid):
+                date = date.get_tuple()
+                if action == 'set':
+                    for propname, value in params.items():
+                        prop = properties[propname]
+                        # make sure the params are eval()'able
+                        if value is None:
+                            pass
+                        elif isinstance(prop, Date):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Interval):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Password):
+                            value = str(value)
+                        params[propname] = value
+                l = [nodeid, date, user, action, params]
+                r.append(map(repr, l))
+        return r
+
+    def import_journals(self, entries):
+        '''Import a class's journal.
+        
+        Uses setjournal() to set the journal for each item.'''
+        properties = self.getprops()
+        d = {}
+        for l in entries:
+            l = map(eval, l)
+            nodeid, date, user, action, params = l
+            r = d.setdefault(nodeid, [])
+            if action == 'set':
+                for propname, value in params.items():
+                    prop = properties[propname]
+                    if value is None:
+                        pass
+                    elif isinstance(prop, Date):
+                        value = date.Date(value)
+                    elif isinstance(prop, Interval):
+                        value = date.Interval(value)
+                    elif isinstance(prop, Password):
+                        pwd = password.Password()
+                        pwd.unpack(value)
+                        value = pwd
+                    params[propname] = value
+            r.append((nodeid, date.Date(date), user, action, params))
+
+        for nodeid, l in d.items():
+            self.db.setjournal(self.classname, nodeid, l)
+
 def _fetchML(sv):
     l = []
     for row in sv:
index 684a04c8be8032af53e008f688a0a1227a7462f8..657b477cd4bdaa3f82f733ec537f9d2c1154b9c6 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: rdbms_common.py,v 1.87 2004-03-31 07:25:14 richard Exp $
+# $Id: rdbms_common.py,v 1.88 2004-04-02 05:58:45 richard Exp $
 ''' Relational database (SQL) backend common code.
 
 Basics:
@@ -679,10 +679,7 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
                     self.arg, self.arg)
                 self.sql(sql, (entry, nodeid))
 
-        # make sure we do the commit-time extra stuff for this node
-        self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
-
-    def setnode(self, classname, nodeid, values, multilink_changes):
+    def setnode(self, classname, nodeid, values, multilink_changes={}):
         ''' Change the specified node.
         '''
         if __debug__:
@@ -736,7 +733,27 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
                 print >>hyperdb.DEBUG, 'setnode', (self, sql, vals)
             self.cursor.execute(sql, vals)
 
-        # now the fun bit, updating the multilinks ;)
+        # we're probably coming from an import, not a change
+        if not multilink_changes:
+            for name in mls:
+                prop = props[name]
+                value = values[name]
+
+                t = '%s_%s'%(classname, name)
+
+                # clear out previous values for this node
+                # XXX numeric ids
+                self.sql('delete from %s where nodeid=%s'%(t, self.arg),
+                        (nodeid,))
+
+                # insert the values for this node
+                for entry in values[name]:
+                    sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
+                        self.arg, self.arg)
+                    # XXX numeric ids
+                    self.sql(sql, (entry, nodeid))
+
+        # we have multilink changes to apply
         for col, (add, remove) in multilink_changes.items():
             tn = '%s_%s'%(classname, col)
             if add:
@@ -752,9 +769,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
                     # XXX numeric ids
                     self.sql(sql, (int(nodeid), int(removeid)))
 
-        # make sure we do the commit-time extra stuff for this node
-        self.transactions.append((self.doSaveNode, (classname, nodeid, values)))
-
     sql_to_hyperdb_value = {
         hyperdb.String : str,
         hyperdb.Date   : lambda x:date.Date(str(x).replace(' ', '.')),
@@ -887,11 +901,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
             'retire' -- 'params' is None
         '''
-        # serialise the parameters now if necessary
-        if isinstance(params, type({})):
-            if action in ('set', 'create'):
-                params = self.serialise(classname, params)
-
         # handle supply of the special journalling parameters (usually
         # supplied on importing an existing database)
         if creator:
@@ -904,7 +913,7 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             journaldate = date.Date()
 
         # create the journal entry
-        cols = ','.join('nodeid date tag action params'.split())
+        cols = 'nodeid,date,tag,action,params'
 
         if __debug__:
             print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
@@ -913,6 +922,22 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         self.save_journal(classname, cols, nodeid, journaldate,
             journaltag, action, params)
 
+    def setjournal(self, classname, nodeid, journal):
+        '''Set the journal to the "journal" list.'''
+        # clear out any existing entries
+        self.sql('delete from %s__journal where nodeid=%s'%(classname,
+            self.arg), (nodeid,))
+
+        # create the journal entry
+        cols = 'nodeid,date,tag,action,params'
+
+        for nodeid, journaldate, journaltag, action, params in journal:
+            if __debug__:
+                print >>hyperdb.DEBUG, 'setjournal', (nodeid, journaldate,
+                    journaltag, action, params)
+            self.save_journal(classname, cols, nodeid, journaldate,
+                journaltag, action, params)
+
     def getjournal(self, classname, nodeid):
         ''' get the journal for id
         '''
@@ -1024,12 +1049,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         # clear the cache
         self.clearCache()
 
-    def doSaveNode(self, classname, nodeid, node):
-        ''' dummy that just generates a reindex event
-        '''
-        # return the classname, nodeid so we reindex this content
-        return (classname, nodeid)
-
     def sql_close(self):
         if __debug__:
             print >>hyperdb.DEBUG, '+++ close database connection +++'
@@ -1253,114 +1272,6 @@ class Class(hyperdb.Class):
         # XXX numeric ids
         return str(newid)
 
-    def export_list(self, propnames, nodeid):
-        ''' Export a node - generate a list of CSV-able data in the order
-            specified by propnames for the given node.
-        '''
-        properties = self.getprops()
-        l = []
-        for prop in propnames:
-            proptype = properties[prop]
-            value = self.get(nodeid, prop)
-            # "marshal" data where needed
-            if value is None:
-                pass
-            elif isinstance(proptype, hyperdb.Date):
-                value = value.get_tuple()
-            elif isinstance(proptype, hyperdb.Interval):
-                value = value.get_tuple()
-            elif isinstance(proptype, hyperdb.Password):
-                value = str(value)
-            l.append(repr(value))
-        l.append(repr(self.is_retired(nodeid)))
-        return l
-
-    def import_list(self, propnames, proplist):
-        ''' Import a node - all information including "id" is present and
-            should not be sanity checked. Triggers are not triggered. The
-            journal should be initialised using the "creator" and "created"
-            information.
-
-            Return the nodeid of the node imported.
-        '''
-        if self.db.journaltag is None:
-            raise DatabaseError, 'Database open read-only'
-        properties = self.getprops()
-
-        # make the new node's property map
-        d = {}
-        retire = 0
-        newid = None
-        for i in range(len(propnames)):
-            # Use eval to reverse the repr() used to output the CSV
-            value = eval(proplist[i])
-
-            # Figure the property for this column
-            propname = propnames[i]
-
-            # "unmarshal" where necessary
-            if propname == 'id':
-                newid = value
-                continue
-            elif propname == 'is retired':
-                # is the item retired?
-                if int(value):
-                    retire = 1
-                continue
-            elif value is None:
-                d[propname] = None
-                continue
-
-            prop = properties[propname]
-            if value is None:
-                # don't set Nones
-                continue
-            elif isinstance(prop, hyperdb.Date):
-                value = date.Date(value)
-            elif isinstance(prop, hyperdb.Interval):
-                value = date.Interval(value)
-            elif isinstance(prop, hyperdb.Password):
-                pwd = password.Password()
-                pwd.unpack(value)
-                value = pwd
-            d[propname] = value
-
-        # get a new id if necessary
-        if newid is None:
-            newid = self.db.newid(self.classname)
-
-        # add the node and journal
-        self.db.addnode(self.classname, newid, d)
-
-        # retire?
-        if retire:
-            # use the arg for __retired__ to cope with any odd database type
-            # conversion (hello, sqlite)
-            sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
-                self.db.arg, self.db.arg)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
-            self.db.cursor.execute(sql, (1, newid))
-
-        # extract the extraneous journalling gumpf and nuke it
-        if d.has_key('creator'):
-            creator = d['creator']
-            del d['creator']
-        else:
-            creator = None
-        if d.has_key('creation'):
-            creation = d['creation']
-            del d['creation']
-        else:
-            creation = None
-        if d.has_key('activity'):
-            del d['activity']
-        if d.has_key('actor'):
-            del d['actor']
-        self.db.addjournal(self.classname, newid, 'create', {}, creator,
-            creation)
-        return newid
-
     _marker = []
     def get(self, nodeid, propname, default=_marker, cache=1):
         '''Get the value of a property on an existing node of this class.
@@ -2246,6 +2157,159 @@ class Class(hyperdb.Class):
         for react in self.reactors[action]:
             react(self.db, self, nodeid, oldvalues)
 
+    #
+    # import / export support
+    #
+    def export_list(self, propnames, nodeid):
+        ''' Export a node - generate a list of CSV-able data in the order
+            specified by propnames for the given node.
+        '''
+        properties = self.getprops()
+        l = []
+        for prop in propnames:
+            proptype = properties[prop]
+            value = self.get(nodeid, prop)
+            # "marshal" data where needed
+            if value is None:
+                pass
+            elif isinstance(proptype, hyperdb.Date):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Interval):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Password):
+                value = str(value)
+            l.append(repr(value))
+        l.append(repr(self.is_retired(nodeid)))
+        return l
+
+    def import_list(self, propnames, proplist):
+        ''' Import a node - all information including "id" is present and
+            should not be sanity checked. Triggers are not triggered. The
+            journal should be initialised using the "creator" and "created"
+            information.
+
+            Return the nodeid of the node imported.
+        '''
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        properties = self.getprops()
+
+        # make the new node's property map
+        d = {}
+        retire = 0
+        newid = None
+        for i in range(len(propnames)):
+            # Use eval to reverse the repr() used to output the CSV
+            value = eval(proplist[i])
+
+            # Figure the property for this column
+            propname = propnames[i]
+
+            # "unmarshal" where necessary
+            if propname == 'id':
+                newid = value
+                continue
+            elif propname == 'is retired':
+                # is the item retired?
+                if int(value):
+                    retire = 1
+                continue
+            elif value is None:
+                d[propname] = None
+                continue
+
+            prop = properties[propname]
+            if value is None:
+                # don't set Nones
+                continue
+            elif isinstance(prop, hyperdb.Date):
+                value = date.Date(value)
+            elif isinstance(prop, hyperdb.Interval):
+                value = date.Interval(value)
+            elif isinstance(prop, hyperdb.Password):
+                pwd = password.Password()
+                pwd.unpack(value)
+                value = pwd
+            d[propname] = value
+
+        # get a new id if necessary
+        if newid is None or not self.hasnode(newid):
+            newid = self.db.newid(self.classname)
+            self.db.addnode(self.classname, newid, d)
+        else:
+            # update
+            self.db.setnode(self.classname, newid, d)
+
+        # retire?
+        if retire:
+            # use the arg for __retired__ to cope with any odd database type
+            # conversion (hello, sqlite)
+            sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
+                self.db.arg, self.db.arg)
+            if __debug__:
+                print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
+            self.db.cursor.execute(sql, (1, newid))
+        return newid
+
+    def export_journals(self):
+        '''Export a class's journal - generate a list of lists of
+        CSV-able data:
+
+            nodeid, date, user, action, params
+
+        No heading here - the columns are fixed.
+        '''
+        properties = self.getprops()
+        r = []
+        for nodeid in self.getnodeids():
+            for nodeid, date, user, action, params in self.history(nodeid):
+                date = date.get_tuple()
+                if action == 'set':
+                    for propname, value in params.items():
+                        prop = properties[propname]
+                        # make sure the params are eval()'able
+                        if value is None:
+                            pass
+                        elif isinstance(prop, Date):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Interval):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Password):
+                            value = str(value)
+                        params[propname] = value
+                l = [nodeid, date, user, action, params]
+                r.append(map(repr, l))
+        return r
+
+    def import_journals(self, entries):
+        '''Import a class's journal.
+        
+        Uses setjournal() to set the journal for each item.'''
+        properties = self.getprops()
+        d = {}
+        for l in entries:
+            l = map(eval, l)
+            nodeid, jdate, user, action, params = l
+            r = d.setdefault(nodeid, [])
+            if action == 'set':
+                for propname, value in params.items():
+                    prop = properties[propname]
+                    if value is None:
+                        pass
+                    elif isinstance(prop, Date):
+                        value = date.Date(value)
+                    elif isinstance(prop, Interval):
+                        value = date.Interval(value)
+                    elif isinstance(prop, Password):
+                        pwd = password.Password()
+                        pwd.unpack(value)
+                        value = pwd
+                    params[propname] = value
+            r.append((nodeid, date.Date(jdate), user, action, params))
+
+        for nodeid, l in d.items():
+            self.db.setjournal(self.classname, nodeid, l)
+
 class FileClass(Class, hyperdb.FileClass):
     '''This class defines a large chunk of data. To support this, it has a
        mandatory String property "content" which is typically saved off