xybu/onedrived-dev

View on GitHub
onedrived/od_tasks/merge_dir.py

Summary

Maintainability
F
5 days
Test Coverage
import itertools
import logging
import os
import shutil

import onedrivesdk.error
from onedrivesdk import Item, Folder, ChildrenCollectionRequest
from send2trash import send2trash

from . import base
from . import delete_item, download_file, upload_file
from .. import mkdir, fix_owner_and_timestamp
from ..od_api_helper import get_item_modified_datetime, item_request_call
from ..od_dateutils import datetime_to_timestamp, diff_timestamps
from ..od_hashutils import hash_match, sha1_value
from ..od_repo import ItemRecordType


def rename_with_suffix(parent_abspath, name, host_name):
    suffix = ' (' + host_name + ')'
    parent_abspath = parent_abspath + '/'

    # Calculate the file name without suffix.
    ent_name, ent_ext = os.path.splitext(name)
    if ent_name.endswith(suffix):
        ent_name = ent_name[:-len(suffix)]

    new_name = ent_name + suffix + ent_ext
    if os.path.exists(parent_abspath + new_name):
        count = 1
        if ' ' in ent_name:
            ent_name, count_str = ent_name.rsplit(' ', maxsplit=1)
            if count_str.isdigit() and count_str[0] != '0':
                count = int(count_str) + 1
        new_name = ent_name + ' ' + str(count) + suffix + ent_ext
        while os.path.exists(parent_abspath + new_name):
            count += 1
            new_name = ent_name + ' ' + str(count) + suffix + ent_ext
    shutil.move(parent_abspath + name, parent_abspath + new_name)
    return new_name


def get_os_stat(path):
    try:
        return os.stat(path)
    except FileNotFoundError:
        return None


