DEV Community

Joe Lin for BeGoodTool.com

Posted on

A batch file renamer looks simple until you have to decide what counts as “the filename”

I thought a browser-based batch renamer would be one of those pleasantly boring tools: pick some files, add a prefix, maybe do a find/replace, download a ZIP, done.

Then I actually built one and ran into the part that’s easy to miss when you only think about the UI: “rename a file” is not one operation. You have to decide whether you’re touching the basename or the full filename, what happens to extensions, how pasted mapping tables should parse, whether photo.JPG and photo.jpg count as a collision, and how much safety you should enforce before handing someone a ZIP full of renamed files.

The Vue component for this tool is pretty honest about those tradeoffs. It doesn’t try to be magical. It’s mostly a small pipeline over File objects plus a few guardrails, and the interesting part is where those guardrails start and stop.

Rule-based renaming works because it splits “basename” from “extension” first

The most important decision in the whole component is that rule-based renaming does not treat the filename as one raw string. It splits on the last dot, transforms only the base part, then reattaches the extension at the end:

const splitName = (fullName) => {
  const idx = fullName.lastIndexOf(".");
  if (idx <= 0) return { base: fullName, ext: "" };
  return { base: fullName.slice(0, idx), ext: fullName.slice(idx + 1) };
};

const buildRuleName = (originalName, index) => {
  const { base, ext } = splitName(originalName);
  let transformedBase = applyFindReplace(base);
  transformedBase = applyCaseMode(transformedBase);

  const prefixText = ruleConfig.prefix || "";
  const suffixText = ruleConfig.suffix || "";
  let numStr = "";
  if (ruleConfig.numberingEnabled) {
    const num = (Number(ruleConfig.numberingStart) || 0) + index;
    numStr = String(num).padStart(Number(ruleConfig.numberingPadding) || 1, "0");
  }

  let result;
  if (!ruleConfig.numberingEnabled) {
    result = prefixText + transformedBase + suffixText;
  } else if (ruleConfig.numberingPosition === "beforeName") {
    result = numStr + prefixText + transformedBase + suffixText;
  } else if (ruleConfig.numberingPosition === "beforeExt") {
    result = prefixText + transformedBase + suffixText + numStr;
  } else {
    result = prefixText + numStr + transformedBase + suffixText;
  }
  return ext ? `${result}.${ext}` : result;
};
Enter fullscreen mode Exit fullscreen mode

That one split buys a lot.

It means find/replace won’t accidentally turn .jpg into .pngg. It means uppercase/lowercase conversion only hits the name part. It also makes the numbering positions sane: “before extension” really is before the extension, not just “somewhere near the right side of the string.”

There are a couple of subtle consequences too:

  • .gitignore is treated as having no extension, because idx <= 0 returns the whole thing as base.
  • archive.tar.gz is treated as base = "archive.tar" and ext = "gz", so only the last suffix is protected.

That’s a very normal choice, but it’s still a choice. The first time you build something like this, you realize there isn’t a universal answer to “what is the extension?” There’s just a policy.

The table-mapping mode is basically spreadsheet paste support, not a full parser

The second mode is more pragmatic than clever. Instead of deriving names from rules, it lets you paste a list of old/new filename pairs, then applies those matches case-insensitively:

const applyPasteMapping = () => {
  const lines = pasteText.value.split(/\r?\n/).filter((l) => l.trim());
  const map = {};
  lines.forEach((line) => {
    const parts = line.split(/[,\t]/);
    if (parts.length >= 2) {
      const oldName = parts[0].trim();
      const newName = parts.slice(1).join(",").trim();
      if (oldName) map[oldName.toLowerCase()] = newName;
    }
  });
  let appliedCount = 0;
  files.value.forEach((f) => {
    const match = map[f.name.toLowerCase()];
    if (match) {
      tableNewNames[f.id] = match;
      appliedCount++;
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

I like this because it’s the right kind of unsophisticated. If someone copied two columns out of Excel, Google Sheets, or Numbers, this will probably work immediately. Newlines split rows. Comma or tab splits columns. Names are matched with toLowerCase(), which avoids the annoying “why didn’t IMG_0001.JPG match img_0001.jpg?” class of bug.

But this is also where the tool gets a little fragile in a very real way.

It is not doing CSV parsing. There’s no quoted-field handling here. If the original filename contains a comma, parts[0] stops at that comma and your mapping becomes ambiguous. The code does try to be forgiving on the new-name side by joining the remaining columns back with commas, but the old-name side is still just “everything before the first comma or tab.” In practice that means tab-separated paste is safer if your filenames are messy.

This mode also changes the contract of the tool a bit: you’re no longer editing just the basename logic. You’re editing the final filename string directly.

The privacy story is believable because the ZIP is built from File objects already in memory

A lot of “your files never leave the browser” tools say that in the marketing copy, then hide the interesting part. This component is refreshingly direct about it.

The preview list is computed locally, duplicate names are checked locally, and the ZIP is generated locally with JSZip:

const previewList = computed(() => {
  const list = files.value.map((f, idx) => {
    let newName;
    if (mode.value === "rule") {
      newName = buildRuleName(f.name, idx);
    } else {
      newName = (tableNewNames[f.id] || "").trim() || f.name;
    }
    return {
      id: f.id,
      originalName: f.name,
      size: f.size,
      newName,
      isDuplicate: false,
    };
  });

  const counts = {};
  list.forEach((item) => {
    const key = item.newName.toLowerCase();
    counts[key] = (counts[key] || 0) + 1;
  });
  list.forEach((item) => {
    item.isDuplicate = counts[item.newName.toLowerCase()] > 1;
  });

  return list;
});

const downloadZip = async () => {
  const zip = new JSZip();
  files.value.forEach((f) => {
    const finalName = (nameMap[f.id] || f.name).trim() || f.name;
    zip.file(finalName, f.file);
  });
  const blob = await zip.generateAsync({ type: "blob" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "renamed_files.zip";
  a.click();
};
Enter fullscreen mode Exit fullscreen mode

That’s the whole architecture, really. The component never needs to upload content anywhere because it already has browser File objects from the input and drag/drop handlers. Renaming is just “choose a different string for the ZIP entry name.”

I also like that duplicate detection is case-insensitive:

const key = item.newName.toLowerCase();
Enter fullscreen mode Exit fullscreen mode

That’s stricter than ZIP technically has to be, but it’s the sort of boring defensive choice that saves people from weird cross-platform behavior later. If two entries only differ by case, plenty of users will still experience that as “those filenames collided.” Blocking the download is a better default than pretending that edge case won’t matter.

The gotchas are where the implementation gets interesting

The most honest part of this component is that it has a few edges a more polished marketing page probably wouldn’t mention.

The regex option, for example, is intentionally narrow:

const applyFindReplace = (base) => {
  if (!ruleConfig.findText) return base;
  try {
    if (ruleConfig.useRegex) {
      const re = new RegExp(ruleConfig.findText, "g");
      return base.replace(re, ruleConfig.replaceText);
    }
    return base.split(ruleConfig.findText).join(ruleConfig.replaceText);
  } catch (err) {
    regexError.value = t("batchFileRenamer.errorRegexInvalid");
    return base;
  }
};

const applyCaseMode = (base) => {
  switch (ruleConfig.caseMode) {
    case "capitalize":
      return base.length ? base.charAt(0).toUpperCase() + base.slice(1) : base;
    default:
      return base;
  }
};
Enter fullscreen mode Exit fullscreen mode

A few consequences fall straight out of that code:

  • Regex is always created with the global g flag. There’s no UI for flags like i or m.
  • “Capitalize” means only the first character of the whole basename. It is not title case.
  • Invalid regex input fails safely: it shows an error and leaves the current basename unchanged.

The bigger gotcha, though, is the mismatch between rule mode and table mode.

In rule mode, the extension is protected by splitName() and reattached at the end. In table mode, the new name comes from this:

newName = (tableNewNames[f.id] || "").trim() || f.name;
Enter fullscreen mode Exit fullscreen mode

So if you manually type vacation-01 instead of vacation-01.jpg, the tool will happily use that exact filename in the ZIP. In other words: the code really does preserve extensions automatically in the rule-based path, but not in the table-editing path. That’s not necessarily wrong, but it is a real behavioral difference, and it’s exactly the kind of thing users only notice after downloading.

That’s also why I think tools like this are more interesting than they look. The UI says “rename files,” but the implementation is mostly a set of small policies about what the tool should protect you from and what it should let you do anyway.

I turned that into a small free tool here if you want to try the exact behavior without rebuilding it yourself: Batch File Renamer.


Available in other languages

Top comments (0)