423f260aba
Self-documents that subdirectories (existing week folders + category folders like code_path_audit/ and license_cve_audit/) are skipped non-recursively. Surfaces in both human-readable and --json output.
164 lines
5.3 KiB
Python
164 lines
5.3 KiB
Python
"""Organize docs/reports/ files into week folders named <YYYY>-<MM>-<DD>.
|
|
|
|
Scheme: ShareX-style <YYYY>-<MM>-<W> where <W> resolves to the day-of-month
|
|
(zero-padded) of the week's Monday. Example: files from the week of Mon
|
|
June 22, 2026 land in docs/reports/2026-06-22/.
|
|
|
|
Only moves files from OLD weeks; the current week's files stay in place
|
|
so in-flight work isn't buried.
|
|
|
|
Date resolution per file:
|
|
1. Parse an 8-digit YYYYMMDD or YYYY-MM-DD substring from the filename
|
|
(most reports embed one, e.g. TRACK_COMPLETION_..._20260627.md).
|
|
2. Fall back to the file's mtime. NOTE: git checkout resets mtime, so
|
|
undated files may be mis-classified as 'current week' and left in place.
|
|
|
|
Idempotent: subdirectories (existing week folders) are skipped; files
|
|
already at their computed destination are skipped.
|
|
|
|
Non-recursive: only immediate children of the reports dir are scanned.
|
|
Subdirectories (existing week folders, category folders like
|
|
code_path_audit/ + license_cve_audit/) are never entered; their contents
|
|
are left untouched. Only loose .md (or other) files in the immediate
|
|
reports dir are candidates for moving.
|
|
|
|
Usage:
|
|
python scripts/organize_reports.py # dry-run; print plan
|
|
python scripts/organize_reports.py --apply # actually move files
|
|
python scripts/organize_reports.py --json # dry-run; JSON output
|
|
python scripts/organize_reports.py --dir docs/reports
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from datetime import date, timedelta
|
|
from pathlib import Path
|
|
|
|
_DATE_REGEX = re.compile(r"(\d{4})[-_]?(\d{2})[-_]?(\d{2})")
|
|
|
|
|
|
def _week_monday(d: date) -> date:
|
|
return d - timedelta(days=d.weekday())
|
|
|
|
|
|
def _parse_filename_date(name: str) -> date | None:
|
|
m = _DATE_REGEX.search(name)
|
|
if not m:
|
|
return None
|
|
y, mo, dy = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
if not (2000 <= y < 2100 and 1 <= mo <= 12 and 1 <= dy <= 31):
|
|
return None
|
|
try:
|
|
return date(y, mo, dy)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _file_week_monday(path: Path) -> date | None:
|
|
fd = _parse_filename_date(path.name)
|
|
if fd is not None:
|
|
return _week_monday(fd)
|
|
try:
|
|
mt = path.stat().st_mtime
|
|
except OSError:
|
|
return None
|
|
return _week_monday(date.fromtimestamp(mt))
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Organize docs/reports/ files into week folders named <YYYY>-<MM>-<DD> (Monday of the file's week). Old weeks only; current week's files stay in place."
|
|
)
|
|
parser.add_argument("--apply", action="store_true", help="Actually move files (default: dry-run)")
|
|
parser.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output")
|
|
parser.add_argument("--dir", default="docs/reports", help="Reports directory (default: docs/reports)")
|
|
args = parser.parse_args()
|
|
|
|
reports = Path(args.dir)
|
|
if not reports.is_dir():
|
|
print(f"error: {reports} is not a directory", file=sys.stderr)
|
|
return 1
|
|
|
|
subdirs_skipped = [e.name for e in sorted(reports.iterdir(), key=lambda e: e.name) if e.is_dir()]
|
|
current_monday = _week_monday(date.today())
|
|
moves: list[dict] = []
|
|
skipped_current_week: list[str] = []
|
|
skipped_no_date: list[str] = []
|
|
skipped_already_in_place: list[str] = []
|
|
|
|
for entry in sorted(reports.iterdir(), key=lambda e: e.name):
|
|
if entry.is_dir():
|
|
continue
|
|
if not entry.is_file():
|
|
continue
|
|
fm = _file_week_monday(entry)
|
|
if fm is None:
|
|
skipped_no_date.append(entry.name)
|
|
continue
|
|
if fm >= current_monday:
|
|
skipped_current_week.append(entry.name)
|
|
continue
|
|
dest_dir = reports / fm.isoformat()
|
|
dest = dest_dir / entry.name
|
|
if dest.exists():
|
|
skipped_already_in_place.append(entry.name)
|
|
continue
|
|
moves.append({"src": str(entry), "dest": str(dest), "week": fm.isoformat()})
|
|
|
|
if args.json:
|
|
print(json.dumps({
|
|
"current_week_monday": current_monday.isoformat(),
|
|
"reports_dir": str(reports),
|
|
"moves": moves,
|
|
"skipped_current_week": skipped_current_week,
|
|
"skipped_no_date": skipped_no_date,
|
|
"skipped_already_in_place": skipped_already_in_place,
|
|
"subdirs_skipped": subdirs_skipped,
|
|
}, indent=2))
|
|
return 0
|
|
|
|
print(f"Current week Monday: {current_monday.isoformat()} (files from this week stay put)")
|
|
print(f"Reports dir: {reports}")
|
|
print()
|
|
if not moves:
|
|
print("Nothing to move.")
|
|
else:
|
|
tag = "MOVE" if args.apply else "DRY "
|
|
print(f"{tag}: {len(moves)} file(s)")
|
|
for mv in moves:
|
|
print(f" {tag} {mv['src']} -> {mv['dest']}")
|
|
|
|
if skipped_current_week:
|
|
print(f"\nSkipped (current week): {len(skipped_current_week)}")
|
|
for n in skipped_current_week:
|
|
print(f" - {n}")
|
|
if skipped_already_in_place:
|
|
print(f"\nSkipped (already in destination): {len(skipped_already_in_place)}")
|
|
for n in skipped_already_in_place:
|
|
print(f" - {n}")
|
|
if skipped_no_date:
|
|
print(f"\nSkipped (no parseable date): {len(skipped_no_date)}")
|
|
for n in skipped_no_date:
|
|
print(f" - {n}")
|
|
if subdirs_skipped:
|
|
print(f"\nSubdirectories skipped (non-recursive; left untouched): {len(subdirs_skipped)}")
|
|
for n in subdirs_skipped:
|
|
print(f" - {n}")
|
|
|
|
if args.apply and moves:
|
|
for mv in moves:
|
|
dest_path = Path(mv["dest"])
|
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(mv["src"], mv["dest"])
|
|
print(f"\nApplied: moved {len(moves)} file(s).")
|
|
elif moves:
|
|
print("\nDry run. Pass --apply to move.")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main()) |