class MergeDirectoryTask(base.TaskBase):

    def __init__(self, repo, task_pool, rel_path, item_request, deep_merge=True,
                 assume_remote_unchanged=False, parent_remote_unchanged=False):
        """
        :param onedrived.od_repo.OneDriveLocalRepository repo:
        :param onedrived.od_task.TaskPool task_pool:
        :param str rel_path: Path of the target item relative to repository root. Assume not ending with '/'.
        :param onedrivesdk.request.item_request_builder.ItemRequestBuilder item_request:
        :param True | False deep_merge: If False, only sync files under the specified directory.
        :param True | False assume_remote_unchanged: If True, assume there is no change in remote repository.
            Can be set True if ctag and etag of the folder Item match its database record.
        :param True | False parent_remote_unchanged: If parent remote dir item wasn't changed.
        """
        super().__init__(repo, task_pool)
        self.rel_path = rel_path
        self.item_request = item_request
        self.local_abspath = repo.local_root + rel_path
        self.deep_merge = deep_merge
        self.assume_remote_unchanged = assume_remote_unchanged
        self.parent_remote_unchanged = parent_remote_unchanged

    def __repr__(self):
        return type(self).__name__ + '(%s, deep=%s, remote_unchanged=%s, parent_remote_unchanged=%s)' % (
            self.local_abspath, self.deep_merge, self.assume_remote_unchanged, self.parent_remote_unchanged)

    def list_local_names(self):
        """
        List all names under the task local directory.
        Try resolving naming conflict (same name case-INsensitive) as it goes.
        :return [str]: A list of entry names.
        """
        # TODO: This logic can be improved if remote info is provided.
        ents_orig = os.listdir(self.local_abspath)
        ents_lower = [s.lower() for s in ents_orig]
        ents_lower_uniq = set(ents_lower)
        if len(ents_orig) == len(ents_lower_uniq):
            return set(ents_orig)
        ents_ret = set()
        ents_ret_lower = set()
        for ent, ent_lower in zip(ents_orig, ents_lower):
            ent_abspath = self.local_abspath + '/' + ent
            if ent_lower in ents_ret_lower:
                ent_name, ent_ext = os.path.splitext(ent)
                count = 1
                new_ent = ent_name + ' ' + str(count) + ent_ext
                new_ent_lower = new_ent.lower()
                while new_ent_lower in ents_ret_lower or new_ent_lower in ents_lower_uniq:
                    count += 1
                    new_ent = ent_name + ' ' + str(count) + ent_ext
                    new_ent_lower = new_ent.lower()
                try:
                    shutil.move(ent_abspath, self.local_abspath + '/' + new_ent)
                    ents_ret.add(new_ent)
                    ents_ret_lower.add(new_ent_lower)
                except (IOError, OSError) as e:
                    logging.error('Error occurred when solving name conflict of "%s": %s.', ent_abspath, e)
                    continue
            else:
                ents_ret.add(ent)
                ents_ret_lower.add(ent_lower)
        return ents_ret

    def handle(self):
        if not os.path.isdir(self.local_abspath):
            logging.error('Error: Local path "%s" is not a directory.' % self.local_abspath)
            return

        self.repo.context.watcher.rm_watch(self.repo, self.local_abspath)

        try:
            all_local_items = self.list_local_names()
        except (IOError, OSError) as e:
            logging.error('Error merging dir "%s": %s.', self.local_abspath, e)
            return

        all_records = self.repo.get_immediate_children_of_dir(self.rel_path)

        if not self.assume_remote_unchanged or not self.parent_remote_unchanged:
            try:
                remote_item_page = item_request_call(self.repo, self.item_request.children.get)
                all_remote_items = remote_item_page

                while True:
                    # HACK: ChildrenCollectionPage is not guaranteed to have
                    # the _next_page_link attribute and
                    # ChildrenCollectionPage.get_next_page_request doesn't
                    # implement the check correctly
                    if not hasattr(remote_item_page, '_next_page_link'):
                        break

                    logging.debug('Paging for more items: %s', self.rel_path)
                    remote_item_page = item_request_call(
                        self.repo,
                        ChildrenCollectionRequest.get_next_page_request(
                            remote_item_page,
                            self.repo.authenticator.client).get)
                    all_remote_items = itertools.chain(
                        all_remote_items, remote_item_page)
            except onedrivesdk.error.OneDriveError as e:
                logging.error('Encountered API Error: %s. Skip directory "%s".', e, self.rel_path)
                return

            for remote_item in all_remote_items:
                remote_is_folder = remote_item.folder is not None
                all_local_items.discard(remote_item.name)  # Remove remote item from untouched list.
                if not self.repo.path_filter.should_ignore(self.rel_path + '/' + remote_item.name, remote_is_folder):
                    self._handle_remote_item(remote_item, all_local_items, all_records)
                else:
                    logging.debug('Ignored remote path "%s/%s".', self.rel_path, remote_item.name)

        for n in all_local_items:
            self._handle_local_item(n, all_records)

        for rec_name, rec in all_records.items():
            logging.info('Record for item %s (%s/%s) is dead. Delete it it.', rec.item_id, rec.parent_path, rec_name)
            self.repo.delete_item(rec_name, rec.parent_path, is_folder=rec.type == ItemRecordType.FOLDER)

        self.repo.context.watcher.add_watch(self.repo, self.local_abspath)

    def _rename_local_and_download_remote(self, remote_item, all_local_items):
        all_local_items.add(rename_with_suffix(self.local_abspath, remote_item.name, self.repo.context.host_name))
        self.task_pool.add_task(
            download_file.DownloadFileTask(self.repo, self.task_pool, remote_item, self.rel_path))

    def _handle_remote_file_with_record(self, remote_item, item_record, item_stat, item_local_abspath, all_local_items):
        """
        :param onedrivesdk.model.item.Item remote_item:
        :param onedrived.od_repo.ItemRecord item_record:
        :param posix.stat_result | None item_stat:
        :param str item_local_abspath:
        :param [str] all_local_items:
        """
        # In this case we have all three pieces of information -- remote item metadata, database record, and local inode
        # stats. The best case is that all of them agree, and the worst case is that they all disagree.

        if os.path.isdir(item_local_abspath):
            # Remote item is a file yet the local item is a folder.
            if item_record and item_record.type == ItemRecordType.FOLDER:
                # TODO: Use the logic in handle_local_folder to solve this.
                send2trash(item_local_abspath)
                self.repo.delete_item(remote_item.name, self.rel_path, True)
            else:
                # When db record does not exist or says the path is a file, then it does not agree with local inode
                # and the information is useless. We delete it and sync both remote and local items.
                if item_record:
                    self.repo.delete_item(remote_item.name, self.rel_path, False)
            return self._handle_remote_file_without_record(remote_item, None, item_local_abspath, all_local_items)

        remote_mtime, _ = get_item_modified_datetime(remote_item)
        local_mtime_ts = item_stat.st_mtime if item_stat else None
        remote_mtime_ts = datetime_to_timestamp(remote_mtime)
        record_mtime_ts = datetime_to_timestamp(item_record.modified_time)
        try:
            remote_sha1_hash = remote_item.file.hashes.sha1_hash
        except AttributeError:
            remote_sha1_hash = None

        if (remote_item.id == item_record.item_id and remote_item.c_tag == item_record.c_tag or
            remote_item.size == item_record.size and
                diff_timestamps(remote_mtime_ts, record_mtime_ts) == 0):
            # The remote item metadata matches the database record. So this item has been synced before.
            if item_stat is None:
                # The local file was synced but now is gone. Delete remote one as well.
                logging.debug('Local file "%s" is gone but remote item matches db record. Delete remote item.',
                              item_local_abspath)
                self.task_pool.add_task(delete_item.DeleteRemoteItemTask(
                    self.repo, self.task_pool, self.rel_path, remote_item.name, remote_item.id, False))
            elif (item_stat.st_size == item_record.size_local and
                  (diff_timestamps(local_mtime_ts, record_mtime_ts) == 0 or
                   remote_sha1_hash and remote_sha1_hash == sha1_value(item_local_abspath))):
                # If the local file matches the database record (i.e., same mtime timestamp or same content),
                # simply return. This is the best case.
                if diff_timestamps(local_mtime_ts, remote_mtime_ts) != 0:
                    logging.info('File "%s" seems to have same content but different timestamp (%f, %f). Fix it.',
                                 item_local_abspath, local_mtime_ts, remote_mtime_ts)
                    fix_owner_and_timestamp(item_local_abspath, self.repo.context.user_uid, remote_mtime_ts)
                    self.repo.update_item(remote_item, self.rel_path, item_stat.st_size)
            else:
                # Content of local file has changed. Because we assume the remote item was synced before, we overwrite
                # the remote item with local one.
                # API Issue: size field may not match file size.
                # Refer to https://github.com/OneDrive/onedrive-sdk-python/issues/88
                # Workaround -- storing both remote and local sizes.
                logging.debug('File "%s" was changed locally and the remote version is known old. Upload it.',
                              item_local_abspath)
                self.task_pool.add_task(upload_file.UploadFileTask(
                    self.repo, self.task_pool, self.item_request, self.rel_path, remote_item.name))
        else:
            # The remote file metadata and database record disagree.
            if item_stat is None:
                # If the remote file is the one on record, then the remote one is newer than the deleted local file
                # so it should be downloaded. If they are not the same, then the remote one should definitely
                # be kept. So the remote file needs to be kept and downloaded anyway.
                logging.debug('Local file "%s" is gone but remote item disagrees with db record. Download it.',
                              item_local_abspath)
                self.task_pool.add_task(
                    download_file.DownloadFileTask(self.repo, self.task_pool, remote_item, self.rel_path))
            elif item_stat.st_size == item_record.size_local and \
                    (diff_timestamps(local_mtime_ts, record_mtime_ts) == 0 or
                     item_record.sha1_hash and item_record.sha1_hash == sha1_value(item_local_abspath)):
                # Local file agrees with database record. This means that the remote file is strictly newer.
                # The local file can be safely overwritten.
                logging.debug('Local file "%s" agrees with db record but remote item is different. Overwrite local.',
                              item_local_abspath)
                self.task_pool.add_task(
                    download_file.DownloadFileTask(self.repo, self.task_pool, remote_item, self.rel_path))
            else:
                # So both the local file and remote file have been changed after the record was created.
                equal_ts = diff_timestamps(local_mtime_ts, remote_mtime_ts) == 0
                if (item_stat.st_size == remote_item.size and (
                        (equal_ts or remote_sha1_hash and remote_sha1_hash == sha1_value(item_local_abspath)))):
                    # Fortunately the two files seem to be the same.
                    # Here the logic is written as if there is no size mismatch issue.
                    logging.debug(
                        'Local file "%s" seems to have same content with remote but record disagrees. Fix db record.',
                        item_local_abspath)
                    if not equal_ts:
                        fix_owner_and_timestamp(item_local_abspath, self.repo.context.user_uid, remote_mtime_ts)
                    self.repo.update_item(remote_item, self.rel_path, item_stat.st_size)
                else:
                    # Worst case we keep both files.
                    logging.debug('Local file "%s" differs from db record and remote item. Keep both versions.',
                                  item_local_abspath)
                    self._rename_local_and_download_remote(remote_item, all_local_items)

    def _handle_remote_file_without_record(self, remote_item, item_stat, item_local_abspath, all_local_items):
        """
        Handle the case in which a remote item is not found in the database. The local item may or may not exist.
        :param onedrivesdk.model.item.Item remote_item:
        :param posix.stat_result | None item_stat:
        :param str item_local_abspath:
        :param [str] all_local_items:
        """
        if item_stat is None:
            # The file does not exist locally, and there is no record in database. The safest approach is probably
            # download the file and update record.
            self.task_pool.add_task(
                download_file.DownloadFileTask(self.repo, self.task_pool, remote_item, self.rel_path))
        elif os.path.isdir(item_local_abspath):
            # Remote path is file yet local path is a dir.
            logging.info('Path "%s" is a folder yet the remote item is a file. Keep both.', item_local_abspath)
            self._rename_local_and_download_remote(remote_item, all_local_items)
        else:
            # We first compare timestamp and size -- if both properties match then we think the items are identical
            # and just update the database record. Otherwise if sizes are equal, we calculate hash of local item to
            # determine if they are the same. If so we update timestamp of local item and update database record.
            # If the remote item has different hash, then we rename the local one and download the remote one so that no
            # information is lost.
            remote_mtime, remote_mtime_w = get_item_modified_datetime(remote_item)
            remote_mtime_ts = datetime_to_timestamp(remote_mtime)
            equal_ts = diff_timestamps(remote_mtime_ts, item_stat.st_mtime) == 0
            equal_attr = remote_item.size == item_stat.st_size and equal_ts
            # Because of the size mismatch issue, we can't use size not being equal as a shortcut for hash not being
            # equal. When the bug is fixed we can do it.
            if equal_attr or hash_match(item_local_abspath, remote_item):
                if not equal_ts:
                    logging.info('Local file "%s" has same content but wrong timestamp. '
                                 'Remote: mtime=%s, w=%s, ts=%s, size=%d. '
                                 'Local: ts=%s, size=%d. Fix it.',
                                 item_local_abspath,
                                 remote_mtime, remote_mtime_w, remote_mtime_ts, remote_item.size,
                                 item_stat.st_mtime, item_stat.st_size)
                    fix_owner_and_timestamp(item_local_abspath, self.repo.context.user_uid, remote_mtime_ts)
                self.repo.update_item(remote_item, self.rel_path, item_stat.st_size)
            else:
                self._rename_local_and_download_remote(remote_item, all_local_items)

    @staticmethod
    def _remote_dir_matches_record(remote_item, record):
        return record and record.type == ItemRecordType.FOLDER and record.size == remote_item.size and \
            record.c_tag == remote_item.c_tag and record.e_tag == remote_item.e_tag

    def _handle_remote_folder(self, remote_item, item_local_abspath, record, all_local_items):
        if not self.deep_merge:
            return
        try:
            remote_dir_matches_record = self._remote_dir_matches_record(remote_item, record)
            if os.path.isfile(item_local_abspath):
                # Remote item is a directory but local item is a file.
                if remote_dir_matches_record:
                    # The remote item is very LIKELY to be outdated.
                    logging.warning('Local path "%s" is a file but its remote counterpart is a folder which seems to '
                                    'be synced before. Will delete the remote folder. To restore it, go to '
                                    'OneDrive.com and move the folder out of Recycle Bin.', item_local_abspath)
                    delete_item.DeleteRemoteItemTask(
                        self.repo, self.task_pool, self.rel_path, remote_item.name, remote_item.id, True).handle()
                    self.task_pool.add_task(upload_file.UploadFileTask(
                        self.repo, self.task_pool, self.item_request, self.rel_path, remote_item.name))
                    return
                # If the remote metadata doesn't agree with record, keep both by renaming the local file.
                all_local_items.add(
                    rename_with_suffix(self.local_abspath, remote_item.name, self.repo.context.host_name))

            if not os.path.exists(item_local_abspath):
                if remote_dir_matches_record:
                    logging.debug('Local dir "%s" is gone but db record matches remote metadata. Delete remote dir.',
                                  item_local_abspath)
                    self.task_pool.add_task(delete_item.DeleteRemoteItemTask(
                        self.repo, self.task_pool, self.rel_path, remote_item.name, remote_item.id, True))
                    return
                # Local directory does not exist. Create it.
                logging.debug('Create missing directory "%s".', item_local_abspath)
                mkdir(item_local_abspath, uid=self.repo.context.user_uid, exist_ok=True)

            # The database is temporarily corrupted until the whole dir is merged. But unfortunately we returned early.
            self.repo.update_item(remote_item, self.rel_path, 0)
            self.task_pool.add_task(MergeDirectoryTask(
                repo=self.repo, task_pool=self.task_pool, rel_path=self.rel_path + '/' + remote_item.name,
                item_request=self.repo.authenticator.client.item(drive=self.repo.drive.id, id=remote_item.id),
                assume_remote_unchanged=remote_dir_matches_record,
                parent_remote_unchanged=self.assume_remote_unchanged))
        except OSError as e:
            logging.error('Error occurred when merging directory "%s": %s', item_local_abspath, e)

    def _handle_remote_item(self, remote_item, all_local_items, all_records):
        """
        :param onedrivesdk.model.item.Item remote_item:
        :param [str] all_local_items:
        :param dict(str, onedrived.od_repo.ItemRecord) all_records:
        """
        # So we have three pieces of information -- the remote item metadata, the record in database, and the inode
        # on local file system. For the case of handling a remote item, the last two may be missing.
        item_local_abspath = self.local_abspath + '/' + remote_item.name
        record = all_records.pop(remote_item.name, None)

        try:
            stat = get_os_stat(item_local_abspath)
        except OSError as e:
            logging.error('Error occurred when accessing path "%s": %s.', item_local_abspath, e)
            return

        if remote_item.folder is not None:
            return self._handle_remote_folder(remote_item, item_local_abspath, record, all_local_items)

        if remote_item.file is None:
            if stat:
                logging.info('Remote item "%s/%s" is neither a file nor a directory yet local counterpart exists. '
                             'Rename local item.', self.rel_path, remote_item.name)
                try:
                    new_name = rename_with_suffix(self.local_abspath, remote_item.name, self.repo.context.host_name)
                    all_local_items.add(new_name)
                except OSError as e:
                    logging.error('Error renaming "%s/%s": %s. Skip this item due to unsolvable type conflict.',
                                  self.rel_path, remote_item.name, e)
            else:
                logging.info('Remote item "%s/%s" is neither a file nor a directory. Skip it.',
                             self.rel_path, remote_item.name)
            return

        if record is None:
            self._handle_remote_file_without_record(remote_item, stat, item_local_abspath, all_local_items)
        else:
            self._handle_remote_file_with_record(remote_item, record, stat, item_local_abspath, all_local_items)

    def _handle_local_folder(self, item_name, item_record, item_local_abspath):
        """
        :param str item_name:
        :param onedrived.od_repo.ItemRecord | None item_record:
        :param str item_local_abspath:
        """
        if not self.deep_merge:
            return

        if self.repo.path_filter.should_ignore(self.rel_path + '/' + item_name, True):
            logging.debug('Ignored local directory "%s/%s".', self.rel_path, item_name)
            return

        if item_record is not None and item_record.type == ItemRecordType.FOLDER:
            if self.assume_remote_unchanged:
                rel_path = self.rel_path + '/' + item_name
                self.task_pool.add_task(MergeDirectoryTask(
                    repo=self.repo, task_pool=self.task_pool, rel_path=rel_path,
                    item_request=self.repo.authenticator.client.item(drive=self.repo.drive.id, path=rel_path),
                    assume_remote_unchanged=True, parent_remote_unchanged=self.assume_remote_unchanged))
            else:
                send2trash(item_local_abspath)
                self.repo.delete_item(item_name, self.rel_path, True)
            return
            # try:
            #     # If there is any file accessed after the time when the record was created, do not delete the dir.
            #     # Instead, upload it back.
            #     # As a note, the API will return HTTP 404 Not Found after the item was deleted. So we cannot know from
            #     # API when the item was deleted. Otherwise this deletion time should be the timestamp to use.
            #     # TODO: A second best timestamp is the latest timestamp of any children item under this dir.
            #     visited_files = subprocess.check_output(
            #         ['find', item_local_abspath, '-type', 'f',
            #         '(', '-newermt', item_record.record_time_str, '-o',
            #          '-newerat', item_record.record_time_str, ')', '-print'], universal_newlines=True)
            #     if visited_files == '':
            #         logging.info('Local item "%s" was deleted remotely and not used since %s. Delete it locally.',
            #                      item_local_abspath, item_record.record_time_str)
            #         send2trash(item_local_abspath)
            #         self.repo.delete_item(item_name, self.rel_path, True)
            #         return
            #     logging.info('Local directory "%s" was deleted remotely but locally used. Upload it back.')
            # except subprocess.CalledProcessError as e:
            #     logging.error('Error enumerating files in "%s" accessed after "%s": %s.',
            #                   item_local_abspath, item_record.record_time_str, e)
            # except OSError as e:
            #     logging.error('Error checking local folder "%s": %s.', item_local_abspath, e)
        elif item_record is not None:
            if self.assume_remote_unchanged:
                logging.info('Remote item for local dir "%s" is a file that has been deleted locally. '
                             'Delete the remote item and upload the file.', item_local_abspath)
                if not delete_item.DeleteRemoteItemTask(
                        repo=self.repo, task_pool=self.task_pool, parent_relpath=self.rel_path,
                        item_name=item_name, item_id=item_record.item_id, is_folder=False).handle():
                    logging.error('Failed to delete outdated remote directory "%s/%s" of Drive %s.',
                                  self.rel_path, item_name, self.repo.drive.id)
                    # Keep the record so that the branch can be revisited next time.
                    return

        # Either we decide to upload the item above, or the folder does not exist remotely and we have no reference
        # whether it existed remotely or not in the past. Better upload it back.
        logging.info('Local directory "%s" seems new. Upload it.', item_local_abspath)
        self.task_pool.add_task(CreateFolderTask(
            self.repo, self.task_pool, item_name, self.rel_path, True, True))

    def _handle_local_file(self, item_name, item_record, item_stat, item_local_abspath):
        """
        :param str item_name:
        :param onedrived.od_repo.ItemRecord | None item_record:
        :param posix.stat_result | None item_stat:
        :param str item_local_abspath:
        """
        if self.repo.path_filter.should_ignore(self.rel_path + '/' + item_name, False):
            logging.debug('Ignored local file "%s/%s".', self.rel_path, item_name)
            return

        if item_stat is None:
            logging.info('Local-only file "%s" existed when scanning but is now gone. Skip it.', item_local_abspath)
            if item_record is not None:
                self.repo.delete_item(item_record.item_name, item_record.parent_path, False)
                if self.assume_remote_unchanged:
                    self.task_pool.add_task(delete_item.DeleteRemoteItemTask(
                        repo=self.repo, task_pool=self.task_pool, parent_relpath=self.rel_path,
                        item_name=item_name, item_id=item_record.item_id, is_folder=False))
            return

        if item_record is not None and item_record.type == ItemRecordType.FILE:
            record_ts = datetime_to_timestamp(item_record.modified_time)
            equal_ts = diff_timestamps(item_stat.st_mtime, record_ts) == 0
            if item_stat.st_size == item_record.size_local and \
                    (equal_ts or item_record.sha1_hash and item_record.sha1_hash == sha1_value(item_local_abspath)):
                # Local file matches record.
                if self.assume_remote_unchanged:
                    if not equal_ts:
                        fix_owner_and_timestamp(item_local_abspath, self.repo.context.user_uid, record_ts)
                else:
                    logging.debug('Local file "%s" used to exist remotely but not found. Delete it.',
                                  item_local_abspath)
                    send2trash(item_local_abspath)
                    self.repo.delete_item(item_record.item_name, item_record.parent_path, False)
                return
            logging.debug('Local file "%s" is different from when it was last synced. Upload it.', item_local_abspath)
        elif item_record is not None:
            # Record is a dir but local entry is a file.
            if self.assume_remote_unchanged:
                logging.info('Remote item for local file "%s" is a directory that has been deleted locally. '
                             'Delete the remote item and upload the file.', item_local_abspath)
                if not delete_item.DeleteRemoteItemTask(
                        repo=self.repo, task_pool=self.task_pool, parent_relpath=self.rel_path,
                        item_name=item_name, item_id=item_record.item_id, is_folder=True).handle():
                    logging.error('Failed to delete outdated remote directory "%s/%s" of Drive %s.',
                                  self.rel_path, item_name, self.repo.drive.id)
                    # Keep the record so that the branch can be revisited next time.
                    return
            logging.debug('Local file "%s" is new to OneDrive. Upload it.', item_local_abspath)

        self.task_pool.add_task(upload_file.UploadFileTask(
            self.repo, self.task_pool, self.item_request, self.rel_path, item_name))

    def _handle_local_item(self, item_name, all_records):
        """
        :param str item_name:
        :param dict(str, onedrived.od_repo.ItemRecord) all_records:
        :return:
        """
        item_local_abspath = self.local_abspath + '/' + item_name
        record = all_records.pop(item_name, None)
        try:
            if os.path.isfile(item_local_abspath):
                # stat can be None because the function can be called long after dir is listed.
                stat = get_os_stat(item_local_abspath)
                self._handle_local_file(item_name, record, stat, item_local_abspath)
            elif os.path.isdir(item_local_abspath):
                self._handle_local_folder(item_name, record, item_local_abspath)
            else:
                logging.warning('Unsupported type of local item "%s". Skip it and remove record.', item_local_abspath)
                if record is not None:
                    self.repo.delete_item(record.item_name, record.parent_path, record.type == ItemRecordType.FOLDER)
        except OSError as e:
            logging.error('Error occurred when accessing path "%s": %s.', item_local_abspath, e)


