#!/usr/bin/env python3 """ our-gdrive-organizer — organize a Google Drive folder under OurDigital conventions. Scans the target directory (default: 2nd-level Drive subfolder), refreshes the README.md index, proposes renames + moves to bring files into convention, and optionally applies them. Usage: organizer.py [TARGET] # dry-run report organizer.py [TARGET] --apply # apply all proposed changes organizer.py [TARGET] --scope index # only refresh root README organizer.py [TARGET] --scope subreadmes # refresh per-subfolder READMEs organizer.py [TARGET] --scope rename # only propose/apply renames organizer.py [TARGET] --scope move # only propose/apply moves organizer.py [TARGET] --scope full # everything (default) organizer.py [TARGET] --json # machine-readable output If TARGET is omitted, uses the current working directory. The script is stdlib-only (Python 3.9+). Safe by default — never writes unless --apply is passed (or running --scope index, which always writes the README). """ from __future__ import annotations import argparse import json import os import re import shutil import sys from dataclasses import dataclass, field, asdict from datetime import datetime, timezone from pathlib import Path from typing import Iterable # --------------------------------------------------------------------------- # Configuration: naming convention enforcement. # Edit shared/conventions.md for the human-readable spec; this dict is the # source of truth for the script. # --------------------------------------------------------------------------- RENAME_RULES: list[tuple[re.Pattern[str], str, str]] = [ # (pattern, replacement, reason) # SEO context only — D.intelligence is the parent company; OurDigital is its # SEO-specialty child brand. Only apply these renames when operating inside # an SEO folder (02_SEO in Action, 04_SEO, etc.). Outside SEO context, keep # D.intelligence names intact (they belong to the parent company). The # script doesn't enforce this caveat — it relies on the caller pointing at # the right target. See shared/patterns/gotchas.md "D.intelligence vs # OurDigital" for the full rule. (re.compile(r"D\.intelligence Lab-", re.I), "OurDigital-", "parent-co SEO asset → OurDigital-"), (re.compile(r"D\.intelligence", re.I), "OurDigital", "parent-co SEO asset → OurDigital"), # Variant without the dot (typo / informal spelling). (re.compile(r"\bD intelligence\b", re.I), "OurDigital", "parent-co SEO asset (no-dot variant) → OurDigital"), ] # Files matching these patterns at the root of a top-level subfolder are # proposed to be moved into a sibling subfolder. MOVE_RULES: list[tuple[re.Pattern[str], str, str]] = [ (re.compile(r"^Screenshot \d{4}-\d{2}-\d{2}.*\.png$"), "screenshots", "screenshot file → screenshots/ subfolder"), (re.compile(r"^.*\.(crdownload|tmp|partial)$"), "_unsorted", "incomplete / temp file → _unsorted/"), ] # Skip these subfolders for invasive changes (renames + moves). # Indexing still includes them, but their internal files aren't reorganized. SENSITIVE_SUBFOLDER_PATTERNS = [ re.compile(r"^04_Case Studies$"), re.compile(r"^99_Project Archive$"), re.compile(r".*Archive$", re.I), re.compile(r"^진단.*$"), ] EXCLUDE_NAMES = {".DS_Store", "Icon\r"} BEGIN_MARK = "" END_MARK = "" # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @dataclass class FolderScan: path: Path files: list[str] = field(default_factory=list) subfolders: dict[str, "FolderScan"] = field(default_factory=dict) @dataclass class Proposal: kind: str # "rename" | "move" src: Path dst: Path reason: str def as_dict(self) -> dict: return {"kind": self.kind, "src": str(self.src), "dst": str(self.dst), "reason": self.reason} # --------------------------------------------------------------------------- # Scanning # --------------------------------------------------------------------------- def is_visible(name: str) -> bool: return not name.startswith(".") and name not in EXCLUDE_NAMES def list_visible_files(path: Path) -> list[str]: try: return sorted(e.name for e in os.scandir(path) if e.is_file(follow_symlinks=False) and is_visible(e.name)) except OSError: return [] def list_visible_dirs(path: Path) -> list[str]: try: return sorted(e.name for e in os.scandir(path) if e.is_dir(follow_symlinks=False) and is_visible(e.name)) except OSError: return [] def scan(path: Path, depth: int = 3) -> FolderScan: """Walk depth levels deep. depth=3 → root + 2 levels of subfolders.""" s = FolderScan(path=path, files=list_visible_files(path)) if depth > 1: for d in list_visible_dirs(path): s.subfolders[d] = scan(path / d, depth - 1) else: for d in list_visible_dirs(path): s.subfolders[d] = FolderScan(path=path / d) return s def is_sensitive(name: str) -> bool: return any(p.search(name) for p in SENSITIVE_SUBFOLDER_PATTERNS) # --------------------------------------------------------------------------- # Index generation (root README.md and per-subfolder READMEs) # --------------------------------------------------------------------------- def render_structure_block(scan_root: FolderScan) -> str: out: list[str] = [] out.append("## Structure (3 levels)") out.append("") ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %z") out.append(f"_Auto-regenerated {ts}. Edits inside the AUTO-STRUCTURE markers will be overwritten._") out.append("") for d1_name in sorted(scan_root.subfolders): d1 = scan_root.subfolders[d1_name] nf1 = len(d1.files) nd1 = len(d1.subfolders) out.append(f"- **{d1_name}/** ({nf1} files, {nd1} subfolders)") for d2_name in sorted(d1.subfolders): d2 = d1.subfolders[d2_name] nf2 = len(d2.files) nd2 = len(d2.subfolders) if nd2 > 0: out.append(f" - {d2_name}/ ({nf2}f, {nd2}d)") for d3_name in sorted(d2.subfolders): d3 = d2.subfolders[d3_name] nf3 = len(d3.files) out.append(f" - {d3_name}/ ({nf3}f)") else: out.append(f" - {d2_name}/ ({nf2}f)") if nf1 > 0: preview = ", ".join(f"`{n}`" for n in d1.files[:5]) out.append(f" - _files_: {preview}") out.append("") return "\n".join(out).rstrip() + "\n" def render_subfolder_block(scan_node: FolderScan) -> str: """Smaller block for per-subfolder README.md (2 levels deep).""" out: list[str] = [] out.append("## Contents") out.append("") ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %z") out.append(f"_Auto-regenerated {ts}._") out.append("") if scan_node.files: out.append(f"**Files at root** ({len(scan_node.files)}):") for f in scan_node.files[:20]: out.append(f"- `{f}`") if len(scan_node.files) > 20: out.append(f"- … and {len(scan_node.files) - 20} more") out.append("") if scan_node.subfolders: out.append(f"**Subfolders** ({len(scan_node.subfolders)}):") for d_name in sorted(scan_node.subfolders): d = scan_node.subfolders[d_name] nf = len(d.files) nd = len(d.subfolders) extras = f", {nd} dirs" if nd else "" out.append(f"- `{d_name}/` ({nf} files{extras})") out.append("") return "\n".join(out).rstrip() + "\n" def splice_readme(readme_path: Path, new_block: str, *, header_default: str | None = None) -> tuple[bool, str]: """Insert/replace the AUTO-STRUCTURE block inside readme_path. Returns (changed, summary). Creates the file with header_default if missing. """ if not readme_path.exists(): if header_default is None: return (False, f"skip: {readme_path} does not exist") readme_path.write_text( f"{header_default}\n\n{BEGIN_MARK}\n{new_block}{END_MARK}\n", encoding="utf-8", ) return (True, f"created: {readme_path}") text = readme_path.read_text(encoding="utf-8") # Update top-level Snapshot counts if they exist parent = readme_path.parent total_files = len(list_visible_files(parent)) total_dirs = len(list_visible_dirs(parent)) text = re.sub(r"^- \*\*Top-level files\*\*:.*$", f"- **Top-level files**: {total_files}", text, count=1, flags=re.M) text = re.sub(r"^- \*\*Top-level subfolders\*\*:.*$", f"- **Top-level subfolders**: {total_dirs}", text, count=1, flags=re.M) if BEGIN_MARK in text and END_MARK in text: pattern = re.compile(re.escape(BEGIN_MARK) + r".*?" + re.escape(END_MARK), re.S) new_text = pattern.sub(BEGIN_MARK + "\n" + new_block + END_MARK, text) else: # Append marker block before the trailing footer (if any) or at EOF new_text = text.rstrip() + "\n\n" + BEGIN_MARK + "\n" + new_block + END_MARK + "\n" if structurally_equal(text, new_text): return (False, f"no structural changes: {readme_path}") readme_path.write_text(new_text, encoding="utf-8") return (True, f"updated: {readme_path}") def structurally_equal(a: str, b: str) -> bool: """Strip the auto-regenerated timestamp lines before comparing.""" strip = re.compile(r"^_Auto-regenerated .*$", re.M) return strip.sub("", a) == strip.sub("", b) # --------------------------------------------------------------------------- # Rename + move proposals # --------------------------------------------------------------------------- def propose_renames(scan_root: FolderScan) -> list[Proposal]: proposals: list[Proposal] = [] def visit(node: FolderScan): for f in node.files: new = f reasons: list[str] = [] for pat, repl, why in RENAME_RULES: next_new = pat.sub(repl, new) if next_new != new: reasons.append(why) new = next_new if new != f: proposals.append(Proposal("rename", node.path / f, node.path / new, "; ".join(reasons))) for sub_name, sub in node.subfolders.items(): if is_sensitive(sub_name): continue visit(sub) visit(scan_root) return proposals def propose_folder_renames(scan_root: FolderScan) -> list[Proposal]: """Propose folder renames matching RENAME_RULES. Guardrails: - Top-level subfolders (depth 1, e.g. "00_Brand Management/") are NEVER renamed automatically — they're the user's manually curated practice areas. Use a one-off `mv` if you need to rename one. - Sensitive folders are skipped entirely (not renamed, not recursed into). """ proposals: list[Proposal] = [] def visit(node: FolderScan, parent_depth: int): sub_depth = parent_depth + 1 for sub_name, sub in node.subfolders.items(): if is_sensitive(sub_name): continue if sub_depth >= 2: # depth-2+ folders are eligible for rename new_name = sub_name reasons: list[str] = [] for pat, repl, why in RENAME_RULES: nxt = pat.sub(repl, new_name) if nxt != new_name: reasons.append(why) new_name = nxt if new_name != sub_name: proposals.append(Proposal( "rename-folder", sub.path, sub.path.parent / new_name, "; ".join(reasons), )) visit(sub, sub_depth) visit(scan_root, parent_depth=0) return proposals def propose_moves(scan_root: FolderScan) -> list[Proposal]: proposals: list[Proposal] = [] # Only operate on top-level subfolders of the target — that's where the # "cluttered files at root" pattern most commonly appears. for sub_name, sub in scan_root.subfolders.items(): if is_sensitive(sub_name): continue for f in sub.files: for pat, dest_subdir, why in MOVE_RULES: if pat.search(f): proposals.append(Proposal( "move", sub.path / f, sub.path / dest_subdir / f, why, )) break return proposals def apply_proposals(props: Iterable[Proposal]) -> list[tuple[Proposal, str]]: results: list[tuple[Proposal, str]] = [] for p in props: try: if p.kind == "move": p.dst.parent.mkdir(parents=True, exist_ok=True) if p.dst.exists(): results.append((p, "skip: destination exists")) continue shutil.move(str(p.src), str(p.dst)) results.append((p, "ok")) except OSError as e: results.append((p, f"error: {e}")) return results # --------------------------------------------------------------------------- # Per-subfolder README.md ensuring + refresh # --------------------------------------------------------------------------- def refresh_subfolder_readmes(scan_root: FolderScan, *, write: bool) -> list[str]: """For each top-level subfolder, ensure README.md exists with AUTO block.""" log: list[str] = [] for sub_name in sorted(scan_root.subfolders): if is_sensitive(sub_name): log.append(f"skip (sensitive): {sub_name}/") continue sub = scan_root.subfolders[sub_name] readme = sub.path / "README.md" block = render_subfolder_block(sub) header = f"# {sub_name}\n\n_OurDigital — auto-indexed subfolder README._" if not write: log.append(f"would refresh: {readme}") continue changed, summary = splice_readme(readme, block, header_default=header) log.append(summary) return log # --------------------------------------------------------------------------- # Reporting # --------------------------------------------------------------------------- def text_report(target: Path, renames: list[Proposal], folder_renames: list[Proposal], moves: list[Proposal], index_summary: str, sub_log: list[str]) -> str: lines = [] lines.append(f"# our-gdrive-organizer report") lines.append(f"target: {target}") lines.append(f"scan time: {datetime.now().strftime('%F %T')}") lines.append("") lines.append("## Index") lines.append(index_summary or "(skipped)") lines.append("") lines.append("## Subfolder READMEs") if sub_log: for line in sub_log: lines.append(f"- {line}") else: lines.append("(skipped)") lines.append("") lines.append(f"## File rename proposals ({len(renames)})") if renames: for p in renames: lines.append(f"- {p.src.relative_to(target)}") lines.append(f" → {p.dst.relative_to(target)}") lines.append(f" ({p.reason})") else: lines.append("(none)") lines.append("") lines.append(f"## Folder rename proposals ({len(folder_renames)})") if folder_renames: for p in folder_renames: lines.append(f"- {p.src.relative_to(target)}/") lines.append(f" → {p.dst.relative_to(target)}/") lines.append(f" ({p.reason})") else: lines.append("(none)") lines.append("") lines.append(f"## Move proposals ({len(moves)})") if moves: for p in moves: lines.append(f"- {p.src.relative_to(target)}") lines.append(f" → {p.dst.relative_to(target)}") lines.append(f" ({p.reason})") else: lines.append("(none)") return "\n".join(lines) + "\n" # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="OurDigital Google Drive folder organizer", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument("target", nargs="?", default=os.getcwd(), help="target directory (default: cwd)") parser.add_argument("--apply", action="store_true", help="actually perform renames and moves (default: dry-run)") parser.add_argument("--scope", choices=["full", "index", "subreadmes", "rename", "move"], default="full", help="restrict the operation (default: full)") parser.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of a text report") args = parser.parse_args(argv) target = Path(args.target).expanduser().resolve() if not target.is_dir(): print(f"target is not a directory: {target}", file=sys.stderr) return 1 scan_root = scan(target, depth=3) index_summary = "" sub_log: list[str] = [] renames: list[Proposal] = [] folder_renames: list[Proposal] = [] moves: list[Proposal] = [] if args.scope in ("full", "index"): readme = target / "README.md" block = render_structure_block(scan_root) header = f"# {target.name}\n\n_OurDigital — auto-indexed root README._\n\n## Snapshot\n\n- **Top-level files**: {len(scan_root.files)}\n- **Top-level subfolders**: {len(scan_root.subfolders)}" changed, index_summary = splice_readme(readme, block, header_default=header) if args.scope in ("full", "subreadmes"): sub_log = refresh_subfolder_readmes(scan_root, write=True) # Order matters when --apply: file renames first, then folder renames. # If folders are renamed first, file paths inside them become invalid. if args.scope in ("full", "rename"): renames = propose_renames(scan_root) if args.apply and renames: results = apply_proposals(renames) for p, status in results: p.reason = f"{p.reason} [{status}]" folder_renames = propose_folder_renames(scan_root) if args.apply and folder_renames: # Apply deepest folders first — renaming a parent invalidates the # path of any nested rename. shutil.move on the deepest target # leaves the parent's name intact and avoids stale paths. ordered = sorted(folder_renames, key=lambda p: len(p.src.parts), reverse=True) results = apply_proposals(ordered) for p, status in results: p.reason = f"{p.reason} [{status}]" if args.scope in ("full", "move"): moves = propose_moves(scan_root) if args.apply and moves: results = apply_proposals(moves) for p, status in results: p.reason = f"{p.reason} [{status}]" if args.json: out = { "target": str(target), "scope": args.scope, "applied": args.apply, "index": index_summary, "subfolder_readmes": sub_log, "renames": [p.as_dict() for p in renames], "folder_renames": [p.as_dict() for p in folder_renames], "moves": [p.as_dict() for p in moves], } print(json.dumps(out, ensure_ascii=False, indent=2)) else: print(text_report(target, renames, folder_renames, moves, index_summary, sub_log)) return 0 if __name__ == "__main__": sys.exit(main())