#!/usr/bin/env python3 """ Downloads Folder Sorter Rules are loaded from rules.toml — edit that file to add keywords/categories. Content match takes priority over filename match. Requires Python 3.11+ (uses built-in tomllib). """ import sys import shutil import tomllib import argparse from pathlib import Path # ── Optional deps (graceful fallback) ──────────────────────────────────────── try: import pdfplumber HAS_PDF = True except ImportError: HAS_PDF = False try: from docx import Document HAS_DOCX = True except ImportError: HAS_DOCX = False try: import openpyxl HAS_XLSX = True except ImportError: HAS_XLSX = False try: from odf import teletype from odf.opendocument import load as odf_load HAS_ODT = True except ImportError: HAS_ODT = False # ── Config loading ──────────────────────────────────────────────────────────── def load_config(config_path: Path) -> dict: if not config_path.exists(): print(f"Error: Config file not found: {config_path}") print("Make sure rules.toml is in the same folder as this script.") sys.exit(1) with open(config_path, "rb") as f: config = tomllib.load(f) return config def validate_config(config: dict): if "categories" not in config or not config["categories"]: print("Error: 'categories' is missing or empty in rules.toml") sys.exit(1) for cat in config["categories"]: if "name" not in cat: print("Error: A category in rules.toml is missing a 'name' field.") sys.exit(1) # ── Text extraction ─────────────────────────────────────────────────────────── def extract_text_pdf(path: Path) -> str: if not HAS_PDF: return "" try: with pdfplumber.open(path) as pdf: return "\n".join(page.extract_text() or "" for page in pdf.pages) except Exception: return "" def extract_text_docx(path: Path) -> str: if not HAS_DOCX: return "" try: doc = Document(path) return "\n".join(p.text for p in doc.paragraphs) except Exception: return "" def extract_text_xlsx(path: Path) -> str: if not HAS_XLSX: return "" try: wb = openpyxl.load_workbook(path, read_only=True, data_only=True) parts = [] for ws in wb.worksheets: for row in ws.iter_rows(values_only=True): parts.append(" ".join(str(c) for c in row if c is not None)) return "\n".join(parts) except Exception: return "" def extract_text_odt(path: Path) -> str: if not HAS_ODT: return "" try: doc = odf_load(path) return teletype.extractText(doc.text) except Exception: return "" def extract_text_plain(path: Path) -> str: for enc in ("utf-8", "utf-16", "latin-1"): try: return path.read_text(encoding=enc) except Exception: continue return "" def extract_text(path: Path) -> str: ext = path.suffix.lower() if ext == ".pdf": return extract_text_pdf(path) elif ext in (".docx", ".doc"): return extract_text_docx(path) elif ext in (".xlsx", ".xls"): return extract_text_xlsx(path) elif ext == ".odt": return extract_text_odt(path) elif ext in (".txt", ".md"): return extract_text_plain(path) return "" # ── Classification ──────────────────────────────────────────────────────────── def classify(text: str, filename: str, categories: list, fallback: str) -> tuple: """ Returns (destination_folder, match_reason). Content keywords checked first, then filename keywords. Categories are evaluated in order — first match wins. """ text_low = text.lower() name_low = filename.lower() for cat in categories: for kw in cat.get("content_keywords", []): if str(kw).lower() in text_low: return cat["name"], f"content: '{kw}'" for cat in categories: for kw in cat.get("filename_keywords", []): if str(kw).lower() in name_low: return cat["name"], f"filename: '{kw}'" return fallback, "no match" # ── Core ────────────────────────────────────────────────────────────────────── def collect_files(downloads: Path, extensions: set) -> list: files = [] for entry in downloads.iterdir(): if entry.is_file() and entry.suffix.lower() in extensions: files.append(entry) return sorted(files) def plan_moves(files: list, downloads: Path, config: dict) -> list: categories = config["categories"] fallback = config.get("fallback_folder", "Other") moves = [] for f in files: print(f" Scanning: {f.name} ...", end=" ", flush=True) text = extract_text(f) dest_folder, reason = classify(text, f.name, categories, fallback) dest_path = downloads / dest_folder / f.name moves.append((f, dest_path, reason)) print(f"-> {dest_folder}/ ({reason})") return moves def print_plan(moves: list, downloads: Path): print("\n" + "=" * 70) print(" DRY-RUN PREVIEW") print("=" * 70) col_w = max(len(src.name) for src, _, _ in moves) + 2 for src, dst, reason in moves: rel = dst.relative_to(downloads) print(f" {src.name:<{col_w}} -> {rel} [{reason}]") print("=" * 70) def execute_moves(moves: list, downloads: Path) -> list: errors = [] for src, dst, _ in moves: try: dst.parent.mkdir(parents=True, exist_ok=True) final_dst = dst counter = 1 while final_dst.exists(): final_dst = dst.with_stem(f"{dst.stem}_{counter}") counter += 1 shutil.move(str(src), str(final_dst)) print(f" OK {src.name} -> {final_dst.relative_to(downloads)}") except Exception as e: errors.append((src, e)) print(f" ERR {src.name} -> {e}") return errors def check_deps() -> list: missing = [] if not HAS_PDF: missing.append("pdfplumber") if not HAS_DOCX: missing.append("python-docx") if not HAS_XLSX: missing.append("openpyxl") if not HAS_ODT: missing.append("odfpy") return missing # ── Entry point ─────────────────────────────────────────────────────────────── def main(): if sys.version_info < (3, 11): print("Error: Python 3.11 or newer is required (for built-in tomllib).") print(f" You have Python {sys.version}") sys.exit(1) parser = argparse.ArgumentParser( description="Sort ~/Downloads using rules defined in rules.toml" ) parser.add_argument( "--dir", default=str(Path.home() / "Downloads"), help="Path to Downloads folder (default: ~/Downloads)", ) parser.add_argument( "--config", default=None, help="Path to rules.toml (default: same folder as this script)", ) parser.add_argument( "--yes", "-y", action="store_true", help="Skip confirmation prompt and move immediately", ) parser.add_argument( "--list-rules", action="store_true", help="Print loaded categories and keywords, then exit", ) args = parser.parse_args() script_dir = Path(__file__).parent config_path = Path(args.config) if args.config else script_dir / "rules.toml" config = load_config(config_path) validate_config(config) extensions = set(config.get("supported_extensions", [ ".pdf", ".docx", ".doc", ".txt", ".xlsx", ".xls", ".md", ".odt" ])) if args.list_rules: print(f"\nConfig: {config_path}\n") for cat in config["categories"]: print(f" [folder] {cat['name']}") if cat.get("description"): print(f" {cat['description']}") if cat.get("content_keywords"): kws = ", ".join(str(k) for k in cat["content_keywords"]) print(f" content : {kws}") if cat.get("filename_keywords"): kws = ", ".join(str(k) for k in cat["filename_keywords"]) print(f" filename: {kws}") print() print(f" [folder] {config.get('fallback_folder', 'Other')} (catch-all)\n") return downloads = Path(args.dir).expanduser().resolve() if not downloads.is_dir(): print(f"Error: '{downloads}' is not a directory.") sys.exit(1) missing = check_deps() if missing: print("Warning: some content-extraction libraries are missing.") print(" Install them for full content scanning:") for m in missing: print(f" pip install {m}") print() print(f"Scanning: {downloads}") print(f"Rules: {config_path}\n") files = collect_files(downloads, extensions) if not files: print("No supported files found.") sys.exit(0) print(f"Found {len(files)} file(s). Classifying...\n") moves = plan_moves(files, downloads, config) print_plan(moves, downloads) if not args.yes: answer = input("\nProceed with moving files? [y/N] ").strip().lower() if answer != "y": print("Aborted. No files were moved.") sys.exit(0) print("\nMoving files...\n") errors = execute_moves(moves, downloads) print() if errors: print(f"Done with {len(errors)} error(s).") else: print("All files moved successfully.") if __name__ == "__main__": main()