From 5a73875782f67707763302d8f7adfa9c18bd2e13 Mon Sep 17 00:00:00 2001 From: luxick Date: Mon, 16 Mar 2026 08:37:56 +0100 Subject: [PATCH] add nn script --- bin/nn | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100755 bin/nn diff --git a/bin/nn b/bin/nn new file mode 100755 index 0000000..85d8554 --- /dev/null +++ b/bin/nn @@ -0,0 +1,225 @@ +#!/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() \ No newline at end of file