Files
our-claude-skills/custom-skills/82-our-gdrive-organizer/code/organizer.py
Andrew Yim c750fa7f5e feat(our-gdrive-organizer): add new skill at slot 82, rename old 82 → 92
New Python CLI + dual SKILL.md (Code + Desktop) for organizing Google
Drive folders under OurDigital conventions:

- Refresh root README index (preserves manual Topics/Notes between
  AUTO-STRUCTURE markers)
- Ensure per-subfolder README.md meta files
- Propose filename + folder renames (D.intelligence → OurDigital with
  SEO-context caveat documented in patterns/gotchas.md)
- Propose moves for cluttered files (screenshots, temp downloads)
- Sensitive-folder skip list (04_Case Studies, 99_Project Archive,
  *Archive*, 진단*)
- shared/patterns/ gotcha library: canonical-files, canonical-folders,
  categorization-rules, 12 known gotchas — grows over time as the
  system encounters new edge cases

Slash command: /organize. CLI: ~/.local/bin/our-gdrive-organize.

82-tui-design-template renumbered to 92 (no content change) to free
slot 82. AGENTS.md and CLAUDE.md updated for both moves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:02:45 +09:00

521 lines
20 KiB
Python
Executable File

#!/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 = "<!-- AUTO-STRUCTURE:BEGIN -->"
END_MARK = "<!-- AUTO-STRUCTURE:END -->"
# ---------------------------------------------------------------------------
# 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())