#!/usr/bin/env python3 """ Fast manual grouping helper. Drag files or directories onto this script, or run: python3 grouping.py path/to/file/or/directory [...] Files are sorted by their current path and moved into one folder named [first stem]-[last stem]. Multiple files/folders are grouped as selected. """ from __future__ import annotations import os import re import shutil import sys from pathlib import Path ORDERED_TIMESTAMP_STEM_RE = re.compile(r"^(?P\d{8}_\d{6})-[1-9]\d*$") def group_stem(path: Path) -> str: stem = path.stem match = ORDERED_TIMESTAMP_STEM_RE.match(stem) if match: return match.group("timestamp") return stem def unique_path(path: Path) -> Path: if not path.exists(): return path for index in range(1, 10000): candidate = path.with_name(f"{path.stem}-{index}{path.suffix}") if not candidate.exists(): return candidate raise RuntimeError(f"Could not find an available name for {path}") def existing_input_paths(args: list[str]) -> list[Path]: paths: list[Path] = [] for arg in args: path = Path(arg).expanduser() if path.exists(): paths.append(path.resolve()) else: print(f"Skipping missing path: {arg}") return paths def resolve_single_directory(path: Path) -> list[Path]: current = path while current.is_dir(): children = sorted(current.iterdir(), key=lambda item: item.name.lower()) if not children: return [] if len(children) == 1 and children[0].is_dir(): current = children[0] continue return [child.resolve() for child in children] return [current.resolve()] def resolve_group_items(args: list[str]) -> list[Path]: paths = existing_input_paths(args) if len(paths) == 1: path = paths[0] if path.is_dir(): return resolve_single_directory(path) return [path] return sorted(paths, key=lambda item: item.name.lower()) def working_dir_for_items(items: list[Path]) -> Path: if len(items) == 1: item = items[0] return item if item.is_dir() else item.parent parents = {item.parent for item in items} if len(parents) == 1: return next(iter(parents)) try: common = Path(os.path.commonpath([str(item) for item in items])) except ValueError as exc: raise RuntimeError("Input paths do not have a common ancestor.") from exc if str(common) == common.anchor: raise RuntimeError("Input paths only share the filesystem root; choose items closer together.") return common def group_items(items: list[Path]) -> Path | None: if not items: return None sorted_items = sorted(items, key=lambda item: item.name.lower()) first = group_stem(sorted_items[0]) last = group_stem(sorted_items[-1]) target_dir = unique_path(working_dir_for_items(sorted_items) / f"{first}-{last}") target_dir.mkdir(parents=True) for source in sorted_items: destination = unique_path(target_dir / source.name) shutil.move(str(source), str(destination)) return target_dir def main(argv: list[str]) -> int: if len(argv) < 2: print("Usage: python3 grouping.py path/to/file/or/directory [...]") return 2 items = resolve_group_items(argv[1:]) if not items: print("No items to group.") return 0 try: target_dir = group_items(items) except RuntimeError as exc: print(exc) return 1 if target_dir is not None: print(f"Moved {len(items)} item(s) into {target_dir}") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv))