Image Organizer Script

February 7, 2026 NEW

A lightweight image organizer script you can drop onto any site that has an /uploads/ folder. It scans for images and automatically moves them into a clean folder structure like:

/uploads/2026/02/ (year/month)

Why it’s useful: Upload folders get messy fast—especially on content sites. This keeps things tidy, makes backups easier, and prevents giant directories that slow down file operations over time.

What it does:

  • Scans a single uploads folder for image files (jpg/png/webp/gif).
  • Moves images into year/month folders based on file modified time.
  • Optionally adds a subfolder by type (jpg, png, etc.).
  • Prints a small report: moved, skipped, errors.
  • Includes a “dry run” mode so you can preview moves safely.

Install:

  1. Create: /tools/img-organizer/
  2. Save the script below as: /tools/img-organizer/index.php
  3. Edit the config at the top (set your uploads path and an admin token).
  4. Run a dry run first:
    /tools/img-organizer/?run=TOKEN&dry=1
  5. Then actually run it:
    /tools/img-organizer/?run=TOKEN

Important: If your site stores image paths in a database, moving files will break those links unless you also update stored paths. This is best for sites that reference images by scanning directories, or for newly uploaded “unattached” images.

<?php
declare(strict_types=1);

/**
 * Tiny Image Organizer (No DB)
 * Moves images from a flat uploads folder into /YYYY/MM/ (optional /ext/)
 *
 * Run:
 *   /tools/img-organizer/?run=CHANGE_ME_TOKEN&dry=1   (preview)
 *   /tools/img-organizer/?run=CHANGE_ME_TOKEN        (do it)
 */

header('X-Content-Type-Options: nosniff');

$CFG = [
  // Change these:
  'token'        => 'CHANGE_ME_TOKEN',
  'uploads_dir'  => $_SERVER['DOCUMENT_ROOT'] . '/uploads', // source folder
  'use_ext_dir'  => false, // if true: /YYYY/MM/jpg/ etc.

  // Behavior:
  'max_files'    => 2500,  // safety cap per run
  'allowed_ext'  => ['jpg','jpeg','png','webp','gif'],
  'skip_if_in_subdir' => true, // skip files already organized in subfolders
];

function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }

function ensure_dir(string $dir): bool {
  return is_dir($dir) || @mkdir($dir, 0755, true);
}

function is_allowed(string $path, array $allowedExt): bool {
  $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
  return $ext !== '' && in_array($ext, $allowedExt, true);
}

