Batch rename files in one directory with pattern rules: build a rename plan from find and replace, numbering, case, space, and date prefix rules, preview it as a dry run, detect collisions and illegal names before touching anything, apply through a saved rename map, and undo from that map. Use when a set of files needs consistent new names in place, with no folder moves and no deletions.
./install.sh file-renaming-automationname: file_renaming_automation description: "Batch rename files in one directory with pattern rules: build a rename plan from find and replace, numbering, case, space, and date prefix rules, preview it as a dry run, detect collisions and illegal names before touching anything, apply through a saved rename map, and undo from that map. Use when a set of files needs consistent new names in place, with no folder moves and no deletions." metadata: { "includeInPrompt": true }
Rename a batch of files in place, in one directory at a time, from a written set of pattern rules. The work is: build a rename plan, preview it as a dry run, catch every collision and illegal name before any file is touched, save the clean entries to a rename map, apply from that map only, and keep the map so the batch can be undone exactly. A rename changes a name. It does not change file contents, move a file to another folder, or remove a file.
Scope:
Deliver these items, in this order:
Run: python3 rename_plan.py --make-fixture /tmp/rename_fixture builds the messy worked fixture, and python3 rename_plan.py --make-clean-fixture /tmp/rename_clean builds the clean one. Plan the messy fixture with python3 rename_plan.py plan /tmp/rename_fixture --spaces underscore --case lower --find img --replace photo. Plan and map the clean fixture with python3 rename_plan.py plan /tmp/rename_clean --spaces underscore --case lower --find img --replace photo --date-prefix --number --write-map /tmp/rename_clean_map.json, then run python3 rename_plan.py apply /tmp/rename_clean_map.json and python3 rename_plan.py undo /tmp/rename_clean_map.json. The script uses only the Python standard library (verified on Python 3.12.3). Both fixtures set every file modification time to 2026-01-15 12:00 UTC, which is why the date prefix in the clean plan reads 2026-01-15 for all three files.
Expected output for the messy fixture plan (exit code 1, because 4 files are in collision and 1 is illegal):
text
files: 7
ok: 1
collisions: 4
illegal: 1
unchanged: 1
COLLISION IMG 001.jpg -> photo_001.jpg [collision_duplicate_target]
COLLISION IMG 002.jpg -> photo_002.jpg [collision_duplicate_target, collision_target_exists]
RENAME Report Final.docx -> report_final.docx
ILLEGAL bad:name.txt -> bad:name.txt [illegal_char]
COLLISION img 001.jpg -> photo_001.jpg [collision_duplicate_target]
UNCHANGED notes.txt
COLLISION photo_002.jpg -> photo_002.jpg [collision_duplicate_target]
The messy fixture is built to trip each check: IMG 001.jpg and img 001.jpg both plan to photo_001.jpg after spaces, case, and find and replace run, IMG 002.jpg plans to photo_002.jpg which the unchanged photo_002.jpg already holds (and photo_002.jpg is itself flagged because its name is claimed by another file), bad:name.txt plans to a name with a colon in it, and notes.txt plans to its own name. Only Report Final.docx, planning to report_final.docx, is ok.
Expected output for the clean fixture plan, with the map written to /tmp/rename_clean_map.json (exit code 0):
text
files: 3
ok: 3
collisions: 0
illegal: 0
unchanged: 0
RENAME IMG 101.jpg -> 2026-01-15_photo_101_001.jpg
RENAME IMG 102.jpg -> 2026-01-15_photo_102_002.jpg
RENAME My Notes.txt -> 2026-01-15_my_notes_003.txt
map_written: /tmp/rename_clean_map.json entries: 3
Expected apply output for that map (exit code 0), followed by the undo output (exit code 0), which restores the three original names IMG 101.jpg, IMG 102.jpg, and My Notes.txt:
text
APPLY IMG 101.jpg -> 2026-01-15_photo_101_001.jpg
APPLY IMG 102.jpg -> 2026-01-15_photo_102_002.jpg
APPLY My Notes.txt -> 2026-01-15_my_notes_003.txt
applied: 3
UNDO 2026-01-15_my_notes_003.txt -> My Notes.txt
UNDO 2026-01-15_photo_102_002.jpg -> IMG 102.jpg
UNDO 2026-01-15_photo_101_001.jpg -> IMG 101.jpg
undone: 3
rename_plan.py (Python 3.12.3, standard library only). Save this code as rename_plan.py:
python
#!/usr/bin/env python3
"""Build a batch rename plan, check collisions and illegal names,
apply a saved rename map, and undo from that map. Python 3.12.3,
standard library only.
Usage:
python3 rename_plan.py --make-fixture DIR
python3 rename_plan.py --make-clean-fixture DIR
python3 rename_plan.py plan DIR [rule flags] [--write-map MAP.json]
python3 rename_plan.py apply MAP.json
python3 rename_plan.py undo MAP.json
Rule flags for plan, applied in this fixed order to the stem:
--spaces underscore|hyphen|remove|keep (default keep)
--case lower|upper|title|keep (default keep; applies to
stem and extension)
--find TEXT --replace TEXT (default: no replacement;
runs after spaces and case,
so match the post-case text)
--date-prefix (prefix YYYY-MM-DD_ from the
file mtime, in UTC)
--number (suffix _001, _002, ... in
sorted name order)
A plan is a dry run: it renames nothing. It writes a rename map only
when --write-map is given, and the map holds only entries whose
status is ok. Apply and undo read that map. Undo reverses apply.
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
ILLEGAL_CHARS = set('\\/:*?"<>|')
RESERVED = {"CON", "PRN", "AUX", "NUL"} | {
f"{p}{n}" for p in ("COM", "LPT") for n in range(1, 10)}
def illegal_reasons(name):
reasons = []
stem = name.rsplit(".", 1)[0] if "." in name else name
if any(c in ILLEGAL_CHARS or ord(c) < 32 for c in name):
reasons.append("illegal_char")
if not stem.strip():
reasons.append("illegal_empty_stem")
if name.endswith(".") or name.endswith(" "):
reasons.append("illegal_trailing")
if stem.upper() in RESERVED:
reasons.append("illegal_reserved_name")
if len(name.encode("utf-8")) > 255:
reasons.append("illegal_too_long")
return reasons
def build_target(name, path, rules, index):
if "." in name and not name.startswith("."):
stem, ext = name.rsplit(".", 1)
ext = "." + ext
else:
stem, ext = name, ""
if rules["spaces"] == "underscore":
stem = stem.replace(" ", "_")
elif rules["spaces"] == "hyphen":
stem = stem.replace(" ", "-")
elif rules["spaces"] == "remove":
stem = stem.replace(" ", "")
if rules["case"] == "lower":
stem, ext = stem.lower(), ext.lower()
elif rules["case"] == "upper":
stem, ext = stem.upper(), ext.upper()
elif rules["case"] == "title":
stem = stem.title()
if rules["find"]:
stem = stem.replace(rules["find"], rules["replace"])
if rules["date_prefix"]:
day = datetime.fromtimestamp(
os.path.getmtime(path), tz=timezone.utc).date().isoformat()
stem = f"{day}_{stem}"
if rules["number"]:
stem = f"{stem}_{index:03d}"
return stem + ext
def plan_dir(directory, rules):
names = sorted(n for n in os.listdir(directory)
if os.path.isfile(os.path.join(directory, n)))
targets = {}
rows = []
for i, name in enumerate(names, start=1):
target = build_target(
name, os.path.join(directory, name), rules, i)
targets.setdefault(target, []).append(name)
rows.append([name, target, [], ""])
existing = set(names)
for row in rows:
name, target = row[0], row[1]
reasons = illegal_reasons(target)
if len(targets[target]) > 1:
reasons.append("collision_duplicate_target")
if target != name and target in existing:
reasons.append("collision_target_exists")
if reasons:
status = ("illegal" if any(r.startswith("illegal")
for r in reasons)
else "collision")
elif target == name:
status = "unchanged"
else:
status = "ok"
row[2], row[3] = reasons, status
return rows
def report(rows, directory, rules, write_map=None):
counts = {"ok": 0, "collision": 0, "illegal": 0, "unchanged": 0}
for row in rows:
counts[row[3]] += 1
print(f"files: {len(rows)}")
print(f"ok: {counts['ok']}")
print(f"collisions: {counts['collision']}")
print(f"illegal: {counts['illegal']}")
print(f"unchanged: {counts['unchanged']}")
for name, target, reasons, status in rows:
suffix = f" [{', '.join(reasons)}]" if reasons else ""
if status == "ok":
print(f"RENAME {name} -> {target}")
elif status == "unchanged":
print(f"UNCHANGED {name}")
else:
print(f"{status.upper()} {name} -> {target}{suffix}")
if write_map:
entries = [{"src": n, "dst": t} for n, t, _, s in rows
if s == "ok"]
data = {"version": 1, "directory": os.path.abspath(directory),
"rules": rules, "entries": entries}
with open(write_map, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
print(f"map_written: {write_map} entries: {len(entries)}")
return 1 if (counts["collision"] or counts["illegal"]) else 0
def load_map(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def apply_map(path, undo=False):
data = load_map(path)
directory = data["directory"]
entries = list(reversed(data["entries"])) if undo else data["entries"]
for e in entries:
src_name, dst_name = (e["dst"], e["src"]) if undo else (
e["src"], e["dst"])
src = os.path.join(directory, src_name)
dst = os.path.join(directory, dst_name)
if not os.path.isfile(src):
print(f"missing_source: {src_name}")
return 1
if os.path.exists(dst):
print(f"target_exists: {dst_name}")
return 1
done = 0
for e in entries:
src_name, dst_name = (e["dst"], e["src"]) if undo else (
e["src"], e["dst"])
os.rename(os.path.join(directory, src_name),
os.path.join(directory, dst_name))
print(f"{'UNDO' if undo else 'APPLY'} {src_name} -> {dst_name}")
done += 1
print(f"{'undone' if undo else 'applied'}: {done}")
return 0
def make_files(directory, spec):
os.makedirs(directory, exist_ok=True)
stamp = datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc).timestamp()
for name, body in spec:
path = os.path.join(directory, name)
with open(path, "w", encoding="utf-8") as fh:
fh.write(body)
os.utime(path, (stamp, stamp))
print(f"fixture_files: {len(spec)} in {directory}")
return 0
def main():
args = sys.argv[1:]
if len(args) == 2 and args[0] == "--make-fixture":
return make_files(args[1], [
("IMG 001.jpg", "a"), ("img 001.jpg", "b"),
("IMG 002.jpg", "c"), ("photo_002.jpg", "d"),
("bad:name.txt", "e"), ("notes.txt", "f"),
("Report Final.docx", "g")])
if len(args) == 2 and args[0] == "--make-clean-fixture":
return make_files(args[1], [
("IMG 101.jpg", "a"), ("IMG 102.jpg", "b"),
("My Notes.txt", "c")])
if args and args[0] in ("apply", "undo") and len(args) == 2:
return apply_map(args[1], undo=(args[0] == "undo"))
if args and args[0] == "plan":
parser = argparse.ArgumentParser(prog="rename_plan.py plan")
parser.add_argument("directory")
parser.add_argument("--spaces", default="keep",
choices=["keep", "underscore", "hyphen",
"remove"])
parser.add_argument("--case", default="keep",
choices=["keep", "lower", "upper", "title"])
parser.add_argument("--find", default="")
parser.add_argument("--replace", default="")
parser.add_argument("--date-prefix", action="store_true")
parser.add_argument("--number", action="store_true")
parser.add_argument("--write-map", default=None)
ns = parser.parse_args(args[1:])
rules = {"spaces": ns.spaces, "case": ns.case,
"find": ns.find, "replace": ns.replace,
"date_prefix": ns.date_prefix, "number": ns.number}
rows = plan_dir(ns.directory, rules)
return report(rows, ns.directory, rules, ns.write_map)
print("usage: rename_plan.py --make-fixture DIR | "
"--make-clean-fixture DIR | plan DIR [flags] | "
"apply MAP.json | undo MAP.json")
return 2
if __name__ == "__main__":
raise SystemExit(main())
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.