447 lines
15 KiB
Python
447 lines
15 KiB
Python
#!/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:
|
||
<new_base><original_variant_suffix><original_extension>
|
||
|
||
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()
|