Image Organizer Script
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:
- Create:
/tools/img-organizer/ - Save the script below as:
/tools/img-organizer/index.php - Edit the config at the top (set your uploads path and an admin token).
- Run a dry run first:
/tools/img-organizer/?run=TOKEN&dry=1 - 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.