@daniel daniel / resolve_conflicts.py
Created at Thu Jul 22 18:46:37 CEST 2021
Resolve Nextcloud sync conflicts
resolve_conflicts.py
Raw
#!/usr/bin/python3
# Resolve file conflicts introduced by Nextcloud syncs.
# Daniel Vedder, 22/7/2021

import os
import re
import sys

# explain to the user what's going on
helptext = """
usage: ./resolve_conflicts.py <folder>

This script helps you clean up conflicts that sometimes occur when Nextcloud syncs.

Within synced folders, Nextcloud keeps track of whether and when you change any
files. If you change a file on your local client but not in the cloud, then the
new version of the file is uploaded the next time you sync your folder. If you
change the version in the cloud (for example with another client, or the web
interface) but not the one on your local client, the one from the cloud is
downloaded and replaces the local version.

However, sometimes both versions, the one on your client and the one in the cloud,
are changed between two synchronisations. (This can happen unexpectedly.) In this
case, Nextcloud declares the cloud version to be the "correct" one and downloads
it. However, so as not to lose any data, it doesn't simply delete the local version,
but renames it to "example_file (conflicted copy 2021-07-22 174701).txt" (for
example). It is then up to you to choose which file is the one you want to keep,
and rename/delete the two conflicting files as appropriate.

This script helps you do that. It scans the folder you tell it to (including all
subfolders) and finds conflicting files. It then gives you a choice which version
you want to keep, and takes care of the renaming or deleting. If you're not sure,
you can simply skip a conflict and come back to it later. The script will ask for
confirmation before deleting anything, so don't worry if you accidentally press a
wrong number.

For help, contact Daniel Vedder: daniel@terranostra.one
Licensed under the terms of the MIT license.
"""


# specify a synced folder via the commandline
syncdir = "example"
if os.path.isdir(sys.argv[-1]): syncdir = sys.argv[-1]

# recursively iterate through the folder
def deconflict_folder(folder):
    print("Deconflicting "+folder)
    for f in os.scandir(folder):
        # returns `DirEntry` objects (https://docs.python.org/3/library/os.html#os.DirEntry)
        if f.is_dir(): deconflict_folder(f.path)
        if "conflicted copy" in f.name: deconflict_file(f)

# If a conflict is found, list the conflicting files and ask the user which to keep
def deconflict_file(dirent):
    # identify conflicting files
    realname = re.sub(" \(conflicted copy [-\s\w]+\)", "", dirent.name)
    realbasename = os.path.splitext(realname)[0]
    realfiletype = os.path.splitext(realname)[1]
    foldercontents = os.listdir(os.path.dirname(dirent.path))
    conflicts = [f for f in foldercontents
                 if ((f == realname) or (realbasename in f and "conflicted copy" in f and re.search(realfiletype+"$", f) is not None))]
    if len(conflicts) < 2: return #perhaps the problem is already fixed
    # Ask the user what to do
    answer = user_choice(conflicts, realname)
    while (not answer.isdigit()) or (int(answer) < 1) or (int(answer) > len(conflicts)+1):
        print("Please answer with a number between 1 and "+str(len(conflicts)+1)+"!")
        answer = user_choice(conflicts, realname)
    while int(answer) <= len(conflicts) and input("Please confirm: delete all versions except '"+conflicts[int(answer)-1]+"'? (y/n) ") != "y":
        print("No confirmation. Please repeat your choice:")
        answer = user_choice(conflicts, realname)
    # Delete unwanted versions
    if int(answer) == len(conflicts)+1: return #skip this conflict
    correctversion = conflicts[int(answer)-1]
    for c in conflicts:
        deletefile = os.path.join(os.path.dirname(dirent.path), c)
        if c != correctversion: os.remove(deletefile)
    if correctversion != realname:
        oldname = os.path.join(os.path.dirname(dirent.path), correctversion)
        newname = os.path.join(os.path.dirname(dirent.path), realname)
        os.rename(oldname, newname)

# List all conflicting files and ask the user which to keep (or whether to skip)
def user_choice(conflicts, realname):
    print("Found conflicting copies of "+realname+". What do you want to do?")
    for c in range(len(conflicts)):
        print(str(c+1)+") only keep "+conflicts[c])
    print(str(len(conflicts)+1)+") skip this conflict, don't change anything.")
    return input(">>> ")

if __name__ == '__main__':
    if sys.argv[-1] in ("-h", "--help"): print(helptext)
    elif os.path.exists(syncdir) and os.path.isdir(syncdir):
        deconflict_folder(syncdir)
        print("Done.")
    else: print("Please specify a valid directory path as a commanline argument.")