Tier 2 — Intermediate

File Organizer

Sort files into folders by extension — fs.readdir, fs.rename, dry-run mode, undo log.

🐍 Python 🟨 JavaScript

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

organizer.mjs
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

  1. Add --by-date flag — organise into 2026/06/ folders by file modification date
  2. Add duplicate detection — skip files with the same name already in the target folder
  3. Add a config file (.organizer.json) so users can define custom category rules
  4. Add --watch mode using fs.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

Try It Yourself