|
|
| version 1.6, 2007/01/09 18:28:23 | version 1.13, 2007/05/25 15:49:40 |
|---|---|
| Line 4 | Line 4 |
| import string | import string |
| import logging | import logging |
| import sys | import sys |
| import types | |
| import time | |
| from xml import sax | from xml import sax |
| from amara import saxtools | from amara import saxtools |
| Line 17 except: | Line 19 except: |
| fm_ns = 'http://www.filemaker.com/fmpxmlresult' | fm_ns = 'http://www.filemaker.com/fmpxmlresult' |
| version_string = "V0.4 ROC 29.3.2007" | |
| def getTextFromNode(nodename): | def getTextFromNode(nodename): |
| """get the cdata content of a node""" | """get the cdata content of a node""" |
| if nodename is None: | if nodename is None: |
| Line 36 def sql_quote(v): | Line 40 def sql_quote(v): |
| v=string.join(string.split(v,dkey),quote_dict[dkey]) | v=string.join(string.split(v,dkey),quote_dict[dkey]) |
| return "'%s'"%v | return "'%s'"%v |
| def SimpleSearch(curs,query, args=None): | def SimpleSearch(curs,query, args=None, ascii=False): |
| """execute sql query and return data""" | """execute sql query and return data""" |
| logging.debug("executing: "+query) | #logging.debug("executing: "+query) |
| if psyco == 1: | if ascii: |
| # encode all in UTF-8 | |
| query = query.encode("UTF-8") | query = query.encode("UTF-8") |
| #if args is not None: | if args is not None: |
| # args = [ sql_quote(a) for a in args ] | encargs = [] |
| for a in args: | |
| if a is not None: | |
| a = a.encode("UTF-8") | |
| encargs.append(a) | |
| args = encargs | |
| curs.execute(query, args) | curs.execute(query, args) |
| logging.debug("sql done") | #logging.debug("sql done") |
| try: | try: |
| return curs.fetchall() | return curs.fetchall() |
| except: | except: |
| return None | return None |
| class TableColumn: | |
| """simple type for storing sql column name and type""" | |
| def __init__(self, name, type=None): | |
| #print "new tablecolumn(%s,%s)"%(name, type) | |
| self.name = name | |
| self.type = type | |
| def getName(self): | |
| return self.name | |
| def getType(self): | |
| if self.type is not None: | |
| return self.type | |
| else: | |
| return "text" | |
| def __str__(self): | |
| return self.name | |
| class xml_handler: | class xml_handler: |
| def __init__(self,options): | |
| """SAX handler to import FileMaker XML file (FMPXMLRESULT format) into the table. | |
| @param options: dict of options | |
| @param options.dsn: database connection string | |
| @param options.table: name of the table the xml shall be imported into | |
| @param options.filename: xmlfile filename | |
| @param options.update_fields: (optional) list of fields to update; default is to create all fields | |
| @param options.id_field: (optional) field which uniquely identifies an entry for updating purposes. | |
| @param options.sync_mode: (optional) really synchronise, i.e. delete entries not in XML file | |
| @param options.lc_names: (optional) lower case and clean up field names from XML | |
| @param options.keep_fields: (optional) don't add fields to SQL database | |
| @param options.ascii_db: (optional) assume ascii encoding in db | |
| @param options.replace_table: (optional) delete and re-insert data | |
| """ | |
| def __init__(self,dsn,table,update_fields=None,id_field=None,sync_mode=False): | |
| ''' | |
| SAX handler to import FileMaker XML file (FMPXMLRESULT format) into the table. | |
| @param dsn: database connection string | |
| @param table: name of the table the xml shall be imported into | |
| @param filename: xmlfile filename | |
| @param update_fields: (optional) list of fields to update; default is to create all fields | |
| @param id_field: (optional) field which uniquely identifies an entry for updating purposes. | |
| @param sync_mode: (optional) really synchronise, i.e. delete entries not in XML file | |
| ''' | |
| # set up parser | # set up parser |
| self.event = None | self.event = None |
| self.top_dispatcher = { | self.top_dispatcher = { |
| (saxtools.START_ELEMENT, fm_ns, u'METADATA'): | (saxtools.START_ELEMENT, fm_ns, u'METADATA'): |
| self.handle_meta_fields, | self.handle_meta_fields, |
| (saxtools.START_ELEMENT, fm_ns, u'RESULTSET'): | (saxtools.START_ELEMENT, fm_ns, u'RESULTSET'): |
| self.handle_data, | self.handle_data_fields, |
| } | } |
| # connect database | # connect database |
| self.dbCon = psycopg.connect(dsn) | self.dbCon = psycopg.connect(options.dsn) |
| self.db = self.dbCon.cursor() | self.db = self.dbCon.cursor() |
| assert self.db, "AIIEE no db cursor for %s!!"%dsn | assert self.db, "AIIEE no db cursor for %s!!"%options.dsn |
| logging.debug("dsn: "+repr(dsn)) | self.table = getattr(options,"table",None) |
| logging.debug("table: "+repr(table)) | self.update_fields = getattr(options,"update_fields",None) |
| logging.debug("update_fields: "+repr(update_fields)) | self.id_field = getattr(options,"id_field",None) |
| logging.debug("id_field: "+repr(id_field)) | self.sync_mode = getattr(options,"sync_mode",None) |
| logging.debug("sync_mode: "+repr(sync_mode)) | self.lc_names = getattr(options,"lc_names",None) |
| self.keep_fields = getattr(options,"keep_fields",None) | |
| self.table = table | self.ascii_db = getattr(options,"ascii_db",None) |
| self.update_fields = update_fields | self.replace_table = getattr(options,"replace_table",None) |
| self.id_field = id_field | self.backup_table = getattr(options,"backup_table",None) |
| self.sync_mode = sync_mode | |
| logging.debug("dsn: "+repr(getattr(options,"dsn",None))) | |
| logging.debug("table: "+repr(self.table)) | |
| logging.debug("update_fields: "+repr(self.update_fields)) | |
| logging.debug("id_field: "+repr(self.id_field)) | |
| logging.debug("sync_mode: "+repr(self.sync_mode)) | |
| logging.debug("lc_names: "+repr(self.lc_names)) | |
| logging.debug("keep_fields: "+repr(self.keep_fields)) | |
| logging.debug("ascii_db: "+repr(self.ascii_db)) | |
| logging.debug("replace_table: "+repr(self.replace_table)) | |
| self.dbIDs = {} | self.dbIDs = {} |
| self.rowcnt = 0 | self.rowcnt = 0 |
| if id_field is not None: | if self.id_field is not None: |
| # prepare a list of ids for sync mode | # prepare a list of ids for sync mode |
| qstr="select %s from %s"%(id_field,table) | qstr="select %s from %s"%(self.id_field,self.table) |
| for id in SimpleSearch(self.db, qstr): | for id in SimpleSearch(self.db, qstr): |
| # value 0: not updated | # value 0: not updated |
| self.dbIDs[id[0]] = 0; | self.dbIDs[id[0]] = 0; |
| Line 102 class xml_handler: | Line 147 class xml_handler: |
| logging.info("%d entries in DB to sync"%self.rowcnt) | logging.info("%d entries in DB to sync"%self.rowcnt) |
| self.fieldNames = [] | # names of fields in XML file |
| self.xml_field_names = [] | |
| # map XML field names to SQL field names | |
| self.xml_field_map = {} | |
| # and vice versa | |
| self.sql_field_map = {} | |
| return | return |
| Line 126 class xml_handler: | Line 176 class xml_handler: |
| #Element closed. Wrap up | #Element closed. Wrap up |
| logging.debug("END METADATA") | logging.debug("END METADATA") |
| # rename table for backup | |
| if self.backup_table: | |
| self.orig_table = self.table | |
| self.table = self.table + "_tmp" | |
| # remove old temp table | |
| qstr = "DROP TABLE %s"%(self.table) | |
| try: | |
| self.db.execute(qstr) | |
| except: | |
| pass | |
| self.dbCon.commit() | |
| if self.id_field: | |
| # sync mode -- copy table | |
| logging.info("copy table %s to %s"%(self.orig_table,self.table)) | |
| qstr = "CREATE TABLE %s AS (SELECT * FROM %s)"%(self.table,self.orig_table) | |
| else: | |
| # rename table and create empty new one | |
| logging.info("create empty table %s"%(self.table)) | |
| qstr = "CREATE TABLE %s AS (SELECT * FROM %s WHERE 1=0)"%(self.table,self.orig_table) | |
| self.db.execute(qstr) | |
| self.dbCon.commit() | |
| # delete data from table for replace | |
| if self.replace_table: | |
| logging.info("delete data from table %s"%(self.table)) | |
| qstr = "TRUNCATE TABLE %s"%(self.table) | |
| self.db.execute(qstr) | |
| self.dbCon.commit() | |
| # try to match date style with XML | |
| self.db.execute("set datestyle to 'german'") | |
| # translate id_field (SQL-name) to XML-name | |
| self.xml_id = self.sql_field_map.get(self.id_field, None) | |
| #logging.debug("xml-fieldnames:"+repr(self.xml_field_names)) | |
| # get list of fields and types of db table | |
| qstr="select attname, format_type(pg_attribute.atttypid, pg_attribute.atttypmod) from pg_attribute, pg_class where attrelid = pg_class.oid and pg_attribute.attnum > 0 and relname = '%s'" | |
| self.sql_fields={} | |
| for f in SimpleSearch(self.db, qstr%self.table): | |
| n = f[0] | |
| t = f[1] | |
| #print "SQL fields: %s (%s)"%(n,t) | |
| self.sql_fields[n] = TableColumn(n,t) | |
| # check fields to update | |
| if self.update_fields is None: | if self.update_fields is None: |
| if self.keep_fields: | |
| # update all existing fields from sql (when they are in the xml file) | |
| self.update_fields = {} | |
| for f in self.sql_fields.keys(): | |
| if self.sql_field_map.has_key(f): | |
| xf = self.sql_field_map[f] | |
| self.update_fields[f] = self.xml_field_map[xf] | |
| else: | |
| # update all fields | # update all fields |
| self.update_fields = self.fieldNames | if self.lc_names: |
| # create dict with sql names | |
| self.update_fields = {} | |
| for f in self.xml_field_map.values(): | |
| self.update_fields[f.getName()] = f | |
| else: | |
| self.update_fields = self.xml_field_map | |
| logging.debug("xml-fieldnames:"+repr(self.fieldNames)) | # and translate to list of xml fields |
| # get list of fields in db table | if self.lc_names: |
| qstr="""select attname from pg_attribute, pg_class where attrelid = pg_class.oid and relname = '%s'""" | self.xml_update_list = [self.sql_field_map[x] for x in self.update_fields] |
| columns=[x[0] for x in SimpleSearch(self.db, qstr%self.table)] | else: |
| self.xml_update_list = self.update_fields.keys() | |
| # adjust db table to fields in XML and fieldlist | |
| for fieldName in self.fieldNames: | if not self.keep_fields: |
| logging.debug("db-fieldname:"+repr(fieldName)) | # adjust db table to fields in XML and update_fields |
| if (fieldName not in columns) and (fieldName in self.update_fields): | for f in self.xml_field_map.values(): |
| qstr="alter table %s add %s %s"%(self.table,fieldName,'text') | logging.debug("sync-fieldname: %s"%f.getName()) |
| sf = self.sql_fields.get(f.getName(), None) | |
| uf = self.update_fields.get(f.getName(), None) | |
| if sf is not None: | |
| # name in db -- check type | |
| if f.getType() != sf.getType(): | |
| logging.debug("field %s has different type (%s vs %s)"%(f,f.getType(),sf.getType())) | |
| elif uf is not None: | |
| # add field to table | |
| qstr="alter table %s add %s %s"%(self.table,uf.getName(),uf.getType()) | |
| logging.info("db add field:"+qstr) | logging.info("db add field:"+qstr) |
| if self.ascii_db and type(qstr)==types.UnicodeType: | |
| qstr=qstr.encode('utf-8') | |
| self.db.execute(qstr) | self.db.execute(qstr) |
| self.dbCon.commit() | self.dbCon.commit() |
| # prepare sql statements for update | # prepare sql statements for update |
| setStr=string.join(["%s = %%s"%f for f in self.update_fields], ', ') | setStr=string.join(["%s = %%s"%self.xml_field_map[f] for f in self.xml_update_list], ', ') |
| self.updQuery="UPDATE %s SET %s WHERE %s = %%s"%(self.table,setStr,self.id_field) | self.updQuery="UPDATE %s SET %s WHERE %s = %%s"%(self.table,setStr,self.id_field) |
| # and insert | # and insert |
| fields=string.join(self.update_fields, ',') | fields=string.join([self.xml_field_map[x].getName() for x in self.xml_update_list], ',') |
| values=string.join(['%s' for f in self.update_fields], ',') | values=string.join(['%s' for f in self.xml_update_list], ',') |
| self.addQuery="INSERT INTO %s (%s) VALUES (%s)"%(self.table,fields,values) | self.addQuery="INSERT INTO %s (%s) VALUES (%s)"%(self.table,fields,values) |
| #print "upQ: ", self.updQuery | logging.debug("update-query: "+self.updQuery) |
| #print "adQ: ", self.addQuery | logging.debug("add-query: "+self.addQuery) |
| return | return |
| def handle_meta_field(self, end_condition): | def handle_meta_field(self, end_condition): |
| name = self.params.get((None, u'NAME')) | name = self.params.get((None, u'NAME')) |
| yield None | yield None |
| #Element closed. Wrap up | #Element closed. Wrap up |
| self.fieldNames.append(name) | if self.lc_names: |
| # clean name | |
| sqlname = name.replace(" ","_").lower() | |
| else: | |
| sqlname = name | |
| self.xml_field_names.append(name) | |
| # map to sql name and default text type | |
| self.xml_field_map[name] = TableColumn(sqlname, 'text') | |
| self.sql_field_map[sqlname] = name | |
| logging.debug("FIELD name: "+name) | logging.debug("FIELD name: "+name) |
| return | return |
| def handle_data(self, end_condition): | def handle_data_fields(self, end_condition): |
| dispatcher = { | dispatcher = { |
| (saxtools.START_ELEMENT, fm_ns, u'ROW'): | (saxtools.START_ELEMENT, fm_ns, u'ROW'): |
| self.handle_row, | self.handle_row, |
| Line 189 class xml_handler: | Line 326 class xml_handler: |
| if self.sync_mode: | if self.sync_mode: |
| # delete unmatched entries in db | # delete unmatched entries in db |
| logging.info("deleting unmatched rows from db") | |
| delQuery = "DELETE FROM %s WHERE %s = %%s"%(self.table,self.id_field) | delQuery = "DELETE FROM %s WHERE %s = %%s"%(self.table,self.id_field) |
| for id in self.dbIDs.keys(): | for id in self.dbIDs.keys(): |
| # find all not-updated fields | # find all not-updated fields |
| if self.dbIDs[id] == 0: | if self.dbIDs[id] == 0: |
| logging.info(" delete:"+id) | logging.info(" delete:"+id) |
| SimpleSearch(self.db, delQuery, [id]) | SimpleSearch(self.db, delQuery, [id], ascii=self.ascii_db) |
| sys.exit(1) | sys.exit(1) |
| elif self.dbIDs[id] > 1: | elif self.dbIDs[id] > 1: |
| logging.info(" sync:"+"id used more than once?"+id) | logging.info(" sync: ID %s used more than once?"%id) |
| self.dbCon.commit() | |
| # reinstate backup tables | |
| if self.backup_table: | |
| backup_name = "%s_%s"%(self.orig_table,time.strftime('%Y_%m_%d_%H_%M_%S')) | |
| logging.info("rename backup table %s to %s"%(self.orig_table,backup_name)) | |
| qstr = "ALTER TABLE %s RENAME TO %s"%(self.orig_table,backup_name) | |
| self.db.execute(qstr) | |
| logging.info("rename working table %s to %s"%(self.table,self.orig_table)) | |
| qstr = "ALTER TABLE %s RENAME TO %s"%(self.table,self.orig_table) | |
| self.db.execute(qstr) | |
| self.dbCon.commit() | self.dbCon.commit() |
| return | return |
| Line 210 class xml_handler: | Line 359 class xml_handler: |
| self.handle_col, | self.handle_col, |
| } | } |
| logging.debug("START ROW") | logging.debug("START ROW") |
| self.dataSet = {} | self.xml_data = {} |
| self.colIdx = 0 | self.colIdx = 0 |
| yield None | yield None |
| Line 230 class xml_handler: | Line 379 class xml_handler: |
| id_val='' | id_val='' |
| # synchronize by id_field | # synchronize by id_field |
| if self.id_field: | if self.id_field: |
| id_val=self.dataSet[self.id_field] | id_val = self.xml_data[self.xml_id] |
| if id_val in self.dbIDs: | if id_val in self.dbIDs: |
| self.dbIDs[id_val] += 1 | self.dbIDs[id_val] += 1 |
| update=True | update=True |
| # collect all values | |
| args = [] | |
| for fn in self.xml_update_list: | |
| f = self.xml_field_map[fn] | |
| val = self.xml_data[fn] | |
| type = self.sql_fields[f.getName()].getType() | |
| if type == "date" and len(val) == 0: | |
| # empty date field | |
| val = None | |
| elif type == "integer" and len(val) == 0: | |
| # empty int field | |
| val = None | |
| args.append(val) | |
| if update: | if update: |
| # update existing row (by id_field) | # update existing row (by id_field) |
| #setvals=[] | # last argument is ID match |
| #for fieldName in self.update_fields: | |
| # setvals.append("%s = %s"%(fieldName,sql_quote(self.dataSet[fieldName]))) | |
| #setStr=string.join(setvals, ',') | |
| id_val=self.dataSet[self.id_field] | |
| #qstr="UPDATE %s SET %s WHERE %s = '%s' "%(self.table,setStr,self.id_field,id_val) | |
| args = [self.dataSet[f] for f in self.update_fields] | |
| args.append(id_val) | args.append(id_val) |
| SimpleSearch(self.db, self.updQuery, args) | logging.debug("update: %s = %s"%(id_val, args)) |
| logging.debug("update: %s"%id_val) | SimpleSearch(self.db, self.updQuery, args, ascii=self.ascii_db) |
| else: | else: |
| # create new row | # create new row |
| #fields=string.join(update_fields, ',') | logging.debug("insert: %s"%args) |
| #values=string.join([" %s "%sql_quote(self.dataSet[x]) for x in self.update_fields], ',') | SimpleSearch(self.db, self.addQuery, args, ascii=self.ascii_db) |
| #qstr="INSERT INTO %s (%s) VALUES (%s)"%(self.table,fields,self.values) | |
| args = [self.dataSet[f] for f in self.update_fields] | |
| SimpleSearch(self.db, self.addQuery, args) | |
| logging.debug("add: %s"%self.dataSet.get(self.id_field, self.rowcnt)) | |
| #logging.info(" row:"+"%d (%s)"%(self.rowcnt,id_val)) | #logging.info(" row:"+"%d (%s)"%(self.rowcnt,id_val)) |
| if (self.rowcnt % 10) == 0: | if (self.rowcnt % 10) == 0: |
| Line 292 class xml_handler: | Line 448 class xml_handler: |
| content += self.params | content += self.params |
| yield None | yield None |
| #Element closed. Wrap up | #Element closed. Wrap up |
| field = self.fieldNames[self.colIdx] | fn = self.xml_field_names[self.colIdx] |
| self.dataSet[field] = content | self.xml_data[fn] = content |
| #print " DATA(", field, ") ", repr(content) | |
| return | return |
| def importFMPXML(options): | |
| """SAX handler to import FileMaker XML file (FMPXMLRESULT format) into the table. | |
| @param options: dict of options | |
| @param options.dsn: database connection string | |
| @param options.table: name of the table the xml shall be imported into | |
| @param options.filename: xmlfile filename | |
| @param options.update_fields: (optional) list of fields to update; default is to create all fields | |
| @param options.id_field: (optional) field which uniquely identifies an entry for updating purposes. | |
| @param options.sync_mode: (optional) really synchronise, i.e. delete entries not in XML file | |
| @param options.lc_names: (optional) lower case and clean up field names from XML | |
| @param options.keep_fields: (optional) don't add fields to SQL database | |
| @param options.ascii_db: (optional) assume ascii encoding in db | |
| @param options.replace_table: (optional) delete and re-insert data | |
| """ | |
| if getattr(options,'update_fields',None): | |
| uf = {} | |
| for f in options.update_fields.split(','): | |
| if f.find(':') > 0: | |
| (n,t) = f.split(':') | |
| else: | |
| n = f | |
| t = None | |
| uf[n] = TableColumn(n,t) | |
| options.update_fields = uf | |
| if getattr(options,'id_field',None) and getattr(options,'replace_table',None): | |
| logging.error("ABORT: sorry, you can't do both sync (id_field) and replace") | |
| sys.exit(1) | |
| ## | parser = sax.make_parser() |
| ## public static int main() | #The "consumer" is our own handler |
| ## | consumer = xml_handler(options) |
| #Initialize Tenorsax with handler | |
| handler = saxtools.tenorsax(consumer) | |
| #Resulting tenorsax instance is the SAX handler | |
| parser.setContentHandler(handler) | |
| parser.setFeature(sax.handler.feature_namespaces, 1) | |
| parser.parse(options.filename) | |
| if __name__ == "__main__": | |
| from optparse import OptionParser | from optparse import OptionParser |
| opars = OptionParser() | opars = OptionParser() |
| Line 319 opars.add_option("-t", "--table", | Line 511 opars.add_option("-t", "--table", |
| help="database table name") | help="database table name") |
| opars.add_option("--fields", default=None, | opars.add_option("--fields", default=None, |
| dest="update_fields", | dest="update_fields", |
| help="list of fields to update (comma separated)", metavar="LIST") | help="list of fields to update (comma separated, sql-names)", metavar="LIST") |
| opars.add_option("--id-field", default=None, | opars.add_option("--id-field", default=None, |
| dest="id_field", | dest="id_field", |
| help="name of id field for synchronisation (only appends data otherwise)", metavar="NAME") | help="name of id field for synchronisation (only appends data otherwise, sql-name)", metavar="NAME") |
| opars.add_option("--sync-mode", default=False, action="store_true", | opars.add_option("--sync", "--sync-mode", default=False, action="store_true", |
| dest="sync_mode", | dest="sync_mode", |
| help="do full sync based on id field (remove unmatched fields from db)") | help="do full sync based on id field (remove unmatched fields from db)") |
| opars.add_option("--lc-names", default=False, action="store_true", | |
| dest="lc_names", | |
| help="clean and lower case field names from XML") | |
| opars.add_option("--keep-fields", default=False, action="store_true", | |
| dest="keep_fields", | |
| help="don't add fields from XML to SQL table") | |
| opars.add_option("--ascii-db", default=False, action="store_true", | |
| dest="ascii_db", | |
| help="the SQL database stores ASCII instead of unicode") | |
| opars.add_option("--replace", default=False, action="store_true", | |
| dest="replace_table", | |
| help="replace table i.e. delete and re-insert data") | |
| opars.add_option("--backup", default=False, action="store_true", | |
| dest="backup_table", | |
| help="create backup of old table (breaks indices)") | |
| opars.add_option("-d", "--debug", default=False, action="store_true", | opars.add_option("-d", "--debug", default=False, action="store_true", |
| dest="debug", | dest="debug", |
| help="debug mode (more output)") | help="debug mode (more output)") |
| Line 333 opars.add_option("-d", "--debug", defaul | Line 540 opars.add_option("-d", "--debug", defaul |
| (options, args) = opars.parse_args() | (options, args) = opars.parse_args() |
| if len(sys.argv) < 2 or options.filename is None or options.dsn is None: | if len(sys.argv) < 2 or options.filename is None or options.dsn is None: |
| print "importFMPXML "+version_string | |
| opars.print_help() | opars.print_help() |
| sys.exit(1) | sys.exit(1) |
| Line 345 logging.basicConfig(level=loglevel, | Line 553 logging.basicConfig(level=loglevel, |
| format='%(asctime)s %(levelname)s %(message)s', | format='%(asctime)s %(levelname)s %(message)s', |
| datefmt='%H:%M:%S') | datefmt='%H:%M:%S') |
| update_fields = None | importFMPXML(options) |
| if options.update_fields: | |
| update_fields = [string.strip(s) for s in options.update_fields.split(',')] | |
| parser = sax.make_parser() | |
| #The "consumer" is our own handler | |
| consumer = xml_handler(dsn=options.dsn,table=options.table, | |
| update_fields=update_fields,id_field=options.id_field, | |
| sync_mode=options.sync_mode) | |
| #Initialize Tenorsax with handler | |
| handler = saxtools.tenorsax(consumer) | |
| #Resulting tenorsax instance is the SAX handler | |
| parser.setContentHandler(handler) | |
| parser.setFeature(sax.handler.feature_namespaces, 1) | |
| parser.parse(options.filename) | |
| print "DONE!" |