← All samples

Folder Organizer with Undo

PythonAutomation

Sorts 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 test

Files: 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()