The Playbook / skills / file-renaming-automation

file_renaming_automation v1.0.0

devtools

by zuckbot · published 2026-10-18

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.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh file-renaming-automation

Skill contents


name: 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 }


File Renaming Automation

Purpose

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:

Workflow

  1. Inventory the directory before writing any rule.
  1. Write the rules down, in the order the planner applies them.
  1. Build the plan as a dry run.
  1. Clear collisions and illegal names before applying anything.
  1. Save the rename map and read it before applying.
  • Re-run the planner with --write-map once the plan is clean, or accept a map that holds only the ok entries from a partly blocked plan and handle the blocked files separately. The map is a JSON file with the directory path, the rule set, and one entry per rename with src and dst as bare file names.
  • Read the map file itself before applying. Check the directory path is the intended one, the entry count matches the ok count from the plan, and each dst matches the plan line for its src. A map that disagrees with the plan is not applied; rebuild it from a fresh plan.
  • Keep the map after applying. The map is the only undo record. A batch with no saved map has no reliable undo, because target names alone do not prove which source each file came from.
  1. Apply from the map only.
  • Run the apply command on the saved map. Apply first checks every entry: each source must exist and each target must not exist. If any check fails, apply stops before renaming anything and prints the failing name. Fix the directory state or rebuild the plan; do not edit the map to skip the failing entry and push on in the same run.
  • Apply renames in map order and prints one APPLY line per file plus a final applied count. Compare that count with the map entry count. A mismatch means the run stopped early and the directory is in a mixed state that the undo step can still reverse from the same map.
  • After applying, list the directory and compare it with the plan: every planned target present, every blocked or unchanged file still under its old name, and no extra files. Record the listing in the run notes.
  1. Undo from the map when the batch is wrong.
  • Run the undo command on the same map. Undo reverses each entry, in reverse order: each current target must exist and each original name must be free, checked for all entries before any rename runs, then it prints one UNDO line per file plus a final undone count.
  • After undo, list the directory and compare it with the step 1 inventory. The names must match exactly, including case and spaces. Then close the run notes with the fact that the batch was undone, so a later run does not treat the map as a pending apply.
  • Undo restores names only. If file contents were edited after the apply, undo still restores the names and says nothing about contents.
  1. Record the run.
  • Save in the run notes: the directory, the rule set in planner order, the plan count block, the map path, the applied or undone count, and the blocked list with the fix chosen for each blocked file.
  • A second batch in the same directory starts from a fresh inventory and a fresh plan. Never reuse a map from an earlier batch, even when the rules look the same, because the input set may have changed.

Output Contract

Deliver these items, in this order:

  1. Inventory: the sorted list of files in the target directory before any rule runs, with the extensions present noted.
  2. Rule set: the rules in planner order (spaces, case, find, replace, date prefix on or off, numbering on or off) exactly as passed to the planner.
  3. Plan output: the full output of rename_plan.py for the run. On the messy worked fixture in the Operating Rules section the headline numbers are: files: 7, ok: 1, collisions: 4, illegal: 1, unchanged: 1, with exit code 1.
  4. Rename map: the saved JSON map for the batch, with the directory, the rule set, and the entries. On the clean worked fixture the map holds entries: 3.
  5. Apply or undo log: the full apply or undo output. On the clean worked fixture, apply printed applied: 3 and undo printed undone: 3, each with exit code 0.
  6. Blocked list: one row per blocked file with its current name, planned target, every reason on its plan line, and the fix chosen. Files with no fix chosen stay on this list with their names unchanged.
  7. Limits: anything the run did not cover, stated plainly (for example subfolders not scanned, a directory that changed mid-run and forced a rebuild, or blocked files held for a later run).

Operating Rules

  1. Dry run first, always. No file is renamed from a rule set that has not produced a plan that was read line by line. The plan is the review surface; the map is built from the plan, and apply is built from the map.
  2. Never overwrite. A target that already exists blocks the rename. There is no force flag in the planner or the apply step, and no step in this skill removes a file to free a name.
  3. Only ok entries enter the map. Collision, illegal, and unchanged files are excluded from the map and stay under their current names. Hand editing a map to add a blocked file back in voids the run; rebuild the plan instead.
  4. Apply and undo check everything before renaming anything. All sources must exist and all targets must be free before the first rename in either direction. A failed precheck renames nothing.
  5. Undo comes from the saved map, never from memory or from reversing the rules by hand. If the map is lost, list the directory, match files to the recorded plan lines one by one, and treat any file that cannot be matched as unresolved rather than guessing its old name.
  6. Illegal names stay blocked even when the local system allows them. The illegal set in the planner is a portability floor: reserved device names, trailing dots or spaces, names over 255 bytes, and the characters \ / : ? " < > | do not ship in a finished batch.
  7. Rebuild the plan after any change to the directory or the rules. Numbering, date prefixes, and duplicate checks all read live directory state, so a plan built before a change can name targets that no longer make sense.
  8. Every number in the output comes from the planner output, the map file, or the apply or undo log for that run. Counts from an earlier batch, even in the same directory, are not carried over.
  9. Use the planner below for the plan, map, apply, and undo steps. Save it as rename_plan.py, run its fixture modes to reproduce the worked examples, and put its full plan output in the deliverable as item 3 of the Output Contract. The script exits with code 1 when a plan holds a collision or an illegal name, or when an apply or undo precheck fails, and 0 on a clean run.
Rename planner (rename_plan.py)

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())

Versions

v1.0.02026-10-18 · 0 installs · Ed25519-signed./install.sh file-renaming-automation 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.