class CreateFolderTask(base.TaskBase):

    def __init__(self, repo, task_pool, item_name, parent_relpath, upload_if_success=True, abort_if_local_gone=True):
        """
        :param onedrived.od_repo.OneDriveLocalRepository repo:
        :param onedrived.od_task.TaskPool task_pool:
        :param str item_name:
        :param str parent_relpath:
        :param True | False upload_if_success:
        :param True | False abort_if_local_gone:
        """
        super().__init__(repo, task_pool)
        self.item_name = item_name
        self.parent_relpath = parent_relpath
        self.local_abspath = repo.local_root + parent_relpath + '/' + item_name
        self.upload_if_success = upload_if_success
        self.abort_if_local_gone = abort_if_local_gone

    def __repr__(self):
        return type(self).__name__ + '(%s, upload=%s)' % (self.local_abspath, self.upload_if_success)

    @staticmethod
    def _get_folder_pseudo_item(item_name):
        item = Item()
        item.name = item_name
        item.folder = Folder()
        return item

    def _get_item_request(self):
        if self.parent_relpath == '':
            return self.repo.authenticator.client.item(drive=self.repo.drive.id, id='root')
        else:
            return self.repo.authenticator.client.item(drive=self.repo.drive.id, path=self.parent_relpath)

    def handle(self):
        logging.info('Creating remote item for local dir "%s".', self.local_abspath)
        try:
            if self.abort_if_local_gone and not os.path.isdir(self.local_abspath):
                logging.warning('Local dir "%s" is gone. Skip creating remote item for it.', self.local_abspath)
                return
            item = self._get_folder_pseudo_item(self.item_name)
            item_request = self._get_item_request()
            item = item_request_call(self.repo, item_request.children.add, item)
            self.repo.update_item(item, self.parent_relpath, 0)
            logging.info('Created remote item for local dir "%s".', self.local_abspath)
            if self.upload_if_success:
                logging.info('Adding task to merge "%s" after remote item was created.', self.local_abspath)
                self.task_pool.add_task(MergeDirectoryTask(
                    self.repo, self.task_pool, self.parent_relpath + '/' + self.item_name,
                    self.repo.authenticator.client.item(drive=self.repo.drive.id, id=item.id)))
            return True
        except (onedrivesdk.error.OneDriveError, OSError) as e:
            logging.error('Error when creating remote dir of "%s": %s.', self.local_abspath, e)
            return False