1. The Problem
Build a file organizer that scans a directory and moves files into subfolders by type (Images/, Documents/, Videos/, etc.). Includes a --dry-run flag that shows what would happen without moving anything, and an undo log so you can reverse the operation.
Why this project? fs/promises async file operations, extension-to-category mapping, dry-run pattern, and writing an undo log — the same patterns used in backup tools, CI pipelines, and file management scripts.
2. How to Think About This
Scan the directory with readdir, filter out subdirectories, map each file's extension to a category, create the target folder if needed (mkdir with { recursive: true }), then rename (move) the file. Record each move in an undo log so a separate --undo command can reverse it.
3. The Build
import { readdir, stat, rename, mkdir, writeFile, readFile } from "fs/promises";
import { join, extname, basename } from "path";
export const CATEGORIES = {
Images: [".jpg",".jpeg",".png",".gif",".webp",".svg",".ico"],
Documents: [".pdf",".doc",".docx",".txt",".md",".csv",".xlsx",".pptx"],
Videos: [".mp4",".mov",".avi",".mkv",".webm"],
Audio: [".mp3",".wav",".flac",".aac",".ogg"],
Code: [".js",".mjs",".ts",".py",".html",".css",".json",".sh"],
Archives: [".zip",".tar",".gz",".rar",".7z"],
};
export function getCategory(filename) {
const ext = extname(filename).toLowerCase();
return Object.entries(CATEGORIES).find(([, exts]) => exts.includes(ext))?.[0] ?? "Other";
}
export async function organise(dir, { dryRun = false, undoLog = "undo.json" } = {}) {
const entries = await readdir(dir);
const moves = [];
for (const entry of entries) {
const srcPath = join(dir, entry);
const info = await stat(srcPath);
if (!info.isFile()) continue; // skip directories
if (entry.startsWith(".")) continue; // skip hidden files
if (entry === basename(undoLog)) continue; // skip our own undo log
const category = getCategory(entry);
const destDir = join(dir, category);
const destPath = join(destDir, entry);
moves.push({ from: srcPath, to: destPath, category });
if (!dryRun) {
await mkdir(destDir, { recursive: true });
await rename(srcPath, destPath);
}
}
if (!dryRun && moves.length > 0) {
await writeFile(join(dir, undoLog), JSON.stringify(moves, null, 2));
}
return moves;
}
export async function undo(dir, undoLog = "undo.json") {
const logPath = join(dir, undoLog);
const moves = JSON.parse(await readFile(logPath, "utf-8"));
for (const { from, to } of moves.reverse()) {
await rename(to, from).catch(() => {}); // ignore if already moved back
}
return moves.length;
}
// ── CLI ─────────────────────────────────────────────────────────────────────
const [,, targetDir = ".", ...flags] = process.argv;
const dryRun = flags.includes("--dry-run");
const doUndo = flags.includes("--undo");
if (doUndo) {
const count = await undo(targetDir);
console.log(`Undone: ${count} file(s) moved back.`);
} else {
const moves = await organise(targetDir, { dryRun });
if (!moves.length) { console.log("No files to organise."); process.exit(0); }
const byCategory = {};
moves.forEach(m => { byCategory[m.category] = (byCategory[m.category] || 0) + 1; });
console.log(dryRun ? "
Dry run — no files moved:" : "
Organised:");
Object.entries(byCategory).sort().forEach(([cat, n]) =>
console.log(` ${cat}/ ← ${n} file${n !== 1 ? "s" : ""}`)
);
if (!dryRun) console.log(`
Undo with: node organizer.mjs ${targetDir} --undo`);
}
4. Test & Prove
import { getCategory, CATEGORIES } from "./organizer.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log(" ✓",d);p++;}catch(e){console.log(" ✗",d,"—",e.message);f++;}}
test("jpg → Images", () => { if(getCategory("photo.jpg")!=="Images") throw new Error(); });
test("PDF → Documents", () => { if(getCategory("report.PDF")!=="Documents") throw new Error(); });
test("mp4 → Videos", () => { if(getCategory("clip.mp4")!=="Videos") throw new Error(); });
test("js → Code", () => { if(getCategory("app.js")!=="Code") throw new Error(); });
test("zip → Archives", () => { if(getCategory("backup.zip")!=="Archives") throw new Error(); });
test("unknown → Other", () => { if(getCategory("file.xyz")!=="Other") throw new Error(); });
test("case-insensitive", () => { if(getCategory("IMG.PNG")!=="Images") throw new Error(); });
test("all extensions covered", () => {
const allExts = Object.values(CATEGORIES).flat();
const dupes = allExts.filter((e,i,a) => a.indexOf(e)!==i);
if(dupes.length) throw new Error("Duplicate extensions: " + dupes);
});
console.log(`
${p} passed, ${f} failed`);5. The Interface
node organizer.mjs ~/Downloads --dry-run # Dry run — no files moved: # Documents/ ← 4 files # Images/ ← 12 files # Videos/ ← 2 files node organizer.mjs ~/Downloads # Organised: # Documents/ ← 4 files # Images/ ← 12 files # Undo with: node organizer.mjs ~/Downloads --undo node organizer.mjs ~/Downloads --undo # Undone: 18 file(s) moved back.
6. Run It
node organizer.mjs . --dry-run # preview on current dir
node organizer.mjs ~/Downloads # actually move files
node organizer.test.mjs🎯 Try this next
- Add
--by-dateflag — organise into2026/06/folders by file modification date - Add duplicate detection — skip files with the same name already in the target folder
- Add a config file (
.organizer.json) so users can define custom category rules - Add
--watchmode usingfs.watch()to organise new files as they appear
What you practised
fs/promises async file operations (readdir, stat, mkdir, rename), the dry-run pattern, writing an undo log to JSON, extname() from Node's path module, and { recursive: true } for safe directory creation.
Reference: Async & Promises · Objects · Error Handling