From 38a9e7de795fff5733a8df5c4fa3b3ea8cf8a602 Mon Sep 17 00:00:00 2001 From: Hubert Pineault <hpinea...@riseup.net> Date: Thu, 28 Feb 2019 01:20:00 -0500 Subject: [PATCH 1/7] New feature: Update folder structure to new config parameters.
Motivation: If you change some of the config file paremeters, like nametrans and utf8 decode, it will completly mess your maildir folder structure. So, to make changes in config file, you need to re-download the whole imap account. If it weights a few gigs, it's a bit annoying. With the coming of features like remote folder creation and utf8 decoding of folder names, a converting tool could be handy for some of us. Introduction: The objectif is to convert an actual maildir folder structure to a new one. There is still a farely good amount of work to do in order to make it ready to merge with main branch. I have already identified some changes that should be made in the update process. Those points are raised in details related commits' messages. I will need some help with cleaning the code and, notably, exceptions handling. The three main points that will need a complete rewritting are: - The invoking method (actually, we load two independent config files) - The way different config parameters are handled by objects - (actually, we use two accounts, so two remote, local and status repos) - Instead of altering existing class, it could be a better idea to create a child class specific for updating. Patch content (7 commits): - The one your reading. Only a commit message. Load up new config - file to which we're uploading Invoke updating process Various new - methods invoked by the update-process Alterations to existing - methods needed by the update process Main update method. Loop - through each account, and prepare for update. Get content from - old folder structure and copy it to new structure Signed-off-by: Hubert Pineault <hpinea...@riseup.net> -- 2.11.0 From 8ee4fe52c6b142323dd0ba9f51c545b0eaaa826f Mon Sep 17 00:00:00 2001 From: Hubert Pineault <hpinea...@riseup.net> Date: Wed, 30 Jan 2019 00:46:25 -0500 Subject: [PATCH 2/7] Load up new config file to which we're uploading Adds three command options: --update-conf [new config file] move-content : simpy move the files --instead of copying message restore-update [aborted update dir] Logic: Loads the new config file the same way it's done for regular config. If --restore-update is provided, check that the dir exists. Set the same command options for the new config file. Initialize some vars that will be used in the update process. Discussion: I'm thinking of completly changing the way the update process takes its input. Instead of loading a new config file, this could be done in a single config files with parameters for updating to new nametrans, folderfilter and encoding. This could could open new possibilities to improve performence of the updating process. It could also make the --restore-update cmd option easier to use, since we can record its dir in the config file. Signed-off-by: Hubert Pineault <hpinea...@riseup.net> --- offlineimap/init.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/offlineimap/init.py b/offlineimap/init.py index 80158bd..b36db25 100644 --- a/offlineimap/init.py +++ b/offlineimap/init.py @@ -97,6 +97,8 @@ class OfflineImap(object): mbnames.write() elif options.deletefolder: return self.__deletefolder(options) + elif options.newconfigfile: + return self.__sync(options) else: return self.__sync(options) @@ -160,6 +162,26 @@ class OfflineImap(object): metavar="[section:]option=value", help="override configuration file option") + parser.add_option("--update-conf", dest="newconfigfile", + metavar="NEWCONFIGFILE", + default=None, + help="(EXPERIMENTAL: do not support changes in account names)" + "convert actual config to new config from source file," + "and replace configfile with sourcefile.") + + parser.add_option("--restore-update", dest="failedupdaterestore", + metavar="FAILEDUPDATERESTORE", + default=None, + help="in conjonction with --update-conf, " + "restore a failed update process.") + + parser.add_option("--move-content", + action="store_true", + dest="movecontent", + default=False, + help="in conjonction with --update-conf, " + "move content of folder instead of copying.") + parser.add_option("-o", action="store_true", dest="runonce", default=False, @@ -246,6 +268,47 @@ class OfflineImap(object): section = "general" config.set(section, key, value) + # Update and convert config file. + if options.newconfigfile: + newconfigfilename = os.path.expanduser(options.newconfigfile) + + # Read new configfile + newconfigfile = CustomConfigParser() + if not os.path.exists(newconfigfilename): + # TODO, initialize and make use of chosen ui for logging + logging.error(" *** New config file '%s' does not exist; aborting!"% + newconfigfilename) + sys.exit(1) + newconfigfile.read(newconfigfilename) + + if options.dryrun: + dryrun = newconfigfile.set('general', 'dry-run', 'True') + newconfigfile.set_if_not_exists('general', 'dry-run', 'False') + + if options.failedupdaterestore: + failedupdaterestore = os.path.expanduser(options.failedupdaterestore) + if not os.path.exists(failedupdaterestore): + # TODO, initialize and make use of chosen ui for logging + logging.error(" *** Failed update '%s' does not exist; aborting!"% + failedupdaterestore) + sys.exit(1) + else: + failedupdaterestore = '' + + # Set update options in both config files + # and initialize some vars. + self.config_filename = configfilename + self.newconfig_filename = newconfigfilename + newconfigfile.set('general', 'update-conf', 'True') + newconfigfile.set('general', 'is-new-config-source', 'True') + newconfigfile.set('general', 'movecontent', str(options.movecontent)) + newconfigfile.set('general', 'failedupdaterestore', failedupdaterestore) + config.set('general', 'update-conf', 'True') + config.set('general', 'movecontent', str(options.movecontent)) + self.newconfig = newconfigfile + config.set('general', 'is-new-config-source', 'False') + config.set_if_not_exists('general', 'update-conf', 'False') + # Which ui to use? CLI option overrides config file. ui_type = config.getdefault('general', 'ui', 'ttyui') if options.interface != None: -- 2.11.0 From d0199b402f1d4f2d2e2220e08a11d0c287756dbd Mon Sep 17 00:00:00 2001 From: Hubert Pineault <hpinea...@riseup.net> Date: Wed, 30 Jan 2019 01:06:36 -0500 Subject: [PATCH 3/7] Invoke updating process The update process is invoked through init.__sync() method. Logic: The idea is not to duplicate sig_handler and UI initializations. Although, I think it should be rewritten in a distinct methode that would called by run(). The sync process and the update process shouldn't mixed. Discussion: I'm thinking of two ways to improve. Either write a new method that would deal with sig_handler and be called by both sync and update processes. Or, simply copy __sync() code and adapt it for the update process. New method: _newactiveaccounts() The method is almost a copy/paste of _get_activeaccounts(). We first make the list of accounts in the new config file. Then we check that they exist in the old config file. It returns only accounts found in both config files. I think this code should be included in another method so that we use _get_activeaccounts() and then test config's files accounts correspondance. (Note that the whole war the update process takes its input through files should probably be re-thought) Signed-off-by: Hubert Pineault <hpinea...@riseup.net> --- offlineimap/init.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/offlineimap/init.py b/offlineimap/init.py index b36db25..e82d808 100644 --- a/offlineimap/init.py +++ b/offlineimap/init.py @@ -480,6 +480,46 @@ class OfflineImap(object): return activeaccounts + def _get_newactiveaccounts(self, activeaccounts, options): + """Check that each accounts in new config file exists in old config + file. + Assumes that self.newconfig is defined. + Based on _get_activeaccounts() + """ + + oldactiveaccounts = activeaccounts + newactiveaccounts = [] + errormsg = None + + # Load accounts in new config file source + newactiveaccountnames = self.newconfig.get("general", "accounts") + if options.accounts: + newactiveaccountnames = options.accounts + newactiveaccountnames = [x.lstrip() + for x in newactiveaccountnames.split(",")] + + allnewaccounts = accounts.getaccountlist(self.newconfig) + + # Check for new config file accounts integrety + # (not sure if it does) and if check if they exists + # in old config file + for accountname in newactiveaccountnames: + if accountname in allnewaccounts \ + and accountname in oldactiveaccounts: + newactiveaccounts.append(accountname) + else: + errormsg = "Valid accounts are: %s"% ( + ", ".join(oldactiveaccounts)) + self.ui.error("The account '%s' does not exist"% accountname) + + if len(activeaccounts) < 1: + errormsg = "No accounts are defined!" + + if errormsg is not None: + self.ui.terminate(1, errormsg=errormsg) + + return newactiveaccounts + def __sync(self, options): """Invoke the correct single/multithread syncing @@ -526,7 +566,16 @@ class OfflineImap(object): activeaccounts = self._get_activeaccounts(options) mbnames.init(self.config, self.ui, options.dryrun) - if options.singlethreading: + if options.newconfigfile: + # Update directory structure and folder names + # instead of syncing. + newactiveaccounts = self._get_newactiveaccounts(activeaccounts, + options) + mbnames.init(self.newconfig, self.ui, options.dryrun) + self.__updateconf(activeaccounts, newactiveaccounts, + options.profiledir) + + elif options.singlethreading: # Singlethreaded. self.__sync_singlethreaded(activeaccounts, options.profiledir) else: -- 2.11.0 From 647c6c215cc2b8f9f211eec881ff93501a796557 Mon Sep 17 00:00:00 2001 From: Hubert Pineault <hpinea...@riseup.net> Date: Sun, 3 Feb 2019 05:54:57 -0500 Subject: [PATCH 4/7] Various new methods invoked by the update-process The main update process is not in this commit and all alteration of existing methods will be subtmitted in distinct commits. The methods found in this commit are of minor importance. Here is the list and explanation. folder.IMAP.IMAPFolder.getimapname(): Simply returns 'self.imap', which is a proprety containing the original imap response before decoding in utf8. repository.MailDir.moveroot(newroot, update_mbnames): Move the local maildir root folder to another location in the filesystem and set self.root accordingly. The method is used to move a maildir root folder to a backup of temporary folder. repository.MailDir.getmessagefilename(uid): return filename... repository.Base.BaseRepository.getmetadata(): Add the new property self.metadatadir. Return a tuple of self.metadatadir, self.mapdir and self.uiddir. Initialize properties if it's needed. This method is implemented in order to be called by account.movemetadata). Since mapdir and uiddir initilization are done in this method, BaseRepository.__init__() will be modified in another commit. repository.LocalStatus.LocalStatusRepository.getmetadata(): Same idea as BaseRepository.getmetadata(), but we need a specific method for status repos. ui.UIBase: there's 10 new methods for printing output. account.movemetadatadir(newmetadatadir, update_mbnames): Move the metadata root folder to another location in the filesystem and call account.getmetadata() to set account proprieties acconrdingly . The method is used to move a metadata to a backup of temporary folder. Note that this method implies some code alteration in accout.init() and account.getmetadata() update_mbnames: I still need to figure out how mbnames work. Not Sure if this will be usefull. Some help please? account.get_folderlist(): Initialize remote and local repos. To make the link between local folders and remote folders, old and new config files need to have their own remoterepos. Although, it shouldn't have to send two request to imap server. I haven't found a way to skip the imap list cmd for the new config file while keeping it from pointing to the old config file remote repo. One way to could be to write a whole new logic of initialysing repositories. This methods would need to get imap result and pass it to the new remoterepos. Signed-off-by: Hubert Pineault <hpinea...@riseup.net> --- offlineimap/accounts.py | 123 +++++++++++++++++++++++++++++++--- offlineimap/folder/IMAP.py | 3 + offlineimap/folder/Maildir.py | 4 +- offlineimap/repository/Base.py | 18 +++++ offlineimap/repository/LocalStatus.py | 24 +++++++ offlineimap/repository/Maildir.py | 24 +++++++ offlineimap/ui/UIBase.py | 85 +++++++++++++++++++++++ 7 files changed, 272 insertions(+), 9 deletions(-) diff --git a/offlineimap/accounts.py b/offlineimap/accounts.py index 4aca7c4..0ae8b0d 100644 --- a/offlineimap/accounts.py +++ b/offlineimap/accounts.py @@ -257,7 +257,8 @@ class SyncableAccount(Account): pass # Failed to delete for some reason. def syncrunner(self): - """The target for both single and multi-threaded modes.""" + """The target for three mode: + single mode, multi-threaded mode and update-conf mode.""" self.ui.registerthread(self) try: @@ -279,8 +280,13 @@ class SyncableAccount(Account): while looping: self.ui.acct(self) try: + # if not self.config.getboolean('general', 'is-new-config-source'): self.__lock() - self.__sync() + if self.config.getboolean('general', 'update-conf'): + self.get_folderlist() + else: + self.__sync() + except (KeyboardInterrupt, SystemExit): raise except OfflineImapError as e: @@ -292,17 +298,26 @@ class SyncableAccount(Account): raise self.ui.error(e, exc_info()[2]) except Exception as e: - self.ui.error(e, exc_info()[2], msg= - "While attempting to sync account '%s'"% self) + if self.config.newconfigsource: + self.ui.error(e, exc_info()[2], msg= + "While attempting to update account '%s'"% self) + else: + self.ui.error(e, exc_info()[2], msg= + "While attempting to sync account '%s'"% self) else: # After success sync, reset the looping counter to 3. if self.refreshperiod: looping = 3 finally: self.ui.acctdone(self) - self._unlock() if looping and self._sleeper() >= 2: + # If in update-conf mode, keep original account locked + # and don't run sleeper + if self.config.getboolean('general', 'update-conf'): looping = 0 + else: + self._unlock() + if looping and self._sleeper() >= 2: + looping = 0 def get_local_folder(self, remotefolder): """Return the corresponding local folder for a given remotefolder.""" @@ -312,8 +327,8 @@ class SyncableAccount(Account): replace(self.remoterepos.getsep(), self.localrepos.getsep())) - # The syncrunner will loop on this method. This means it is called - # more than once during the run. + # The syncrunner will loop on this method (unless in update-conf mode). + # This means it is called more than once during the run. def __sync(self): """Synchronize the account once, then return. @@ -452,6 +467,98 @@ class SyncableAccount(Account): except Exception as e: self.ui.error(e, exc_info()[2], msg="Calling hook") + ######## + ### Config update methodes: + # + def movemetadatadir(self, newmetadatadir, update_mbnames=False): + '''Move metadatadir to a new path. + + This function is called by the config updating process. + + TODO: update mbnames according to new root''' + + self.ui.movemetadatadir(self.name, newmetadatadir) + if update_mbnames: + self.ui.warn("Updating mbname for moving metadatadir NOT YET IMPLEMENTED") + # raise + #XXX TODO + if self.dryrun: + return + + try: + from shutil import move + # Get list of metadata objects to move to backup folder + movables = [] + movables.append(self.getaccountmeta()) + if self._lockfilepath: + movables.append(self._lockfilepath) + movables.append(self.localrepos.metadatadir) + movables.append(self.remoterepos.metadatadir) + + # Set path for new meta + self.config.set('general', 'metadata', newmetadatadir) + self.metadatadir = self.config.getmetadatadir() + + # Move metadata + for m in movables: + basename = os.path.basename(m) + move(m, os.path.join(newmetadatadir, basename)) + + self._lockfilepath = os.path.join( + newmetadatadir, + os.path.basename(self._lockfilepath)) + self.metadatadir = newmetadatadir + self.localrepos.metadatadir = None + self.localrepos.getmetadata() + self.remoterepos.metadatadir = None + self.remoterepos.getmetadata() + self.statusrepos.metadatadir = None + self.statusrepos.getmetadata() + + except OSError or OfflineImapError as e: + self.ui.error(e, exc_info()[2], + "Moving metadatadir to '%s'"% + (newmetadatadir)) + raise + + # The syncrunner will loop on this method. This means it is called more than + # once during the run. + def get_folderlist(self): + """Get the account folderlist once, then return. + + Assumes that `self.remoterepos`, `self.localrepos`, and + `self.statusrepos` has already been populated, so it should only + be called from the :meth:`syncrunner` function. + + Based on __sync()""" + + hook = self.getconf('presynchook', '') # Is it important? + self.callhook(hook) + + if self.utf_8_support and self.remoterepos.getdecodefoldernames(): + raise OfflineImapError("Configuration mismatch in account " + + "'%s'. "% self.getname() + + "\nAccount setting 'utf8foldernames' and repository " + + "setting 'decodefoldernames'\nmay not be used at the " + + "same time. This account has not been updated.\n" + + "Please check the configuration and documentation.", + OfflineImapError.ERROR.REPO) + + try: + self.remoterepos.getfolders() + self.remoterepos.dropconnections() + self.localrepos.getfolders() + + #XXX: This section should be checked and + # rewritten (copy-pasted from callhook to handle try) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + self.ui.error(e, exc_info()[2], msg="Calling hook") + + hook = self.getconf('postupdateconfhook', '') # Is this right? + self.callhook(hook) + #XXX: This function should likely be refactored. This should not be #passed the # account instance. diff --git a/offlineimap/folder/IMAP.py b/offlineimap/folder/IMAP.py index ead4396..3a716e4 100644 --- a/offlineimap/folder/IMAP.py +++ b/offlineimap/folder/IMAP.py @@ -96,6 +96,9 @@ class IMAPFolder(BaseFolder): name = imaputil.utf8_IMAP(name) return name + def getimapname(self): + return self.imap_name + # Interface from BaseFolder def suggeststhreads(self): singlethreadperfolder_default = False diff --git a/offlineimap/folder/Maildir.py b/offlineimap/folder/Maildir.py index f061bcd..795f4d3 100644 --- a/offlineimap/folder/Maildir.py +++ b/offlineimap/folder/Maildir.py @@ -295,7 +295,6 @@ class MaildirFolder(BaseFolder): uid, self._foldermd5, self.infosep, ''.join(sorted(flags))) return uniq_name.replace(os.path.sep, self.sep_subst) - def save_to_tmp_file(self, filename, content): """Saves given content to the named temporary file in the 'tmp' subdirectory of $CWD. @@ -416,6 +415,9 @@ class MaildirFolder(BaseFolder): self.ui.debug('maildir', 'savemessage: returning uid %d' % uid) return uid + def getmessagefilename(self, uid): + return self.messagelist[uid]['filename'] + # Interface from BaseFolder def getmessageflags(self, uid): return self.messagelist[uid]['flags'] diff --git a/offlineimap/repository/Base.py b/offlineimap/repository/Base.py index 2ad7708..60f926c 100644 --- a/offlineimap/repository/Base.py +++ b/offlineimap/repository/Base.py @@ -63,6 +63,24 @@ class BaseRepository(CustomConfig.ConfigHelperMixin): self.foldersort = self.localeval.eval( self.getconf('foldersort'), {'re': re}) + def getmetadata(self): + if self.metadatadir and self.mapdir and self.uiddir: + return self.metadatadir, self.mapdir, self.uiddir + else: + self.metadatadir = os.path.join(self.config.getmetadatadir(), + 'Repository-' + self.name) + if not os.path.exists(self.metadatadir): + os.mkdir(self.metadatadir, 0o700) + self.mapdir = os.path.join(self.metadatadir, 'UIDMapping') + if not os.path.exists(self.mapdir): + os.mkdir(self.mapdir, 0o700) + # FIXME: self.uiddir variable name is lying about itself. + # (Still true with this new method???) + self.uiddir = os.path.join(self.metadatadir, 'FolderValidity') + if not os.path.exists(self.uiddir): + os.mkdir(self.uiddir, 0o700) + return self.metadatadir, self.mapdir, self.uiddir + def restore_atime(self): """Sets folders' atime back to their values after a sync diff --git a/offlineimap/repository/LocalStatus.py b/offlineimap/repository/LocalStatus.py index a651fd2..4ad2315 100644 --- a/offlineimap/repository/LocalStatus.py +++ b/offlineimap/repository/LocalStatus.py @@ -53,6 +53,30 @@ class LocalStatusRepository(BaseRepository): # self._folders is a dict of name:LocalStatusFolders(). self._folders = {} + def getmetadata(self): + # class and root for all backends. + self.backends = {} + self.backends['sqlite'] = { + 'class': LocalStatusSQLiteFolder, + 'root': os.path.join(self.account.getaccountmeta(), 'LocalStatus-sqlite') + } + self.backends['plain'] = { + 'class': LocalStatusFolder, + 'root': os.path.join(self.account.getaccountmeta(), 'LocalStatus') + } + + if self.account.getconf('status_backend', None) is not None: + raise OfflineImapError( + "the 'status_backend' configuration option is not supported" + " anymore; please, remove this configuration option.", + OfflineImapError.ERROR.REPO + ) + # Set class and root for sqlite. + self.setup_backend('sqlite') + + if not os.path.exists(self.root): + os.mkdir(self.root, 0o700) + def _instanciatefolder(self, foldername): return self.LocalStatusFolderClass(foldername, self) # Instanciate. diff --git a/offlineimap/repository/Maildir.py b/offlineimap/repository/Maildir.py index 0db728c..5c2685d 100644 --- a/offlineimap/repository/Maildir.py +++ b/offlineimap/repository/Maildir.py @@ -130,6 +130,30 @@ class MaildirRepository(BaseRepository): else: raise + def moveroot(self, newroot, update_mbnames = False): + '''Move local repository root folder to a new path. + + This function is called by the config updating process. + + TODO: update mbnames according to new root''' + + self.ui.moveroot(self.name, newroot) + if self.account.dryrun: + return + try: + os.renames(self.root, newroot) + self.root = newroot + except OSError or OfflineImapError as e: + self.ui.error(e, exc_info()[2], + "Moving root from '%s' to '%s'"% + (self.root, newroot)) + raise + + if update_mbnames: + self.ui.warn("Updating mbname for moving root folder NOT YET IMPLEMENTED") + # raise + #XXX TODO + def deletefolder(self, foldername): self.ui.warn("NOT YET IMPLEMENTED: DELETE FOLDER %s"% foldername) diff --git a/offlineimap/ui/UIBase.py b/offlineimap/ui/UIBase.py index 731d6f1..f46eb35 100644 --- a/offlineimap/ui/UIBase.py +++ b/offlineimap/ui/UIBase.py @@ -344,6 +344,91 @@ class UIBase(object): self.debug('', "Copying folder structure from %s to %s" %\ (src_repo, dst_repo)) + ############################## Config updating + + def updateconf(self, oldconf, newconf): + """Output that we start the updating process.""" + + self.acct_startimes['updateconf'] = time.time() + self.logger.info("*** Updating config file %s from source %s"% + (oldconf, newconf)) + + def updateconfdone(self): + """Output that we finished the updating process.""" + + sec = time.time() - self.acct_startimes['updateconf'] + del self.acct_startimes['updateconf'] + self.logger.info("*** Finished updating config. Total time elapsed: %d:%02d"% + (sec // 60, sec % 60)) + + def updateconfacct(self, account): + """Output that we start updating folder structure for account.""" + + self.acct_startimes['updateconfacct'] = time.time() + self.logger.info("*** Updating folder structure for account '%s'"% + account) + + def updateconfacctdone(self, account): + """Output that we finished updating folder structure for account.""" + + sec = time.time() - self.acct_startimes['updateconfacct'] + del self.acct_startimes['updateconfacct'] + self.logger.info("*** Finished account '%s' in %d:%02d"% + (account, sec // 60, sec % 60)) + + def getfoldercontent(self, oldfolder, newfolder, move=False): + """Log 'Moving folder content...'.""" + + self.acct_startimes['getfoldercontent'] = time.time() + prefix = "Moving " if move else "Copying " + prefix = "[DRYRUN] " + prefix if self.dryrun else prefix + self.info(('{0}folder content from "{1}" to "{2}"'.format( + prefix, oldfolder, newfolder))) + + def nbmessagestosend(self, folder_basename, nbofmessages, movecontent): + """ Output the number of messages (files) this operation will move.""" + + indent = '{:>4}'.format('') + prefix = "[DRYRUN] " if self.dryrun else "" + action = 'Moving ' if movecontent else 'Copying ' + if nbofmessages == 0: + self.info("{0}Folder empty.".format(indent)) + else: + self.info(("{0}{1}{2}{3} messages to folder '{4}'".format( + indent, prefix, action, nbofmessages, folder_basename))) + + def sendingmessage(self, uid, num, num_to_send, src, destfolder, movecontent): + """Output a log line stating which message we copy.""" + + indent = '{:>4}'.format('') + prefix = "[DRYRUN] " if self.dryrun else "" + action = 'Moving ' if movecontent else 'Copying ' + self.logger.debug("%s%s%s message UID %s (%d/%d) %s:%s -> %s:%s"% ( + indent, prefix, action, uid, num, num_to_send, src.repository, src, + destfolder.repository, destfolder)) + + def getfoldercontentdone(self): + """Output that we finished operation on folder content for old folder.""" + + indent = '{:>4}'.format('') + sec = time.time() - self.acct_startimes['getfoldercontent'] + del self.acct_startimes['getfoldercontent'] + self.logger.info("%s Operation completed in %d:%02d"% + (indent, sec // 60, sec % 60)) + + def moveroot(self, account, newroot): + """Output that we are moving the root folder for the account.""" + + prefix = "[DRYRUN] " if self.dryrun else "" + self.logger.info("*** {0}Moving root folder for account '{1}' to {2}".format( + prefix, account, newroot)) + + def movemetadatadir(self, account, newmetadatadir): + """Output that we are moving the metadatadir for the account.""" + + self.logger.info("*** Moving metadata directory for account '%s' to %s"% + (account, newmetadatadir)) + ############################## Folder syncing def makefolder(self, repo, foldername): """Called when a folder is created.""" -- 2.11.0 From e31fc24b7f827156f673ffb675f1f360ff5398d0 Mon Sep 17 00:00:00 2001 From: Hubert Pineault <hpinea...@riseup.net> Date: Mon, 4 Feb 2019 00:22:00 -0500 Subject: [PATCH 5/7] Alterations to existing methods needed by the update process account.SyncableAccount.syncrunner(): The update process uses two SyncableAccount instance (new and old) for each account found in both config files. To initialize repos, we use syncrunner in order to loop for imap connection and deal with exceptions. When in an update process, syncrunner will call self.get_folderlist(). Exceptions, error messages and loop handling are altered to deal with updating. I think this method should be left to its original state and the whole repos initialization for the update process should be in a distinct method. folder.IMAP.IMAPFolder.__init__(): Add self.imap_name, which store original imap name returned in utf7 encoding. With the implentation of utf8 decoding option (utf8foldernames), the imap name gets replaced if the option is enabled. The update process needs to know the original imap return (in utf7 encode) in order to match a config file where the utf8foldernames is enabled with a config file where it's disabled. (Note that the new methods self.getimapname returns self.imap_name) repository.Base.Baserepository.__init__(): Remove initialization of self.uiddir and self.mapdir. Call self.getmedata() where initialization is handled and the new property self.metadatadir is added. repository.LocalStatus.LocalStatusRepository.__init__(): Same logic as for Baserepository.__init__(), but specific for localrepos. Remove initialization of self.backend. Signed-off-by: Hubert Pineault <hpinea...@riseup.net> --- offlineimap/folder/IMAP.py | 6 ++++-- offlineimap/repository/Base.py | 13 +++---------- offlineimap/repository/LocalStatus.py | 25 +------------------------ 3 files changed, 8 insertions(+), 36 deletions(-) diff --git a/offlineimap/folder/IMAP.py b/offlineimap/folder/IMAP.py index 3a716e4..ee7ab39 100644 --- a/offlineimap/folder/IMAP.py +++ b/offlineimap/folder/IMAP.py @@ -49,9 +49,11 @@ class IMAPFolder(BaseFolder): # querying the IMAP server, while False is used when # creating a folder object from a locally available utf_8 # name) # In any case the given name is first dequoted. - name = imaputil.dequote(name) + self.imap_name = imaputil.dequote(name) # For update-conf mode if decode and repository.account.utf_8_support: - name = imaputil.IMAP_utf8(name) + name = imaputil.IMAP_utf8(self.imap_name) + else: + name = self.imap_name self.sep = imapserver.delim super(IMAPFolder, self).__init__(name, repository) if repository.getdecodefoldernames(): diff --git a/offlineimap/repository/Base.py b/offlineimap/repository/Base.py index 60f926c..0a58612 100644 --- a/offlineimap/repository/Base.py +++ b/offlineimap/repository/Base.py @@ -34,16 +34,9 @@ class BaseRepository(CustomConfig.ConfigHelperMixin): self.localeval = account.getlocaleval() self._accountname = self.account.getname() self._readonly = self.getconfboolean('readonly', False) - self.uiddir = os.path.join(self.config.getmetadatadir(), - 'Repository-' + self.name) if not os.path.exists(self.uiddir): - os.mkdir(self.uiddir, 0o700) self.mapdir = - os.path.join(self.uiddir, 'UIDMapping') if not - os.path.exists(self.mapdir): - os.mkdir(self.mapdir, 0o700) - # FIXME: self.uiddir variable name is lying about itself. - self.uiddir = os.path.join(self.uiddir, 'FolderValidity') if - not os.path.exists(self.uiddir): - os.mkdir(self.uiddir, 0o700) + + self.mapdir = self.uiddir = self.metadatadir = None + self.getmetadata() self.nametrans = lambda foldername: foldername self.folderfilter = lambda foldername: 1 diff --git a/offlineimap/repository/LocalStatus.py b/offlineimap/repository/LocalStatus.py index 4ad2315..605093a 100644 --- a/offlineimap/repository/LocalStatus.py +++ b/offlineimap/repository/LocalStatus.py @@ -26,30 +26,7 @@ from offlineimap.error import OfflineImapError class LocalStatusRepository(BaseRepository): def __init__(self, reposname, account): BaseRepository.__init__(self, reposname, account) - - # class and root for all backends. - self.backends = {} self.backends['sqlite'] = { - 'class': LocalStatusSQLiteFolder, 'root': - os.path.join(account.getaccountmeta(), - 'LocalStatus-sqlite') - } self.backends['plain'] = { - 'class': LocalStatusFolder, 'root': - os.path.join(account.getaccountmeta(), 'LocalStatus') - } - - if self.account.getconf('status_backend', None) is not None: - raise OfflineImapError( - "the 'status_backend' configuration option is not - supported" " anymore; please, remove this configuration - option.", OfflineImapError.ERROR.REPO - ) - # Set class and root for sqlite. - self.setup_backend('sqlite') - - if not os.path.exists(self.root): - os.mkdir(self.root, 0o700) - + self.getmetadata() # self._folders is a dict of name:LocalStatusFolders(). self._folders = {} -- 2.11.0 From 16af7c323f00233315e384f36c797d88da13d676 Mon Sep 17 00:00:00 2001 From: Hubert Pineault <hpinea...@riseup.net> Date: Mon, 4 Feb 2019 19:16:41 -0500 Subject: [PATCH 6/7] Main update method. Loop through each account, and prepare for update. For the update process, we use two SyncableAccount instance, one for original config file (oldaccount) and one for the new config file (newaccount). Before initializing newaccount, we need to move oldaccount maildir root and metadata folder to a backup location. So we first initialize old account to get folder names and we move them. We then initialize newaccount, maildir and metadata folders will be created automatically. Once both accounts are initialized, we sync folder structure for newaccount and then call newaccount.get_content_from_account() to procede to copying or moving content from old folder structure to new folder structure. The whole update process should be rewritten in a new class UpdatableAccount inheriting from Account. The UpdatableAccount class should have these repos defined: statusrepos, oldlocalrepos, newlocalrepos, remoterepos. To avoid having to pass two imap list command and two independent remoterepos, we will likely have to write a new method to replace SyncableAccount.get_local_folder(remotefolder) which seems incompatible with two sets of nametrans. Signed-off-by: Hubert Pineault <hpinea...@riseup.net> --- offlineimap/folder/Maildir.py | 67 +++++++++++++++ offlineimap/init.py | 188 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) diff --git a/offlineimap/folder/Maildir.py b/offlineimap/folder/Maildir.py index 795f4d3..bab8284 100644 --- a/offlineimap/folder/Maildir.py +++ b/offlineimap/folder/Maildir.py @@ -551,3 +551,70 @@ class MaildirFolder(BaseFolder): self.ui.warn(("Inconsistent FMD5 for file `%s':" " Neither `%s' nor `%s' found") % (filename, oldfmd5, self._foldermd5)) + + def sendcontentto(self, dstfolder, movecontent=False, dryrun=False): + from shutil import move, copy2 + + srcfolder = self + folderpath = os.path.join(srcfolder.root, srcfolder.name) + + for d in os.listdir(folderpath): + if os.path.isdir(os.path.join(folderpath, d)): + messages = os.listdir(os.path.join(folderpath, d)) + num = 0 + totalnum = len(messages) + self.ui.nbmessagestosend(os.path.join(dstfolder.name, d), + totalnum, movecontent) + + for f in messages: + #TODO: Show progression + + if os.path.isdir(os.path.join(folderpath, d, f)): + '''There should not be any folder in here. If there + is, should we also deal with it? In a sane folder + structure, this should not happen. Or am I wrong?''' + + self.ui.info("A folder was found in source folder '{0}'" + "while trying to move its content. Ignoring" + "'{1}'. This indicate that the original" + "folder structure is probably corrupt in" + "some way. Please investigate.", + format(os.path.join(srcfolder.name, d), f)) + continue + + dstfile = os.path.join(dstfolder.root, dstfolder.name, d, f) + if movecontent: + #TODO: Prevent or ask file overwrite + move(os.path.join(folderpath, d, f), + dstfile) + else: + num += 1 + if os.path.exists(dstfile): + self.ui.ignorecopyingmessage(f, srcfolder, dstfolder) + continue + if not dryrun: + copy2(os.path.join(folderpath, d, f), + dstfile) + + if (movecontent + and len(os.listdir(os.path.join(folderpath, d))) == 0 + and not dryrun): + os.rmdir(os.path.join(folderpath, d)) + elif movecontent and not dryrun: + self.ui.info("Folder {0} is not empty. Some files were not moved". + format(os.path.join(folderpath, d))) + + else: + '''If there is a file in srcfolder, should we also deal with it? In a + sane folder structure, this should not happen. Or am I wrong? + move(os.path.join(folderpath, d), + os.join(dstfolder.root, dstfolder.name, f))''' + self.ui.ignorecopyingmessage(self._parse_filename(d)['UID'], + srcfolder, dstfolder) + + if movecontent and len(os.listdir(folderpath)) == 0 \ + and not dryrun: + os.rmdir(folderpath) + elif movecontent and not dryrun: + self.ui.info("Folder {0} is not empty. Some files were not moved.". + format(folderpath)) diff --git a/offlineimap/init.py b/offlineimap/init.py index e82d808..b31f7d1 100644 --- a/offlineimap/init.py +++ b/offlineimap/init.py @@ -628,6 +628,194 @@ class OfflineImap(object): prof.dump_stats(os.path.join( profiledir, "%s_%s.prof"% (dt, account.getname()))) + def __updateconf(self, list_oldaccounts, list_newaccounts, profiledir): + """Executed only in singlethreaded mode. + + For each account found in new config source file, + get folder structure from both files and change + local repository accordingly. + + Assumes that list_newaccounts are only existing account + in old config file, and that config_filename and newconfig_filename + are defined. + + :param accs: A list of accounts that should be synced + """ + if profiledir: + self.ui.error("Profile mode in config update is not implemented yet!") + # Profile mode. + raise NotImplementedError + + self.ui.updateconf(self.config_filename, self.newconfig_filename) + + # For each account in new config file, initiate both + # account (old and new). Then set a temporary dir for + # the new folder structure + for accountname in list_newaccounts: + updatedone = False + self.ui.updateconfacct(accountname) + + try: + # Disable remote folder creation + conf_account_remoterepos = 'Repository ' + \ + self.newconfig.get("Account " + accountname, + 'remoterepository') + self.config.set(conf_account_remoterepos, + 'createfolders', + 'False') + self.newconfig.set(conf_account_remoterepos, + 'createfolders', + 'False') + localrepos = 'Repository ' + \ + self.config.get("Account " + accountname, + 'localrepository') + newconf_localfolders = os.path.expanduser( + self.newconfig.get(localrepos, 'localfolders')) + failedupdaterestore = os.path.expanduser( + self.newconfig.get('general', 'failedupdaterestore')) + + # Set temporary dir for dryrun. Otherwise, old and new accounts + # will point to the same dir. + if self.newconfig.getboolean('general', 'dry-run'): + if failedupdaterestore: + newtmpmetadatadir = failedupdaterestore + else: + tmpfolder_name = '.tmp-update-dryrun' + metadatadir = os.path.expanduser(self.newconfig. + getdefault("general", + "metadata", + "~/.offlineimap")) + tmpfolder = os.path.join(metadatadir, + tmpfolder_name) + if not os.path.exists(tmpfolder): + os.makedirs(tmpfolder, 0o700) + newtmplocalfolders = os.path.join(metadatadir, + tmpfolder, + os.path.basename(newconf_localfolders)) + + newconf_localrepos = 'Repository ' + \ + self.config.get("Account " + accountname, + 'localrepository') + self.newconfig.set(newconf_localrepos, + 'localfolders', + newtmplocalfolders) + newtmpmetadatadir = os.path.join(metadatadir, + tmpfolder, + 'metadata') + self.newconfig.set('general', 'metadata', newtmpmetadatadir) + + threading.currentThread().name = \ + "Account getfolder %s"% accountname + + ### Old account + oldaccount = accounts.SyncableAccount(self.config, + accountname) + oldaccount.syncrunner() + + ### Proceed to update + # Check for CTRL-C or SIGTERM (not sure if it's ok). + if oldaccount.abort_NOW_signal.is_set(): + break + + # Backup metadata and point old account to it + metadatadir = os.path.expanduser(oldaccount.metadatadir) + metadatabak = os.path.join(metadatadir, + "UpdateBackup_" + accountname) + oldaccount.movemetadatadir(metadatabak) + + # Move oldlocalrepo to a backup folder + from datetime import datetime + updatetime = datetime.today().strftime('%y%m%d.%H%M') + maildir = oldaccount.localrepos.root + mailbak = self.getbackupname( + os.path.join(metadatabak, '{0}.{1}'.format( + os.path.basename(maildir), updatetime))) + oldaccount.localrepos.moveroot(mailbak) + oldaccount.localrepos.forgetfolders() + oldaccount.localrepos.getfolders() + + ### New account + if failedupdaterestore \ + and not self.newconfig.getboolean('general', 'dry-run'): + from shutil import move + move(os.path.join(failedupdaterestore, + os.path.basename(newconf_localfolders)), + newconf_localfolders) + for item in os.listdir(failedupdaterestore): + s = os.path.join(failedupdaterestore, item) + d = os.path.join(metadatadir, item) + move(s, d) + os.rmdir(failedupdaterestore) + + newaccount = accounts.SyncableAccount(self.newconfig, + accountname) + newaccount.syncrunner() + + # Check for CTRL-C or SIGTERM (not sure if it's ok). + if newaccount.abort_NOW_signal.is_set(): + break + + # Create the new folder structure + newaccount.remoterepos.sync_folder_structure(newaccount.localrepos, + newaccount.statusrepos) + # Moving old content into new structure + newaccount.get_content_from_account(oldaccount) + + except: + try: + newaccount + except: + pass + else: + # Backup failed update metadata + failedmetadatabak = self.getbackupname( + os.path.join(os.path.expanduser(newaccount.metadatadir), + "FailedUpdate_{0}.{1}". + format(accountname, updatetime))) + newaccount.movemetadatadir(failedmetadatabak) + # Backup failed update maildir + mailbasename = os.path.basename(newaccount.localrepos.root) + failedmaildirbak = os.path.join(failedmetadatabak, mailbasename) + newaccount.localrepos.moveroot(failedmaildirbak) + try: + oldaccount + except: + pass + else: + # Move back old account maildir and metadata to their original dir + if oldaccount.metadatadir != metadatadir: + oldaccount.movemetadatadir(metadatadir) + try: + oldaccount.localrepos + except: + pass + else: + if oldaccount.localrepos.root != maildir: + oldaccount.localrepos.moveroot(maildir) + raise + + finally: + oldaccount._unlock() + newaccount._unlock() + if self.newconfig.getboolean('general', 'dry-run') \ + and os.path.exists(tmpfolder): + from shutil import rmtree + try: + rmtree(tmpfolder) + except IOError: + raise #TODO Message error + self.ui.updateconfacctdone(accountname) + + self.ui.updateconfdone() + + def getbackupname(self, folder): + i = 0 + while os.path.exists(folder): + i += 1 + folder = '{0}.{1}'.format(folder, i) + return folder + + def __serverdiagnostics(self, options): self.ui.info(" imaplib2: %s (%s)"% (imaplib.__version__, imaplib.DESC)) for accountname in self._get_activeaccounts(options): -- 2.11.0 From 4398c0b3df24c5d2e9ee3c4a467f1d9fc6b9a7b5 Mon Sep 17 00:00:00 2001 From: Hubert Pineault <hpinea...@riseup.net> Date: Wed, 27 Feb 2019 22:59:35 -0500 Subject: [PATCH 7/7] Get content from old folder structure and copy it to new structure Two new methods are added. this is where the actual update is done. SyncableAccount.get_content_from_account(oldaccount, move): Called by the newaccount to get content from oldaccount. Loop through remote folders, skiping ignored folders and folders not yet synced in oldaccount maildir structure. For each folder, get old and new local folder names, then call self.__get_folder_content(). SyncableAccount.__get_folder_content(oldaccount, old_localfolder, new_localfolder, new_remotefolder, movecontent): Initialize message list from both local folders (old and new) and run folder.Base.syncmessagesto(). Unfortunatly, this is not much faster than downloading the whole account from imap. I've tried writing a new method for moving files instead of copying messages by adapting __syncmessagesto_copy(), but it wasn't much faster either. I need to know more about mbnames to improve performance. I little help on this subject would be greatly appreciated. Signed-off-by: Hubert Pineault <hpinea...@riseup.net> --- offlineimap/accounts.py | 134 ++++++++++++++++++++++++++++++++++ offlineimap/folder/Maildir.py | 165 +++++++++++++++++++++++++++--------------- 2 files changed, 239 insertions(+), 60 deletions(-) diff --git a/offlineimap/accounts.py b/offlineimap/accounts.py index 0ae8b0d..6544b98 100644 --- a/offlineimap/accounts.py +++ b/offlineimap/accounts.py @@ -559,6 +559,140 @@ class SyncableAccount(Account): hook = self.getconf('postupdateconfhook', '') # Is this right? self.callhook(hook) + def get_content_from_account(self, oldaccount): + newaccount = self + + old_remote_hash, new_remote_hash = {}, {} + old_local_hash, new_local_hash = {}, {} + + for folder in oldaccount.remoterepos.getfolders(): + old_remote_hash[folder.getimapname()] = folder + + for folder in oldaccount.localrepos.getfolders(): + old_local_hash[folder.getname()] = folder + + for folder in newaccount.remoterepos.getfolders(): + new_remote_hash[folder.getimapname()] = folder + + for folder in newaccount.localrepos.getfolders(): + new_local_hash[folder.getname()] = folder + + # Loop through remote folder and get local folder correspondance + for new_remote_imapname, new_remote_folder in new_remote_hash.items(): + if not new_remote_folder.sync_this: + self.ui.debug('', "Ignoring filtered folder in new config '%s'" + "[%s]"% (new_remote_folder.getname(), + newaccount.remoterepos)) + continue # Ignore filtered folder. + if not new_remote_imapname in old_remote_hash.keys(): + self.ui.debug('', "Ignoring filtered folder in old config '%s'" + "[%s]"% (new_remote_folder.getname(), + newaccount.remoterepos)) + continue # Ignore filtered folder. + + # Apply old remote nametrans and fix serparator. + old_remote_folder = old_remote_hash[new_remote_imapname] + old_local_name = old_remote_folder.getvisiblename().replace( + oldaccount.remoterepos.getsep(), + oldaccount.localrepos.getsep()) + if old_local_name not in old_local_hash.keys(): + self.ui.debug('', "Ignoring unsynced folder '%s'" + "[%s]"% (new_remote_folder.getname(), + newaccount.remoterepos)) + continue # Ignore unsynced folder. + old_local_folder = oldaccount.get_local_folder(old_remote_folder) + + # Check for CTRL-C or SIGTERM (not sure if it's ok). + if (oldaccount.abort_NOW_signal.is_set() + or newaccount.abort_NOW_signal.is_set()): + break + + if not newaccount.localrepos.getconfboolean('readonly', False): + if not newaccount.dryrun: + new_local_folder = newaccount.get_local_folder(new_remote_folder) + else: + new_local_folder = new_remote_folder.getvisiblename().replace( + newaccount.remoterepos.getsep(), + newaccount.localrepos.getsep()) + self.__get_folder_content(oldaccount, old_local_folder, + new_local_folder, new_remote_folder) + + newaccount.localrepos.restore_atime() + mbnames.writeIntermediateFile(self.name) # Write out mailbox names. + + def __get_folder_content(self, oldaccount, old_localfolder, + new_localfolder, new_remotefolder): + """Get the content from old_local_folder""" + + newaccount = self + dststatusrepos = newaccount.statusrepos + srcstatusrepos = oldaccount.statusrepos + remoterepos = newaccount.remoterepos + movecontent = self.config.getboolean('general', 'movecontent') + old_localfolder_name = old_localfolder.name + new_localfolder_name = new_localfolder if self.dryrun \ + else new_localfolder.name + + newaccount.ui.getfoldercontent(old_localfolder_name, + new_localfolder_name, + movecontent) + + if self.dryrun: + newaccount.ui.getfoldercontentdone(old_localfolder_name) + return + + # Load status folders. + srcstatusfolder = srcstatusrepos.getfolder(old_localfolder_name. + replace(old_localfolder.getsep(), + srcstatusrepos.getsep())) + srcstatusfolder.openfiles() + dststatusfolder = dststatusrepos.getfolder(new_remotefolder.getvisiblename(). + replace(remoterepos.getsep(), dststatusrepos.getsep())) + dststatusfolder.openfiles() + + #TODO: check that local folder does not contain local sep + ''' # The remote folder names must not have the local sep char in + # their names since this would cause troubles while converting + # the name back (from local to remote). + sep = localrepos.getsep() + if (sep != os.path.sep and + sep != remoterepos.getsep() and + sep in remotefolder.getname()): + self.ui.warn('', "Ignoring folder '%s' due to unsupported " + "'%s' character serving as local separator."% + (remotefolder.getname(), localrepos.getsep())) + continue # Ignore unsupported folder name.''' + + try: + # Add the folder to the mbnames mailboxes. + mbnames.add(newaccount.name, newaccount.localrepos.root, + new_localfolder.getname()) + + # At this point, is this test necessary? + if not newaccount.localrepos.getconfboolean('readonly', False): + old_localfolder.sendcontentto(new_localfolder, srcstatusfolder, dststatusfolder, movecontent) + else: + self.ui.debug('', "Not sending content to read-only repository '%s'"% + newaccount.localrepos.getname()) + + newaccount.localrepos.restore_atime() + + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + self.ui.error(e, msg="ERROR while getting folder content for %s: %s"% + (new_localfolder.getvisiblename(), traceback.format_exc())) + raise # Raise unknown Exceptions so we can fix them. + else: + newaccount.ui.getfoldercontentdone() + finally: + for folder in ["old_localfolder", "new_localfolder"]: + if folder in locals(): + locals()[folder].dropmessagelistcache() + for folder in ["srcstatusfolder", "dststatusfolder"]: + if folder in locals(): + locals()[folder].closefiles() + #XXX: This function should likely be refactored. This should not be #passed the # account instance. diff --git a/offlineimap/folder/Maildir.py b/offlineimap/folder/Maildir.py index bab8284..b54879d 100644 --- a/offlineimap/folder/Maildir.py +++ b/offlineimap/folder/Maildir.py @@ -552,69 +552,114 @@ class MaildirFolder(BaseFolder): " Neither `%s' nor `%s' found") % (filename, oldfmd5, self._foldermd5)) - def sendcontentto(self, dstfolder, movecontent=False, - dryrun=False): - from shutil import move, copy2 + def sendcontentto(self, dstfolder, srcstatusfolder, dststatusfolder, + movecontent=False, dryrun=False): + '''We avoid using Maildir.cachemessagelist() in order to drastically + improve performance. Instead, we use statusrepos and + listdir().''' + from shutil import move, copy2, rmtree srcfolder = self - folderpath = os.path.join(srcfolder.root, srcfolder.name) - - for d in os.listdir(folderpath): - if os.path.isdir(os.path.join(folderpath, d)): - messages = os.listdir(os.path.join(folderpath, d)) num - = 0 totalnum = len(messages) - self.ui.nbmessagestosend(os.path.join(dstfolder.name, - d), - totalnum, movecontent) - - for f in messages: - #TODO: Show progression - - if os.path.isdir(os.path.join(folderpath, d, f)): - '''There should not be any folder in here. If - there is, should we also deal with it? In a - sane folder structure, this should not - happen. Or am I wrong?''' - - self.ui.info("A folder was found in source - folder '{0}'" - "while trying to move its - content. Ignoring" "'{1}'. This - indicate that the original" - "folder structure is probably - corrupt in" "some way. Please - investigate.", - format(os.path.join(srcfolder.name, - d), f)) - continue + srcstatusfolder.cachemessagelist() + dststatusfolder.cachemessagelist() + srcfolderpath = os.path.join(srcfolder.root, srcfolder.name) + dstfolderpath = os.path.join(dstfolder.root, dstfolder.name) + savemsgtostatusfolder = True + + # To speed up process, when dststatusfolder is empty, we copy status + # file and test on maildir folder for existing messages. If + # dststatusfolder is not empty, we procede to savemessage. + if len(dststatusfolder.getmessageuidlist()) == 0 \ + and len(srcstatusfolder.getmessageuidlist()) > 0: + savemsgtostatusfolder = False + + if savemsgtostatusfolder: + sendmsglist = [uid for uid in srcstatusfolder.getmessageuidlist() + if not dststatusfolder.uidexists(uid)] + else: + sendmsglist = srcstatusfolder.getmessageuidlist() + num_of_msg = len(sendmsglist) - dstfile = os.path.join(dstfolder.root, - dstfolder.name, d, f) if movecontent: - #TODO: Prevent or ask file overwrite - move(os.path.join(folderpath, d, f), - dstfile) else: - num += 1 if os.path.exists(dstfile): - self.ui.ignorecopyingmessage(f, srcfolder, - dstfolder) continue - if not dryrun: - copy2(os.path.join(folderpath, d, f), - dstfile) - - if (movecontent - and len(os.listdir(os.path.join(folderpath, d))) == - 0 - and not dryrun): - os.rmdir(os.path.join(folderpath, d)) - elif movecontent and not dryrun: - self.ui.info("Folder {0} is not empty. Some files - were not moved". - format(os.path.join(folderpath, d))) + if not savemsgtostatusfolder and num_of_msg > 0: + try: + dststatusfolder.closefiles() + copy2(srcstatusfolder.filename, dststatusfolder.filename) + except OSError: + savemsgtostatusfolder = True + pass - else: - '''If there is a file in srcfolder, should we also deal - with it? In a sane folder structure, this should not - happen. Or am I wrong? move(os.path.join(folderpath, - d), - os.join(dstfolder.root, dstfolder.name, f))''' - self.ui.ignorecopyingmessage(self._parse_filename(d)['UID'], - srcfolder, dstfolder) - - if movecontent and len(os.listdir(folderpath)) == 0 \ + self.ui.nbmessagestosend(dstfolder.name, num_of_msg, movecontent) + + filelist = {} + try: + for f in os.listdir(os.path.join(srcfolderpath, 'cur')) \ + + os.listdir(os.path.join(srcfolderpath, 'new')): + uidmatch = re_uidmatch.search(f) + if uidmatch: + filelist[int(uidmatch.group(1))] = f + except OSError: + pass + + #TODO : + # -Ignore UIDs??? + + for num, uid in enumerate(sendmsglist): + #TODO: Bail out on CTRL-C or SIGTERM. + '''if offlineimap.accounts.Account.abort_NOW_signal.is_set(): + break''' + try: + # Should we check that UID > 0? + # With Maildir, there shouldn't be any UID = 0, or am I wrong? + num += 1 + filename = filelist[uid] + flags = srcstatusfolder.getmessageflags(uid) + dir_prefix = 'cur' if 'S' in flags else 'new' + srcfilepath = os.path.join(srcfolderpath, dir_prefix, filename) + dstfilepath = os.path.join(dstfolderpath, dir_prefix, filename) + + self.ui.sendingmessage(uid, num, num_of_msg, + srcfolder, dstfolder, movecontent) + + if dryrun: + continue + if movecontent: + #TODO: Prevent or ask file overwrite + move(srcfilepath, dstfilepath) + else: + if os.path.exists(dstfilepath): + self.ui.ignorecopyingmessage(filename, srcfolder, dstfolder) + elif not dryrun: + copy2(srcfilepath, dstfilepath) + except Exception as e: + self.ui.info( + "Error while sending content of folder {0}, to {1} : '{2}'". + format(srcfolder.name, dstfolder.name, e)) + raise + + if savemsgtostatusfolder: + labels = srcstatusfolder.getmessagelabels(uid) + # Should we save rtime and mtime? + dststatusfolder.savemessage(uid, None, flags, 0, 0, labels) + dststatusfolder.save() + + for folder in ["srcfolder", "srcstatusfolder", "dststatusfolder"]: + if folder in locals(): + locals()[folder].dropmessagelistcache() + + if num_of_msg == 0: + num = 0 + + if movecontent \ + and num == num_of_msg \ and not dryrun: - os.rmdir(folderpath) + try: + rmtree(srcfolderpath) + except OSError: + self.ui.warn( + "Error while removing source folder {0}, but no \ + messages were left in it.".format(srcfolder.root)) + pass elif movecontent and not dryrun: - self.ui.info("Folder {0} is not empty. Some files were not - moved.". - format(folderpath)) + self.ui.warn( + "Folder {0} is not empty. Some files were not moved. \ + Please investigate source statusfolder.".format(srcfolder.root)) -- 2.11.0 _______________________________________________ OfflineIMAP-project mailing list: OfflineIMAP-project@alioth-lists.debian.net https://alioth-lists.debian.net/cgi-bin/mailman/listinfo/offlineimap-project OfflineIMAP homepages: - https://github.com/OfflineIMAP - http://offlineimap.org