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>
This commit is contained in:
520
custom-skills/82-our-gdrive-organizer/code/organizer.py
Executable file
520
custom-skills/82-our-gdrive-organizer/code/organizer.py
Executable file
@@ -0,0 +1,520 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user