Folder Organizer with Undo
PythonAutomationSorts a messy Downloads-style folder into Type/Month subfolders. It runs as a dry run first and never deletes anything: duplicates are parked, name clashes are renamed, and a log lets you undo every move.
Run it
cd samples/file-organizer
python make_demo_folder.py
python organize.py demo_downloads # dry run
python organize.py demo_downloads --apply
python organize.py demo_downloads --undo
python test_organize.py # round-trip testFiles: organize.py · make_demo_folder.py · test_organize.py · README.md
Real run (--apply on the demo folder)
MOVE logo.png -> Images/2026-07/logo.png
MOVE notes.md -> Documents/2026-10/notes (1).md
MOVE mystery.bin -> Other/2026-10/mystery.bin
MOVE IMG_2041.jpg -> Images/2026-08/IMG_2041.jpg
MOVE Q3 sales.xlsx -> Spreadsheets/2026-09/Q3 sales.xlsx
MOVE contacts.csv -> Spreadsheets/2026-09/contacts.csv
MOVE setup-tool.exe -> Installers/2026-05/setup-tool.exe
MOVE site-backup.zip -> Archives/2026-10/site-backup.zip
MOVE podcast-ep12.mp3 -> Audio-Video/2026-06/podcast-ep12.mp3
MOVE IMG_2041 copy.jpg -> Duplicates/IMG_2041 copy.jpg [duplicate of IMG_2041.jpg]
MOVE invoice-march.pdf -> Documents/2026-03/invoice-march.pdf
MOVE meeting-notes.txt -> Documents/2026-10/meeting-notes.txt
moved 12 files; undo with: python organize.py demo_downloads --undo
Tested: PASS: 13 files in demo folder, dry run changed nothing, 12 moved (1 duplicate parked, 1 name clash renamed), undo restored all
Code
"""Tidy a messy folder: sort files into Type/YYYY-MM subfolders, rename duplicates safely,
and write an undo log so every move can be reversed.
Usage:
python organize.py FOLDER # dry run: shows the plan, changes nothing
python organize.py FOLDER --apply # move the files, write FOLDER/_organize_log.csv
python organize.py FOLDER --undo # put everything back using the log
Standard library only."""
import argparse, csv, datetime as dt, hashlib, pathlib, shutil, sys
TYPES = {"Documents": {".pdf", ".doc", ".docx", ".txt", ".md", ".odt", ".rtf"}, "Spreadsheets": {".xlsx", ".xls", ".csv", ".ods"},
"Images": {".jpg", ".jpeg", ".png", ".gif", ".heic", ".webp", ".svg"}, "Audio-Video": {".mp3", ".wav", ".mp4", ".mov", ".m4a"},
"Archives": {".zip", ".rar", ".7z", ".tar", ".gz"}, "Installers": {".exe", ".msi", ".dmg", ".pkg"}}
LOG = "_organize_log.csv"
def kind(p):
ext = p.suffix.lower()
return next((k for k, exts in TYPES.items() if ext in exts), "Other")
def digest(p):
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1 << 16), b""): h.update(chunk)
return h.hexdigest()
def plan(root):
moves, seen = [], {}
for p in sorted((x for x in root.iterdir() if x.is_file() and x.name != LOG and not x.name.startswith(".")), key=lambda x: (len(x.stem), x.name)): # shortest name = the original
month = dt.datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m")
dest_dir = root / kind(p) / month; dest = dest_dir / p.name
h = digest(p)
if h in seen: # exact duplicate content: park it in Duplicates/ instead of deleting
dest_dir = root / "Duplicates"; dest = dest_dir / p.name
seen.setdefault(h, p)
n = 1; taken = {m[1] for m in moves}
while dest.exists() or dest in taken:
dest = dest_dir / f"{p.stem} ({n}){p.suffix}"; n += 1
moves.append((p, dest, "duplicate of " + seen[h].name if seen[h] != p else ""))
return moves
def main():
a = argparse.ArgumentParser(); a.add_argument("folder"); g = a.add_mutually_exclusive_group(); g.add_argument("--apply", action="store_true"); g.add_argument("--undo", action="store_true")
args = a.parse_args(); root = pathlib.Path(args.folder).resolve(); log = root / LOG
if args.undo:
if not log.exists(): sys.exit("no log found, nothing to undo")
rows = list(csv.DictReader(open(log)))
for r in reversed(rows):
src, dst = root / r["moved_to"], root / r["original"]
if src.exists() and not dst.exists(): shutil.move(src, dst)
for d in sorted({(root / r["moved_to"]).parent for r in rows}, key=lambda d: -len(d.parts)):
while d != root and d.exists() and not any(d.iterdir()): d.rmdir(); d = d.parent
log.unlink(); print(f"undo: restored {len(rows)} files"); return
moves = plan(root)
for src, dst, note in moves: print(f"{'MOVE' if args.apply else 'PLAN'} {src.name:34} -> {dst.relative_to(root)}" + (f" [{note}]" if note else ""))
if not args.apply: print(f"dry run: {len(moves)} files would move. Add --apply to do it."); return
with open(log, "w", newline="") as f:
w = csv.writer(f); w.writerow(["original", "moved_to", "note"])
for src, dst, note in moves:
dst.parent.mkdir(parents=True, exist_ok=True); shutil.move(src, dst); w.writerow([src.name, str(dst.relative_to(root)), note])
print(f"moved {len(moves)} files; undo with: python organize.py {args.folder} --undo")
if __name__ == "__main__": main()