#!/usr/bin/env python3 """ mfr.py – MemorySharing Multi-File Renamer (Tkinter GUI) This tool helps rename related media files (RAW, JPEG, thumbnails, websize variants, WAV, etc.) in groups. Grouping logic: - Files in the selected directory are grouped by a common basename prefix. - Variant suffixes like "_tn", "_ws", etc. are stripped to find the "group key". All files with stems sharing that key are in one group. Renaming logic: - For each group, compute the earliest "logical timestamp" among its members: EXIF DateTimeOriginal (if present) OR filesystem mtime. - Build a new base name: [optional date]_[camera_tag]_[extra_text]_[group_index] where: - date is formatted with Python's datetime.strftime() - camera_tag is chosen from a dropdown (or custom) - extra_text is the user-supplied string, spaces -> '-' - group_index is a 4-digit sequence (0001, 0002, ...) - Each file in the group gets renamed to: Example: Original files: IMG_1234.NEF IMG_1234.JPG IMG_1234_tn.jpg If group index is 1, camera tag "d600", date "2025_0401", text "family", they might be renamed to: 2025_0401_d600_family_0001.NEF 2025_0401_d600_family_0001.JPG 2025_0401_d600_family_0001_tn.jpg """ import os from pathlib import Path from datetime import datetime import tkinter as tk from tkinter import filedialog, messagebox, ttk from PIL import Image from PIL.ExifTags import TAGS # ------------------------- Group / timestamp logic -------------------------- DEFAULT_VARIANT_MARKERS = ["_tn", "_ws"] def get_exif_datetime_original(path: Path): """Return EXIF DateTimeOriginal as datetime, or None.""" try: with Image.open(path) as img: exif = img._getexif() if not exif: return None exif_data = {TAGS.get(tag_id, tag_id): value for tag_id, value in exif.items()} dto = exif_data.get("DateTimeOriginal") if not dto: return None # Typical EXIF datetime format: "YYYY:MM:DD HH:MM:SS" try: return datetime.strptime(dto, "%Y:%m:%d %H:%M:%S") except (ValueError, TypeError): return None except Exception: return None def get_logical_timestamp(path: Path) -> float: """Best timestamp: EXIF DateTimeOriginal if available, else mtime.""" exif_dt = get_exif_datetime_original(path) if exif_dt is not None: return exif_dt.timestamp() return os.path.getmtime(path) def derive_group_key(stem: str, variant_markers): """ Derive a "group key" from a stem by stripping known variant markers. We look for the earliest occurrence of any marker like "_tn", "_ws", and take everything before that index as the group key. """ s_lower = stem.lower() positions = [] for marker in variant_markers: m_lower = marker.lower() idx = s_lower.find(m_lower) if idx != -1: positions.append(idx) if not positions: return stem cutoff = min(positions) if cutoff <= 0: return stem return stem[:cutoff] def collect_groups_in_directory(directory: Path, variant_markers): """ Collect files into groups for a single directory (no recursion). Returns a dict: {group_key: [Path, ...], ...} """ groups = {} for entry in sorted(directory.iterdir()): if not entry.is_file(): continue # Skip dotfiles if entry.name.startswith("."): continue stem = entry.stem key = derive_group_key(stem, variant_markers) groups.setdefault(key, []).append(entry) return groups # ----------------------------- GUI application ------------------------------ class MFRApp(tk.Tk): def __init__(self): super().__init__() self.title("MemorySharing Multi-File Renamer") self.geometry("900x600") self.directory_var = tk.StringVar() self.date_format_var = tk.StringVar(value="%Y_%m%d") self.include_date_var = tk.BooleanVar(value=True) # Camera tag dropdown, plus custom override self.camera_tags = ["d600", "z6", "em5", "other"] self.camera_tag_var = tk.StringVar(value=self.camera_tags[0]) self.camera_custom_var = tk.StringVar() self.extra_text_var = tk.StringVar() self.variant_markers_var = tk.StringVar(value=",".join(DEFAULT_VARIANT_MARKERS)) # Store preview mappings [(old_path, new_path), ...] self.preview_mappings = [] self._build_ui() def _build_ui(self): # Top frame: directory selection top_frame = ttk.Frame(self, padding=8) top_frame.pack(fill=tk.X) ttk.Label(top_frame, text="Directory:").grid(row=0, column=0, sticky=tk.W) dir_entry = ttk.Entry(top_frame, textvariable=self.directory_var, width=60) dir_entry.grid(row=0, column=1, sticky=tk.W, padx=4) ttk.Button(top_frame, text="Browse...", command=self.select_directory).grid( row=0, column=2, padx=4 ) # Date options date_frame = ttk.LabelFrame(self, text="Date options", padding=8) date_frame.pack(fill=tk.X, padx=8, pady=4) ttk.Checkbutton( date_frame, text="Include date in filename", variable=self.include_date_var ).grid(row=0, column=0, sticky=tk.W) ttk.Label(date_frame, text="Date format (strftime):").grid( row=1, column=0, sticky=tk.W, pady=(4, 0) ) ttk.Entry(date_frame, textvariable=self.date_format_var, width=20).grid( row=1, column=1, sticky=tk.W, padx=4, pady=(4, 0) ) ttk.Label(date_frame, text="Example: %Y_%m%d → 2025_0401").grid( row=1, column=2, sticky=tk.W, padx=8 ) # Camera / text options name_frame = ttk.LabelFrame(self, text="Naming components", padding=8) name_frame.pack(fill=tk.X, padx=8, pady=4) ttk.Label(name_frame, text="Camera tag:").grid(row=0, column=0, sticky=tk.W) camera_menu = ttk.OptionMenu( name_frame, self.camera_tag_var, self.camera_tags[0], *self.camera_tags ) camera_menu.grid(row=0, column=1, sticky=tk.W, padx=4) ttk.Label(name_frame, text="Custom tag (optional):").grid( row=1, column=0, sticky=tk.W, pady=(4, 0) ) ttk.Entry(name_frame, textvariable=self.camera_custom_var, width=20).grid( row=1, column=1, sticky=tk.W, padx=4, pady=(4, 0) ) ttk.Label(name_frame, text="Extra text:").grid( row=2, column=0, sticky=tk.W, pady=(4, 0) ) ttk.Entry(name_frame, textvariable=self.extra_text_var, width=40).grid( row=2, column=1, columnspan=2, sticky=tk.W, padx=4, pady=(4, 0) ) ttk.Label( name_frame, text="(Spaces will be converted to '-' in the basename.)", ).grid(row=3, column=1, columnspan=2, sticky=tk.W, padx=4, pady=(2, 0)) # Variant markers vm_frame = ttk.LabelFrame(self, text="Variant markers", padding=8) vm_frame.pack(fill=tk.X, padx=8, pady=4) ttk.Label(vm_frame, text="Variant suffixes (comma separated):").grid( row=0, column=0, sticky=tk.W ) ttk.Entry(vm_frame, textvariable=self.variant_markers_var, width=30).grid( row=0, column=1, sticky=tk.W, padx=4 ) ttk.Label(vm_frame, text="Example: _tn,_ws").grid( row=0, column=2, sticky=tk.W, padx=4 ) # Buttons button_frame = ttk.Frame(self, padding=8) button_frame.pack(fill=tk.X) ttk.Button(button_frame, text="Preview", command=self.preview).pack( side=tk.LEFT, padx=4 ) ttk.Button(button_frame, text="Rename", command=self.rename).pack( side=tk.LEFT, padx=4 ) # Info about pattern pattern_label = ttk.Label( button_frame, text=( "Pattern: [date?]_[camera]_[extra]_[group-index] + variant + extension\n" "Example: 2025_0401_d600_family_0001_tn.jpg" ), justify=tk.LEFT, ) pattern_label.pack(side=tk.LEFT, padx=16) # Preview text area text_frame = ttk.Frame(self, padding=8) text_frame.pack(fill=tk.BOTH, expand=True) self.preview_text = tk.Text(text_frame, wrap=tk.NONE) self.preview_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar_y = ttk.Scrollbar(text_frame, orient=tk.VERTICAL, command=self.preview_text.yview) scrollbar_y.pack(side=tk.RIGHT, fill=tk.Y) self.preview_text.configure(yscrollcommand=scrollbar_y.set) # --------------------------- UI callbacks -------------------------------- def select_directory(self): dirname = filedialog.askdirectory(title="Select directory to rename") if dirname: self.directory_var.set(dirname) def _get_variant_markers(self): raw = self.variant_markers_var.get().strip() if not raw: return DEFAULT_VARIANT_MARKERS return [s.strip() for s in raw.split(",") if s.strip()] def _compute_preview_mappings(self): """Compute the planned renames without performing them.""" self.preview_mappings = [] self.preview_text.delete("1.0", tk.END) dir_str = self.directory_var.get().strip() if not dir_str: messagebox.showerror("Error", "Please select a directory.") return directory = Path(dir_str) if not directory.is_dir(): messagebox.showerror("Error", f"Not a directory: {directory}") return try: variant_markers = self._get_variant_markers() except Exception as e: messagebox.showerror("Error", f"Invalid variant markers: {e}") return groups = collect_groups_in_directory(directory, variant_markers) if not groups: self.preview_text.insert(tk.END, "No files found in directory.\n") return include_date = self.include_date_var.get() date_format = self.date_format_var.get().strip() camera_tag = self.camera_custom_var.get().strip() or self.camera_tag_var.get().strip() extra_text = self.extra_text_var.get().strip().replace(" ", "-") # Build name components that are static across groups name_components = [] # date is per-group, so not included here if camera_tag: name_components.append(camera_tag) if extra_text: name_components.append(extra_text) # Use a stable ordering of groups group_keys = sorted(groups.keys()) seq_width = len(str(len(group_keys))) if len(group_keys) > 0 else 1 if seq_width < 4: seq_width = 4 for idx, group_key in enumerate(group_keys, start=1): files = groups[group_key] # Find earliest logical timestamp in group ts_list = [get_logical_timestamp(p) for p in files] earliest_ts = min(ts_list) dt = datetime.fromtimestamp(earliest_ts) # Build base name parts = [] if include_date: try: date_part = dt.strftime(date_format) except Exception as e: messagebox.showerror( "Error", f"Invalid date format '{date_format}': {e}", ) self.preview_mappings = [] return if date_part: parts.append(date_part) parts.extend(name_components) seq_str = f"{idx:0{seq_width}d}" parts.append(seq_str) base = "_".join(p for p in parts if p) # For each file in group, preserve variant suffix for p in files: old_stem = p.stem suffix_part = "" if old_stem.startswith(group_key): suffix_part = old_stem[len(group_key) :] new_stem = base + suffix_part new_name = new_stem + p.suffix new_path = p.with_name(new_name) self.preview_mappings.append((p, new_path)) # Check for collisions (two different old paths yielding same new path) dest_counts = {} for old, new in self.preview_mappings: dest_counts[new] = dest_counts.get(new, 0) + 1 collisions = [dst for dst, cnt in dest_counts.items() if cnt > 1] if collisions: self.preview_text.insert( tk.END, "WARNING: Naming collisions detected. Some new filenames are not unique.\n", ) for c in collisions: self.preview_text.insert(tk.END, f" -> {c}\n") self.preview_text.insert( tk.END, "Please adjust options (e.g., extra text) to avoid collisions.\n\n", ) # Print mapping for old, new in self.preview_mappings: if old == new: continue self.preview_text.insert(tk.END, f"{old.name} -> {new.name}\n") if not self.preview_mappings: self.preview_text.insert(tk.END, "No renames planned.\n") def preview(self): self._compute_preview_mappings() def rename(self): self._compute_preview_mappings() if not self.preview_mappings: messagebox.showinfo("Info", "No renames to apply.") return # Check for collisions again, and abort if any dest_map = {} for old, new in self.preview_mappings: if old == new: continue if new in dest_map and dest_map[new] != old: messagebox.showerror( "Error", f"Collision: multiple files would be renamed to {new.name}\n" "Renaming aborted.", ) return dest_map[new] = old if not messagebox.askyesno( "Confirm rename", "Apply the listed renames?\n\n" "This will rename files on disk. It is recommended to have a backup.", ): return # Perform renames errors = [] for old, new in self.preview_mappings: if old == new: continue try: old.rename(new) except Exception as e: errors.append((old, new, e)) if errors: msg_lines = ["Some renames failed:"] for old, new, e in errors: msg_lines.append(f"{old} -> {new}: {e}") messagebox.showerror("Errors during rename", "\n".join(msg_lines)) else: messagebox.showinfo("Done", "Renaming completed successfully.") # Refresh preview with new names (optional) self.preview() # ------------------------------ main ---------------------------------------- def main(): app = MFRApp() app.mainloop() if __name__ == "__main__": main()