From 145f21eae71d39fd431e78f6ac50b34e3881f443 Mon Sep 17 00:00:00 2001 From: luxick Date: Mon, 16 Mar 2026 08:46:28 +0100 Subject: [PATCH] Add numbering mode to nn --- bin/nn | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/bin/nn b/bin/nn index 85d8554..67af25d 100755 --- a/bin/nn +++ b/bin/nn @@ -100,6 +100,35 @@ def plan_parse_rename(filepath): return new_name, STATUS_RENAME +def get_date_for_file(filepath, parse_mode): + """Resolve a date string for a file, or None if unavailable.""" + if parse_mode: + 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) + return date_str + + # Default pattern mode: extract date from filename + stem = filepath.stem + + # Check if already normalized (e.g. "2024-06-01 breakfast.jpg") + norm_match = re.match(r'^(\d{4})-(\d{2})-(\d{2}) ', filepath.name) + if norm_match: + return f'{norm_match.group(1)}-{norm_match.group(2)}-{norm_match.group(3)}' + + # Try the standard pattern + match = PATTERN.match(stem) + if not match: + match = PATTERN.match(filepath.name) + if match: + year, month, day, _ = match.groups() + return f'{year}-{month}-{day}' + + return None + + def collect_files(paths): """Resolve the list of files to process.""" if not paths: @@ -144,6 +173,49 @@ def build_plan(files, parse_mode): return plan +def build_number_plan(files, parse_mode): + """Build a rename plan using sequential numbering per day.""" + entries = [] + for file_path in files: + date_str = get_date_for_file(file_path, parse_mode) + entries.append((file_path, date_str)) + + day_counter = {} + plan = [] + seen_targets = {} + + for file_path, date_str in entries: + if date_str is None: + plan.append((file_path, None, STATUS_NO_MATCH)) + continue + + day_counter[date_str] = day_counter.get(date_str, 0) + 1 + seq = day_counter[date_str] + ext = file_path.suffix + new_name = f'{date_str} {seq:02d}{ext}' + + if new_name == file_path.name: + plan.append((file_path, None, STATUS_ALREADY)) + continue + + status = STATUS_RENAME + 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: @@ -194,6 +266,15 @@ def main(): action='store_true', help='Use EXIF data or file dates instead of pattern matching.', ) + parser.add_argument( + '-n', + '--number', + action='store_true', + help=( + 'Discard original filename stems and number files sequentially ' + 'per day. Combines with --parse to select the date source.' + ), + ) parser.add_argument( 'files', nargs='*', @@ -207,7 +288,10 @@ def main(): print('No files found.') sys.exit(0) - plan = build_plan(files, args.parse) + if args.number: + plan = build_number_plan(files, args.parse) + else: + plan = build_plan(files, args.parse) print_preview(plan) renames = sum(1 for _, _, status in plan if status == STATUS_RENAME)