#!/usr/bin/env python3 """Normalize photo and image filenames from phones and other sources.""" import argparse import re import shutil import subprocess import sys from datetime import datetime from pathlib import Path PATTERN = re.compile( r'^[A-Za-z]+[_-]' r'(\d{4})-?(\d{2})-?(\d{2})' r'[_-]' r'(.+)$' ) NORMALIZED = re.compile(r'^\d{4}-\d{2}-\d{2} .+') STATUS_RENAME = 'rename' STATUS_ALREADY = 'skip (already normalized)' STATUS_NO_MATCH = 'skip (no rule matched)' STATUS_CONFLICT = 'skip (conflict)' def get_exif_date(filepath): """Try to get the image creation date via exiftool.""" try: result = subprocess.run( ['exiftool', '-s3', '-d', '%Y-%m-%d', '-DateTimeOriginal', str(filepath)], capture_output=True, text=True, timeout=10, ) date_str = result.stdout.strip() if date_str and re.match(r'^\d{4}-\d{2}-\d{2}$', date_str): return date_str except (FileNotFoundError, subprocess.TimeoutExpired): pass return None def get_file_date(filepath): """Get file creation date (or modification time as fallback).""" stat = filepath.stat() ts = getattr(stat, 'st_birthtime', None) or stat.st_mtime return datetime.fromtimestamp(ts).strftime('%Y-%m-%d') def plan_default_rename(filepath): """Plan a rename using the default pattern-matching mode.""" stem = filepath.stem ext = filepath.suffix if NORMALIZED.match(filepath.name): return None, STATUS_ALREADY match = PATTERN.match(stem) if not match: match = PATTERN.match(filepath.name) if match: year, month, day, rest = match.groups() new_name = f'{year}-{month}-{day} {rest}' else: return None, STATUS_NO_MATCH else: year, month, day, rest = match.groups() new_name = f'{year}-{month}-{day} {rest}{ext}' return new_name, STATUS_RENAME def plan_parse_rename(filepath): """Plan a rename using EXIF or file date.""" if NORMALIZED.match(filepath.name): return None, STATUS_ALREADY date_str = None if shutil.which('exiftool') is not None: date_str = get_exif_date(filepath) if not date_str: date_str = get_file_date(filepath) stem = filepath.stem ext = filepath.suffix if stem.startswith(date_str): new_name = f'{date_str} {stem[len(date_str):].lstrip(" -_")}{ext}' else: new_name = f'{date_str} {stem}{ext}' if new_name == f'{date_str} {ext}': new_name = f'{date_str}{ext}' if new_name == filepath.name: return None, STATUS_ALREADY return new_name, STATUS_RENAME def collect_files(paths): """Resolve the list of files to process.""" if not paths: paths = ['.'] files = [] for path in paths: candidate = Path(path) if candidate.is_dir(): files.extend(sorted(file for file in candidate.iterdir() if file.is_file())) elif candidate.is_file(): files.append(candidate) return files def build_plan(files, parse_mode): """Build a list of (original_path, new_name, status) tuples.""" plan = [] seen_targets = {} for file_path in files: if parse_mode: new_name, status = plan_parse_rename(file_path) else: new_name, status = plan_default_rename(file_path) if status == STATUS_RENAME and new_name: target = file_path.parent / new_name target_key = str(target).casefold() if target.exists() and target.resolve() != file_path.resolve(): status = STATUS_CONFLICT elif target_key in seen_targets: prev_idx = seen_targets[target_key] plan[prev_idx] = (plan[prev_idx][0], plan[prev_idx][1], STATUS_CONFLICT) status = STATUS_CONFLICT else: seen_targets[target_key] = len(plan) plan.append((file_path, new_name, status)) return plan def print_preview(plan): """Print a formatted preview table.""" if not plan: print('No files found.') return col1 = max(len(file_path.name) for file_path, _, _ in plan) col2 = max(len(new_name or '') for _, new_name, _ in plan) col1 = max(col1, len('Original')) col2 = max(col2, len('New Name')) header = f'{"Original":<{col1}} {"New Name":<{col2}} Status' print(header) print('-' * len(header)) for file_path, new_name, status in plan: print(f'{file_path.name:<{col1}} {(new_name or ""):<{col2}} {status}') print() def apply_renames(plan): """Execute the planned renames.""" count = 0 for file_path, new_name, status in plan: if status != STATUS_RENAME: continue file_path.rename(file_path.parent / new_name) count += 1 return count def confirm(prompt='Apply renames? [y/n] '): """Ask the user for confirmation.""" while True: answer = input(prompt).strip().lower() if answer == 'y': return True if answer == 'n': return False def main(): parser = argparse.ArgumentParser(description='Normalize photo and image filenames.') parser.add_argument( '-p', '--parse', action='store_true', help='Use EXIF data or file dates instead of pattern matching.', ) parser.add_argument( 'files', nargs='*', help='Files or directories to process. Defaults to current directory.', ) args = parser.parse_args() files = collect_files(args.files) if not files: print('No files found.') sys.exit(0) plan = build_plan(files, args.parse) print_preview(plan) renames = sum(1 for _, _, status in plan if status == STATUS_RENAME) if renames == 0: print('Nothing to rename.') sys.exit(0) if confirm(): print(f'Renamed {apply_renames(plan)} file(s).') else: print('Aborted.') if __name__ == '__main__': main()