"""Tidy a messy Downloads-style folder.
Each loose file in FOLDER moves to FOLDER/<type>/<year-month>/<name>, where type
is PDFs, Images, Excel or Others (from the extension) and year-month comes from
the file's last-modified date. A name that is already taken gets " (2)", " (3)"
added, so nothing is overwritten. Subfolders and unfinished downloads are left
alone. Each move is added to FOLDER/move_log.csv right after it happens.
Run: python organize_files.py FOLDER --dry-run (print the plan, move nothing)
python organize_files.py FOLDER (move the files, write the log)
"""
import argparse
import csv
import shutil
from collections import Counter
from datetime import datetime
from pathlib import Path
TYPES = {
"PDFs": {".pdf"},
"Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"},
"Excel": {".xlsx", ".xls", ".xlsm", ".csv"},
}
LOG_NAME = "move_log.csv"
SKIP_NAMES = {LOG_NAME, "desktop.ini", "thumbs.db"}
UNFINISHED = (".crdownload", ".part", ".tmp")
def file_type(path):
extension = path.suffix.lower()
return next((name for name, exts in TYPES.items() if extension in exts), "Others")
def free_name(wanted, taken):
"""Return wanted, or 'name (2).ext', 'name (3).ext' ... if that name is in use."""
candidate, number = wanted, 1
while candidate.exists() or candidate in taken:
number += 1
candidate = wanted.with_name(f"{wanted.stem} ({number}){wanted.suffix}")
return candidate
def make_plan(folder):
"""Return (moves, skipped). Each move is (source, destination, renamed)."""
moves, skipped, taken = [], [], set()
for source in sorted((p for p in folder.iterdir() if p.is_file()), key=lambda p: p.name.lower()):
if source.name.lower() in SKIP_NAMES or source.name.lower().endswith(UNFINISHED):
skipped.append(source.name)
continue
month = datetime.fromtimestamp(source.stat().st_mtime).strftime("%Y-%m")
wanted = folder / file_type(source) / month / source.name
destination = free_name(wanted, taken)
taken.add(destination)
moves.append((source, destination, destination != wanted))
return moves, skipped
def move_files(moves, folder):
"""Move each file and write its log row right after the move."""
log_path = folder / LOG_NAME
new_log = not log_path.exists()
with open(log_path, "a", newline="", encoding="utf-8") as log:
writer = csv.writer(log)
if new_log:
writer.writerow(["moved_at", "from", "to", "renamed"])
for source, destination, renamed in moves:
if destination.exists(): # last check just before the move
raise SystemExit(f"Stopped: {destination} appeared during the run")
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.move(source, destination)
writer.writerow([datetime.now().isoformat(timespec="seconds"), source.name,
destination.relative_to(folder).as_posix(), "yes" if renamed else "no"])
def main():
parser = argparse.ArgumentParser(description="Tidy a Downloads-style folder.")
parser.add_argument("folder", type=Path)
parser.add_argument("--dry-run", action="store_true", help="only print the plan")
args = parser.parse_args()
folder = args.folder.resolve()
moves, skipped = make_plan(folder)
print("DRY RUN: only the plan, nothing is moved" if args.dry_run else "REAL RUN")
for source, destination, renamed in moves:
note = " (renamed: name taken)" if renamed else ""
print(f" {source.name:<23} -> {destination.relative_to(folder).as_posix()}{note}")
if moves and not args.dry_run:
move_files(moves, folder)
print(f"Log: {len(moves)} rows added to {LOG_NAME}")
counts = Counter(destination.relative_to(folder).parts[0] for _, destination, _ in moves)
by_type = ", ".join(f"{name} {counts[name]}" for name in [*TYPES, "Others"] if counts[name])
print(f"{'Would move' if args.dry_run else 'Moved'} {len(moves)} files ({by_type or 'none'})."
f" Renamed {sum(r for _, _, r in moves)} to avoid overwriting.")
print(f"Skipped: {', '.join(skipped) or 'none'}")
if __name__ == "__main__":
main()