function relpath(string $base, string $full): string {
  $base = rtrim($base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
  if (strpos($full, $base) === 0) return substr($full, strlen($base));
  return $full;
}

$tok = (string)($_GET['run'] ?? '');
if ($tok === '' || !hash_equals((string)$CFG['token'], $tok)) {
  http_response_code(403);
  header('Content-Type: text/plain; charset=utf-8');
  echo "Forbidden.\n";
  exit;
}

$dry = isset($_GET['dry']) && (string)$_GET['dry'] === '1';
$root = rtrim((string)$CFG['uploads_dir'], DIRECTORY_SEPARATOR);

if ($root === '' || !is_dir($root)) {
  header('Content-Type: text/plain; charset=utf-8');
  echo "uploads_dir does not exist: {$root}\n";
  exit;
}

$files = [];
$it = new DirectoryIterator($root);

foreach ($it as $f) {
  if ($f->isDot()) continue;

  // optionally skip already-sorted subfolders
  if ($f->isDir()) {
    if (!empty($CFG['skip_if_in_subdir'])) continue;
    // if you want recursive sorting, set skip_if_in_subdir=false and add recursion (kept tiny here)
    continue;
  }

  if (!$f->isFile()) continue;

  $path = $f->getPathname();
  if (!is_allowed($path, (array)$CFG['allowed_ext'])) continue;
  $files[] = $path;

  if (count($files) >= (int)$CFG['max_files']) break;
}

$moved = 0;
$skipped = 0;
$errors = 0;
$rows = [];

foreach ($files as $src) {
  $mtime = @filemtime($src);
  if (!$mtime) { $errors++; $rows[] = ['ERR', relpath($root,$src), 'mtime']; continue; }

  $y = gmdate('Y', $mtime);
  $m = gmdate('m', $mtime);

  $ext = strtolower(pathinfo($src, PATHINFO_EXTENSION));
  $destDir = $root . DIRECTORY_SEPARATOR . $y . DIRECTORY_SEPARATOR . $m;
  if (!empty($CFG['use_ext_dir'])) $destDir .= DIRECTORY_SEPARATOR . $ext;

  if (!ensure_dir($destDir)) { $errors++; $rows[] = ['ERR', relpath($root,$src), 'mkdir']; continue; }

  $baseName = basename($src);
  $dest = $destDir . DIRECTORY_SEPARATOR . $baseName;

  // avoid overwrite: add suffix if needed
  if (file_exists($dest)) {
    $name = pathinfo($baseName, PATHINFO_FILENAME);
    $k = 2;
    do {
      $dest = $destDir . DIRECTORY_SEPARATOR . $name . '-' . $k . '.' . $ext;
      $k++;
    } while (file_exists($dest) && $k < 200);
    if (file_exists($dest)) { $errors++; $rows[] = ['ERR', relpath($root,$src), 'name-collision']; continue; }
  }

  $from = relpath($root, $src);
  $to   = relpath($root, $dest);

  if ($dry) {
    $skipped++;
    $rows[] = ['DRY', $from, $to];
    continue;
  }

  if (@rename($src, $dest)) {
    $moved++;
    $rows[] = ['OK', $from, $to];
  } else {
    // fallback copy+unlink for cross-device cases
    if (@copy($src, $dest) && @unlink($src)) {
      $moved++;
      $rows[] = ['OK', $from, $to];
    } else {
      $errors++;
      $rows[] = ['ERR', $from, 'move-failed'];
    }
  }
}

header('Content-Type: text/html; charset=utf-8');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tiny Image Organizer</title>
<style>
  body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#0b0f16;color:#e8eef8}
  .wrap{max-width:980px;margin:0 auto;padding:18px}
  .card{background:#111a26;border:1px solid rgba(255,255,255,.10);border-radius:16px;padding:14px;margin:12px 0}
  table{width:100%;border-collapse:collapse}
  th,td{padding:10px;border-bottom:1px solid rgba(255,255,255,.08);vertical-align:top}
  th{text-align:left;font-size:12px;opacity:.75}
  .muted{opacity:.75}
  .ok{color:#7CFF9A} .dry{color:#FFD37C} .err{color:#FF7C7C}
  code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
</style>
</head>
<body>
<div class="wrap">
  <div class="card">
    <h1 style="margin:0 0 8px;font-size:20px">Tiny Image Organizer</h1>
    <div class="muted">Mode: <b><?php echo $dry ? 'Dry run (no changes)' : 'Live run (moving files)'; ?></b></div>
    <div class="muted" style="margin-top:6px">
      Moved: <b class="ok"><?php echo (int)$moved; ?></b> •
      Planned: <b class="dry"><?php echo (int)$skipped; ?></b> •
      Errors: <b class="err"><?php echo (int)$errors; ?></b>
    </div>
    <div class="muted" style="margin-top:8px">
      Output structure: <code>/uploads/YYYY/MM/<?php echo !empty($CFG['use_ext_dir']) ? 'ext/' : ''; ?></code>
    </div>
  </div>

  <div class="card">
    <table>
      <thead>
        <tr><th>Status</th><th>From</th><th>To / Note</th></tr>
      </thead>
      <tbody>
      <?php foreach ($rows as $r):
        $cls = ($r[0]==='OK')?'ok':(($r[0]==='DRY')?'dry':'err');
      ?>
        <tr>
          <td class="<?php echo $cls; ?>"><b><?php echo h($r[0]); ?></b></td>
          <td><code><?php echo h((string)$r[1]); ?></code></td>
          <td><code><?php echo h((string)$r[2]); ?></code></td>
        </tr>
      <?php endforeach; ?>
      </tbody>
    </table>
  </div>

  <div class="card">
    <div class="muted" style="line-height:1.5">
      <b>Heads up:</b> If your site stores image URLs in a database, moving files changes paths.
      This organizer is ideal for “free” uploads folders, staging directories, or setups that generate URLs by scanning directories.
    </div>
  </div>
</div>
</body>
</html>

Comments (0)

No comments yet — be the first.

← Back to all scripts