empty-folder-manager
Version:
Interactive CLI tool for analyzing and removing empty folders from filesystem with .gitignore support and custom glob patterns
345 lines (278 loc) ⢠12 kB
JavaScript
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const EmptyFolderCLI = require('../../remove-empty-folders.js');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));
// Custom EmptyFolderManager class for web API
class EmptyFolderManager extends EmptyFolderCLI {
constructor() {
super();
this.progressCallback = null;
}
setProgressCallback(callback) {
this.progressCallback = callback;
}
// Override the findEmptyFolders method to provide progress updates
async findEmptyFolders(basePath, emptyFolders = [], processedCount = { value: 0 }, ignoredCount = { value: 0 }) {
try {
const entries = await fs.readdir(basePath, { withFileTypes: true });
const directories = entries.filter(entry => entry.isDirectory());
for (let i = 0; i < directories.length; i += this.speedSettings.batchSize) {
const batch = directories.slice(i, i + this.speedSettings.batchSize);
for (const dir of batch) {
const fullPath = path.join(basePath, dir.name);
processedCount.value++;
// Send progress updates
if (this.progressCallback && processedCount.value % this.speedSettings.progressInterval === 0) {
const percentage = Math.min(90, (processedCount.value / 1000) * 100); // Rough estimate
this.progressCallback({
type: 'progress',
percentage,
message: `š Processed ${processedCount.value} folders (ignored ${ignoredCount.value})...`
});
}
try {
const shouldIgnore = await this.shouldIgnorePath(fullPath, this.config.currentPath);
if (shouldIgnore) {
ignoredCount.value++;
continue;
}
await this.findEmptyFolders(fullPath, emptyFolders, processedCount, ignoredCount);
if (await this.isDirectoryEmpty(fullPath)) {
emptyFolders.push(fullPath);
}
if (this.speedSettings.sleepBetweenOps > 0) {
await this.sleep(this.speedSettings.sleepBetweenOps);
}
} catch (error) {
continue;
}
}
}
} catch (error) {
// Skip directories we can't read
}
return emptyFolders;
}
async analyzeDirectory(config) {
// Set up configuration
this.config.currentPath = config.path;
this.useGitIgnore = config.gitignoreEnabled;
this.customPatterns = config.customPatterns || [];
// Set speed settings
const speedPresets = {
'turbo': { sleepBetweenOps: 0, batchSize: 500, progressInterval: 100 },
'fast': { sleepBetweenOps: 10, batchSize: 200, progressInterval: 75 },
'normal': { sleepBetweenOps: 50, batchSize: 100, progressInterval: 50 },
'gentle': { sleepBetweenOps: 100, batchSize: 50, progressInterval: 25 },
'ultra-gentle': { sleepBetweenOps: 250, batchSize: 25, progressInterval: 10 }
};
this.speedSettings = speedPresets[config.speed] || speedPresets.normal;
// Load gitignore files
await this.loadGitIgnoreFiles(this.config.currentPath);
const startTime = Date.now();
const emptyFolders = [];
const processedCount = { value: 0 };
const ignoredCount = { value: 0 };
await this.findEmptyFolders(this.config.currentPath, emptyFolders, processedCount, ignoredCount);
const endTime = Date.now();
const duration = ((endTime - startTime) / 1000).toFixed(2);
const analysisData = {
basePath: this.config.currentPath,
timestamp: Date.now(),
emptyFolders: emptyFolders.sort(),
totalProcessed: processedCount.value,
ignoredCount: ignoredCount.value,
duration: duration,
speedSettings: config.speed,
filteringEnabled: this.useGitIgnore || this.customPatterns.length > 0,
gitIgnoreEnabled: this.useGitIgnore,
customPatterns: [...this.customPatterns]
};
// Save analysis results to file (like CLI version)
await this.ensureAnalysisDir();
const pathHash = this.generatePathHash(this.config.currentPath);
const analysisFile = path.join('.analysis', `analysis-${pathHash}.json`);
await fs.writeFile(analysisFile, JSON.stringify(analysisData, null, 2));
return analysisData;
}
async removeFolders(folders, isDryRun = false) {
let removedCount = 0;
let errorCount = 0;
for (let i = 0; i < folders.length; i++) {
const folderPath = folders[i];
try {
const exists = await fs.access(folderPath).then(() => true).catch(() => false);
if (!exists) continue;
const isEmpty = await this.isDirectoryEmpty(folderPath);
if (!isEmpty) continue;
if (!isDryRun) {
await fs.rmdir(folderPath);
}
removedCount++;
if (this.speedSettings.sleepBetweenOps > 0) {
await this.sleep(this.speedSettings.sleepBetweenOps);
}
} catch (error) {
errorCount++;
}
}
return { removedCount, errorCount };
}
}
// API Routes
// Get existing analysis for a path
app.get('/api/analysis/:pathHash', async (req, res) => {
try {
const { pathHash } = req.params;
const analysisFile = path.join('.analysis', `analysis-${pathHash}.json`);
try {
const analysisData = JSON.parse(await fs.readFile(analysisFile, 'utf8'));
res.json(analysisData);
} catch (error) {
res.status(404).json({ error: 'No analysis found for this path' });
}
} catch (error) {
console.error('Get analysis error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Generate path hash endpoint
app.post('/api/path-hash', async (req, res) => {
try {
const { path: dirPath } = req.body;
if (!dirPath) {
return res.status(400).json({ error: 'Path is required' });
}
const manager = new EmptyFolderManager();
const pathHash = manager.generatePathHash(dirPath);
res.json({ pathHash });
} catch (error) {
console.error('Path hash error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Analyze directory endpoint
app.post('/api/analyze', async (req, res) => {
try {
const config = req.body;
// Validate input
if (!config.path) {
return res.status(400).json({ error: 'Path is required' });
}
// Check if path exists and is a directory
try {
const stats = await fs.stat(config.path);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path must be a directory' });
}
} catch (error) {
return res.status(400).json({ error: 'Path does not exist or is not accessible' });
}
// Set up streaming response
res.writeHead(200, {
'Content-Type': 'application/json',
'Transfer-Encoding': 'chunked'
});
const manager = new EmptyFolderManager();
// Set up progress callback
manager.setProgressCallback((data) => {
res.write(JSON.stringify(data) + '\n');
});
try {
// Send initial progress
res.write(JSON.stringify({
type: 'progress',
percentage: 5,
message: 'Initializing analysis...'
}) + '\n');
const result = await manager.analyzeDirectory(config);
// Send completion
res.write(JSON.stringify({
type: 'complete',
result: result
}) + '\n');
} catch (error) {
res.write(JSON.stringify({
type: 'error',
message: error.message
}) + '\n');
}
res.end();
} catch (error) {
console.error('Analysis error:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Remove folders endpoint
app.post('/api/remove', async (req, res) => {
try {
const { folders, dryRun = false, speed = 'normal' } = req.body;
if (!folders || !Array.isArray(folders)) {
return res.status(400).json({ error: 'Folders array is required' });
}
const manager = new EmptyFolderManager();
// Set speed settings
const speedPresets = {
'turbo': { sleepBetweenOps: 0, batchSize: 500, progressInterval: 100 },
'fast': { sleepBetweenOps: 10, batchSize: 200, progressInterval: 75 },
'normal': { sleepBetweenOps: 50, batchSize: 100, progressInterval: 50 },
'gentle': { sleepBetweenOps: 100, batchSize: 50, progressInterval: 25 },
'ultra-gentle': { sleepBetweenOps: 250, batchSize: 25, progressInterval: 10 }
};
manager.speedSettings = speedPresets[speed] || speedPresets.normal;
const result = await manager.removeFolders(folders, dryRun);
res.json(result);
} catch (error) {
console.error('Removal error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Serve the main page
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// Start server
const server = app.listen(PORT, () => {
console.log(`šļø Empty Folder Manager Web UI running on http://localhost:${PORT}`);
console.log(`š Open your browser and navigate to the URL above to get started!`);
});
// Handle port conflicts gracefully
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
console.error(`ā Port ${PORT} is already in use.`);
console.error('š” Another instance of the web UI might be running.');
console.error('š” Try stopping other instances or use a different port.');
console.error('š” You can kill existing processes with: pkill -f "node.*server.js"');
process.exit(1);
} else {
console.error('ā Server error:', error.message);
process.exit(1);
}
});
// Graceful shutdown handling
process.on('SIGINT', () => {
console.log('\nš Shutting down web server gracefully...');
server.close(() => {
console.log('ā
Web server stopped.');
process.exit(0);
});
});
process.on('SIGTERM', () => {
console.log('\nš Received SIGTERM, shutting down gracefully...');
server.close(() => {
console.log('ā
Web server stopped.');
process.exit(0);
});
});
module.exports = app;