@daniel daniel / resolve_conflicts.py
Created at Thu Jul 22 18:46:37 CEST 2021
Resolve Nextcloud sync conflicts
resolve_conflicts.py
Raw
  1. #!/usr/bin/python3
  2. # Resolve file conflicts introduced by Nextcloud syncs.
  3. # Daniel Vedder, 22/7/2021
  4.  
  5. import os
  6. import re
  7. import sys
  8.  
  9. # explain to the user what's going on
  10. helptext = """
  11. usage: ./resolve_conflicts.py <folder>
  12.  
  13. This script helps you clean up conflicts that sometimes occur when Nextcloud syncs.
  14.  
  15. Within synced folders, Nextcloud keeps track of whether and when you change any
  16. files. If you change a file on your local client but not in the cloud, then the
  17. new version of the file is uploaded the next time you sync your folder. If you
  18. change the version in the cloud (for example with another client, or the web
  19. interface) but not the one on your local client, the one from the cloud is
  20. downloaded and replaces the local version.
  21.  
  22. However, sometimes both versions, the one on your client and the one in the cloud,
  23. are changed between two synchronisations. (This can happen unexpectedly.) In this
  24. case, Nextcloud declares the cloud version to be the "correct" one and downloads
  25. it. However, so as not to lose any data, it doesn't simply delete the local version,
  26. but renames it to "example_file (conflicted copy 2021-07-22 174701).txt" (for
  27. example). It is then up to you to choose which file is the one you want to keep,
  28. and rename/delete the two conflicting files as appropriate.
  29.  
  30. This script helps you do that. It scans the folder you tell it to (including all
  31. subfolders) and finds conflicting files. It then gives you a choice which version
  32. you want to keep, and takes care of the renaming or deleting. If you're not sure,
  33. you can simply skip a conflict and come back to it later. The script will ask for
  34. confirmation before deleting anything, so don't worry if you accidentally press a
  35. wrong number.
  36.  
  37. For help, contact Daniel Vedder: daniel@terranostra.one
  38. Licensed under the terms of the MIT license.
  39. """
  40.  
  41.  
  42. # specify a synced folder via the commandline
  43. syncdir = "example"
  44. if os.path.isdir(sys.argv[-1]): syncdir = sys.argv[-1]
  45.  
  46. # recursively iterate through the folder
  47. def deconflict_folder(folder):
  48. print("Deconflicting "+folder)
  49. for f in os.scandir(folder):
  50. # returns `DirEntry` objects (https://docs.python.org/3/library/os.html#os.DirEntry)
  51. if f.is_dir(): deconflict_folder(f.path)
  52. if "conflicted copy" in f.name: deconflict_file(f)
  53.  
  54. # If a conflict is found, list the conflicting files and ask the user which to keep
  55. def deconflict_file(dirent):
  56. # identify conflicting files
  57. realname = re.sub(" \(conflicted copy [-\s\w]+\)", "", dirent.name)
  58. realbasename = os.path.splitext(realname)[0]
  59. realfiletype = os.path.splitext(realname)[1]
  60. foldercontents = os.listdir(os.path.dirname(dirent.path))
  61. conflicts = [f for f in foldercontents
  62. if ((f == realname) or (realbasename in f and "conflicted copy" in f and re.search(realfiletype+"$", f) is not None))]
  63. if len(conflicts) < 2: return #perhaps the problem is already fixed
  64. # Ask the user what to do
  65. answer = user_choice(conflicts, realname)
  66. while (not answer.isdigit()) or (int(answer) < 1) or (int(answer) > len(conflicts)+1):
  67. print("Please answer with a number between 1 and "+str(len(conflicts)+1)+"!")
  68. answer = user_choice(conflicts, realname)
  69. while int(answer) <= len(conflicts) and input("Please confirm: delete all versions except '"+conflicts[int(answer)-1]+"'? (y/n) ") != "y":
  70. print("No confirmation. Please repeat your choice:")
  71. answer = user_choice(conflicts, realname)
  72. # Delete unwanted versions
  73. if int(answer) == len(conflicts)+1: return #skip this conflict
  74. correctversion = conflicts[int(answer)-1]
  75. for c in conflicts:
  76. deletefile = os.path.join(os.path.dirname(dirent.path), c)
  77. if c != correctversion: os.remove(deletefile)
  78. if correctversion != realname:
  79. oldname = os.path.join(os.path.dirname(dirent.path), correctversion)
  80. newname = os.path.join(os.path.dirname(dirent.path), realname)
  81. os.rename(oldname, newname)
  82.  
  83. # List all conflicting files and ask the user which to keep (or whether to skip)
  84. def user_choice(conflicts, realname):
  85. print("Found conflicting copies of "+realname+". What do you want to do?")
  86. for c in range(len(conflicts)):
  87. print(str(c+1)+") only keep "+conflicts[c])
  88. print(str(len(conflicts)+1)+") skip this conflict, don't change anything.")
  89. return input(">>> ")
  90.  
  91. if __name__ == '__main__':
  92. if sys.argv[-1] in ("-h", "--help"): print(helptext)
  93. elif os.path.exists(syncdir) and os.path.isdir(syncdir):
  94. deconflict_folder(syncdir)
  95. print("Done.")
  96. else: print("Please specify a valid directory path as a commanline argument.")