add nn script
This commit is contained in:
225
bin/nn
Executable file
225
bin/nn
Executable file
@@ -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()
|
||||
Reference in New Issue
Block a user