Created
December 9, 2025 19:51
-
-
Save DrSkippy/bca0f950fb1a816cd653c71756410a81 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| File Renamer Script | |
| Walks directories and renames files matching the pattern: | |
| FROM: <string>_<sequence>_<date>.jpg | |
| TO: <date>_<sequence>_<string>.jpg | |
| Creates backup files (.bak) before renaming. | |
| """ | |
| import argparse | |
| import re | |
| import shutil | |
| from pathlib import Path | |
| from typing import Optional, Tuple | |
| class FileRenamer: | |
| """Handles file renaming operations with backup creation.""" | |
| # Pattern: <string>_<sequence>_<YYYYMMDD>.jpg | |
| PATTERN = re.compile(r'^(.+?)_(\d+)_(\d{8})\.jpg$', re.IGNORECASE) | |
| def __init__(self, dry_run: bool = False, verbose: bool = False): | |
| self.dry_run = dry_run | |
| self.verbose = verbose | |
| self.processed_count = 0 | |
| self.skipped_count = 0 | |
| self.error_count = 0 | |
| def parse_filename(self, filename: str) -> Optional[Tuple[str, str, str, str]]: | |
| """ | |
| Parse filename to extract components. | |
| Args: | |
| filename: The filename to parse | |
| Returns: | |
| Tuple of (string_part, sequence, date, extension) or None if no match | |
| """ | |
| match = self.PATTERN.match(filename) | |
| if not match: | |
| return None | |
| string_part, sequence, date = match.groups() | |
| # Validate date format (basic check) | |
| if not self._is_valid_date(date): | |
| return None | |
| return string_part, sequence, date, '.jpg' | |
| def _is_valid_date(self, date_str: str) -> bool: | |
| """Validate YYYYMMDD date format.""" | |
| if len(date_str) != 8: | |
| return False | |
| try: | |
| year = int(date_str[:4]) | |
| month = int(date_str[4:6]) | |
| day = int(date_str[6:8]) | |
| # Basic validation | |
| if year < 1900 or year > 2100: | |
| return False | |
| if month < 1 or month > 12: | |
| return False | |
| if day < 1 or day > 31: | |
| return False | |
| return True | |
| except ValueError: | |
| return False | |
| def create_new_filename(self, string_part: str, sequence: str, date: str, ext: str) -> str: | |
| """ | |
| Create new filename in format: <date>_<sequence>_<string>.jpg | |
| Args: | |
| string_part: The string portion of the filename | |
| sequence: The sequence number | |
| date: The date in YYYYMMDD format | |
| ext: The file extension | |
| Returns: | |
| New filename | |
| """ | |
| return f"{date}_{sequence}_{string_part}{ext}" | |
| def create_backup(self, file_path: Path) -> bool: | |
| """ | |
| Create a backup of the file. | |
| Args: | |
| file_path: Path to the file to backup | |
| Returns: | |
| True if backup successful, False otherwise | |
| """ | |
| backup_path = file_path.with_suffix(file_path.suffix + '.bak') | |
| if self.dry_run: | |
| print(f" [DRY RUN] Would create backup: {backup_path.name}") | |
| return True | |
| try: | |
| shutil.copy2(file_path, backup_path) | |
| if self.verbose: | |
| print(f" Created backup: {backup_path.name}") | |
| return True | |
| except Exception as e: | |
| print(f" ERROR creating backup: {e}") | |
| return False | |
| def rename_file(self, file_path: Path) -> bool: | |
| """ | |
| Process and rename a single file. | |
| Args: | |
| file_path: Path to the file to rename | |
| Returns: | |
| True if renamed successfully, False otherwise | |
| """ | |
| filename = file_path.name | |
| parsed = self.parse_filename(filename) | |
| if not parsed: | |
| if self.verbose: | |
| print(f"SKIP: {filename} (doesn't match pattern)") | |
| self.skipped_count += 1 | |
| return False | |
| string_part, sequence, date, ext = parsed | |
| new_filename = self.create_new_filename(string_part, sequence, date, ext) | |
| new_path = file_path.parent / new_filename | |
| # Check if target already exists | |
| if new_path.exists() and new_path != file_path: | |
| print(f"SKIP: {filename} (target {new_filename} already exists)") | |
| self.skipped_count += 1 | |
| return False | |
| # Skip if filename is already in the correct format | |
| if filename == new_filename: | |
| if self.verbose: | |
| print(f"SKIP: {filename} (already in correct format)") | |
| self.skipped_count += 1 | |
| return False | |
| print(f"\nProcessing: {filename}") | |
| print(f" → {new_filename}") | |
| # Create backup | |
| if not self.create_backup(file_path): | |
| self.error_count += 1 | |
| return False | |
| # Rename file | |
| if self.dry_run: | |
| print(f" [DRY RUN] Would rename to: {new_filename}") | |
| self.processed_count += 1 | |
| return True | |
| try: | |
| file_path.rename(new_path) | |
| print(f" ✓ Renamed successfully") | |
| self.processed_count += 1 | |
| return True | |
| except Exception as e: | |
| print(f" ERROR renaming file: {e}") | |
| self.error_count += 1 | |
| return False | |
| def walk_directory(self, directory: Path, recursive: bool = True) -> None: | |
| """ | |
| Walk through directory and process files. | |
| Args: | |
| directory: Directory to process | |
| recursive: Whether to process subdirectories | |
| """ | |
| if not directory.exists(): | |
| print(f"ERROR: Directory does not exist: {directory}") | |
| return | |
| if not directory.is_dir(): | |
| print(f"ERROR: Not a directory: {directory}") | |
| return | |
| print(f"Scanning directory: {directory}") | |
| print(f"Recursive: {recursive}") | |
| print(f"Dry run: {self.dry_run}") | |
| print("-" * 60) | |
| # Get all .jpg files | |
| if recursive: | |
| files = list(directory.rglob('*.jpg')) + list(directory.rglob('*.JPG')) | |
| else: | |
| files = list(directory.glob('*.jpg')) + list(directory.glob('*.JPG')) | |
| if not files: | |
| print("No .jpg files found") | |
| return | |
| print(f"Found {len(files)} .jpg file(s)\n") | |
| # Process each file | |
| for file_path in sorted(files): | |
| self.rename_file(file_path) | |
| # Print summary | |
| print("\n" + "=" * 60) | |
| print("SUMMARY") | |
| print("=" * 60) | |
| print(f"Files processed: {self.processed_count}") | |
| print(f"Files skipped: {self.skipped_count}") | |
| print(f"Errors: {self.error_count}") | |
| if self.dry_run: | |
| print("\n[DRY RUN MODE] No files were actually modified.") | |
| print("Run without --dry-run to apply changes.") | |
| def main(): | |
| """Main entry point.""" | |
| parser = argparse.ArgumentParser( | |
| description='Rename files from <string>_<seq>_<date>.jpg to <date>_<seq>_<string>.jpg', | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| %(prog)s /path/to/photos # Process directory | |
| %(prog)s /path/to/photos --dry-run # Preview changes | |
| %(prog)s /path/to/photos --no-recursive # Don't process subdirectories | |
| %(prog)s . -v # Process current dir with verbose output | |
| """ | |
| ) | |
| parser.add_argument( | |
| 'directory', | |
| type=Path, | |
| nargs='?', | |
| default=Path.cwd(), | |
| help='Directory to process (default: current directory)' | |
| ) | |
| parser.add_argument( | |
| '--dry-run', | |
| action='store_true', | |
| help='Preview changes without actually renaming files' | |
| ) | |
| parser.add_argument( | |
| '--no-recursive', | |
| action='store_true', | |
| help='Do not process subdirectories' | |
| ) | |
| parser.add_argument( | |
| '-v', '--verbose', | |
| action='store_true', | |
| help='Show verbose output including skipped files' | |
| ) | |
| args = parser.parse_args() | |
| renamer = FileRenamer(dry_run=args.dry_run, verbose=args.verbose) | |
| renamer.walk_directory(args.directory, recursive=not args.no_recursive) | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment