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;
};
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:
-
.gitignoreis treated as having no extension, becauseidx <= 0returns the whole thing asbase. -
archive.tar.gzis treated asbase = "archive.tar"andext = "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++;
}
});
};
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();
};
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();
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;
}
};
A few consequences fall straight out of that code:
- Regex is always created with the global
gflag. There’s no UI for flags likeiorm. - “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;
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
- 批量檔案改名工具 — 繁體中文
- 批量文件改名工具 — 简体中文
- Batch File Renamer — English
- 一括ファイル名変更ツール — 日本語
- 파일 일괄 이름 변경 도구 — 한국어
- Renommer des fichiers en masse — Français
- Массовое переименование файлов — Русский
- Dateien in Stapeln umbenennen — Deutsch
- Ganti Nama File Massal — Bahasa Indonesia
- Renombrar archivos en lote — Español
- Đổi tên file theo lô — Tiếng Việt
- เครื่องมือเปลี่ยนชื่อไฟล์แบบกลุ่ม — ไทย
- Masowa zmiana nazw plików — Polski
- Toplu Dosya Yeniden Adlandırma — Türkçe
- Rinomina file in blocco — Italiano
- Renomear arquivos em lote — Português
- Bestanden in bulk hernoemen — Nederlands
- Масове перейменування файлів — Українська
Top comments (0)