foldertree-cli
Version:
Create and visualize folder structures using tree diagrams from the command line
370 lines (302 loc) • 11.9 kB
JavaScript
const fs = require('fs');
const path = require('path');
const ignore = require('ignore');
const DEFAULT_IGNORED_PATTERNS = [
// Version Control
'.git/**',
'.svn/**',
'.hg/**',
'.bzr/**',
// Dependencies
'node_modules/**',
'bower_components/**',
'vendor/**',
// IDE and Editor files
'.idea/**',
'.vscode/**',
'.vs/**',
'*.sublime-*',
// Build and Cache
'dist/**',
'build/**',
'out/**',
'.cache/**',
'.tmp/**',
'.temp/**',
// OS files
'.DS_Store',
'Thumbs.db',
// Log files
'*.log',
'logs/**',
// Coverage reports
'coverage/**',
'.nyc_output/**',
// Environment and secrets
'.env*',
'.env.local',
'.env.*.local',
// Additional common ignores
'*.pyc',
'__pycache__/**',
'.sass-cache/**',
'.next/**',
'.nuxt/**',
'.serverless/**',
'.webpack/**',
'.parcel-cache/**'
];
class StructureValidator {
static VALID_LINE_PATTERNS = {
EMPTY: /^$/,
SEPARATOR: /^[\s│]*$/,
FILE_OR_DIR: /^[\s│]*(?:├──|└──)\s+[\w\-\.\/]+$/
};
static ERROR_MESSAGES = {
INVALID_LINE: 'Invalid line format',
INVALID_INDENTATION: 'Invalid indentation',
INVALID_CHARACTERS: 'Invalid characters between separator and tree symbol',
MISSING_TREE_CHAR: 'Missing tree character (├── or └──)',
EMPTY_FILE: 'Input file is empty',
INVALID_FILE_START: 'File must start with a valid entry (├── or └──)',
INCONSISTENT_INDENTATION: 'Inconsistent indentation level'
};
validateFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
if (lines.length === 0) {
throw new Error(StructureValidator.ERROR_MESSAGES.EMPTY_FILE);
}
const errors = [];
let previousIndentLevel = 0;
lines.forEach((line, index) => {
const lineNumber = index + 1;
const trimmedLine = line.trimRight();
if (this.isEmptyOrSeparatorLine(trimmedLine)) {
return;
}
// Check for invalid characters between separator and tree symbol
const separatorMatch = trimmedLine.match(/^[\s│]*/);
const afterSeparator = trimmedLine.slice(separatorMatch[0].length);
if (!afterSeparator.startsWith('├──') && !afterSeparator.startsWith('└──')) {
errors.push(`Line ${lineNumber}: ${StructureValidator.ERROR_MESSAGES.INVALID_CHARACTERS}`);
return;
}
const validationError = this.validateLine(trimmedLine, previousIndentLevel, lineNumber);
if (validationError) {
errors.push(`Line ${lineNumber}: ${validationError}`);
}
previousIndentLevel = this.getIndentationLevel(trimmedLine);
});
if (errors.length > 0) {
throw new Error(errors.join('\n'));
}
return true;
}
isEmptyOrSeparatorLine(line) {
return StructureValidator.VALID_LINE_PATTERNS.EMPTY.test(line) ||
StructureValidator.VALID_LINE_PATTERNS.SEPARATOR.test(line);
}
validateLine(line, previousIndentLevel, lineNumber) {
if (!StructureValidator.VALID_LINE_PATTERNS.FILE_OR_DIR.test(line)) {
return StructureValidator.ERROR_MESSAGES.INVALID_LINE;
}
const currentIndentLevel = this.getIndentationLevel(line);
if (currentIndentLevel > previousIndentLevel + 1) {
return StructureValidator.ERROR_MESSAGES.INCONSISTENT_INDENTATION;
}
return null;
}
getIndentationLevel(line) {
const match = line.match(/^[\s│]*/);
return match ? Math.floor(match[0].length / 4) : 0;
}
}
class FolderStructureManager {
constructor(options = {}) {
this.validator = new StructureValidator();
this.ignoreRules = ignore();
this.includeHidden = options.includeHidden || false;
// Always initialize with default ignores unless explicitly included
if (!this.includeHidden) {
this.ignoreRules.add(DEFAULT_IGNORED_PATTERNS);
}
}
loadIgnoreRules(gitignorePath) {
try {
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
this.ignoreRules.add(gitignoreContent);
} catch (error) {
// silently continue if no valid .gitignore
// console.error('Error loading .gitignore:', error);
}
}
// Creates actual folders and files from structure file
createStructureFromFile(inputFile, targetDir) {
// Validate file before processing
try {
this.validator.validateFile(inputFile);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`ENOENT: no such file or directory, open '${inputFile}'`);
}
throw error;
}
const content = fs.readFileSync(inputFile, 'utf8');
const lines = content.split('\n');
// Create target directory if it doesn't exist
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// Stack to keep track of current path components and their levels
let pathStack = [];
lines.forEach((line) => {
// Skip empty lines or lines containing only │
if (!line.trim() || line.trim() === '│') return;
// Count leading spaces and │ characters to determine level
const leadingChars = line.match(/^[\s│]*/)[0];
const level = Math.floor((leadingChars.match(/\s/g) || []).length / 2);
// Extract the actual name (remove tree characters ├── or └── and trim)
const name = line.replace(/^[\s│]*(?:├──|└──)\s*/, '').trim();
if (!name) return;
// Pop items from stack until we reach the parent level
while (pathStack.length > 0 && pathStack[pathStack.length - 1].level >= level) {
pathStack.pop();
}
// Add current item to stack
pathStack.push({ name, level });
// Create the full path from stack items
const pathComponents = pathStack.map(item => item.name);
const fullPath = path.join(targetDir, ...pathComponents);
try {
if (name.endsWith('/')) {
// It's a directory - remove trailing slash for creation
const dirPath = fullPath.slice(0, -1);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
} else {
// Ensure parent directory exists
const parentDir = path.dirname(fullPath);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
// Create file if it doesn't exist
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, '');
}
}
} catch (error) {
console.error(`Error creating ${fullPath}:`, error);
}
});
}
// Generates structure text from existing directory
generateStructureText(directory, outputFile) {
const structure = this.scanDirectory(directory);
const treeText = this.generateTree(structure);
fs.writeFileSync(outputFile, treeText);
}
scanDirectory(directory, relativePath = '') {
const structure = {};
const items = fs.readdirSync(directory);
items.forEach(item => {
const fullPath = path.join(directory, item);
const itemRelativePath = path.join(relativePath, item);
// Skip if item matches ignore rules
if (this.ignoreRules.ignores(itemRelativePath)) {
return;
}
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
const subStructure = this.scanDirectory(fullPath, itemRelativePath);
if (Object.keys(subStructure).length > 0) {
structure[item + '/'] = subStructure;
}
} else {
structure[item] = null;
}
});
return structure;
}
generateTree(structure, prefix = '') {
let result = '';
const entries = Object.entries(structure);
entries.forEach(([key, value], index) => {
const isLast = index === entries.length - 1;
const connector = isLast ? '└──' : '├──';
result += `${prefix}${connector} ${key}\n`;
if (value !== null) {
const newPrefix = prefix + (isLast ? ' ' : '│ ');
result += this.generateTree(value, newPrefix);
}
});
return result;
}
}
// CLI handling
function showHelp() {
console.log(`
Usage:
foldertree-cli (create-folders|create|c) <input-file> <target-directory>
foldertree-cli (generate-file|generate|g) <source-directory> <output-file> [options]
Commands:
create-folders, create, c - Create folder structure from input file
generate-file, generate, g - Generate structure text file from existing directory
Options:
--ignore <gitignore-file> - Specify a .gitignore file to exclude additional paths
--include-hidden - Include hidden and system folders (like .git, .vscode)
Examples:
foldertree-cli create-folders ./structure.txt ./my-project
foldertree-cli generate-file ./my-project ./output-structure.txt
foldertree-cli generate-file ./my-project ./output-structure.txt --ignore ./.gitignore
foldertree-cli generate-file ./my-project ./output-structure.txt --include-hidden
`);
}
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length < 3) {
showHelp();
process.exit(1);
}
const command = args[0];
const arg1 = args[1];
const arg2 = args[2];
// Parse options
const ignoreIndex = args.indexOf('--ignore');
const gitignorePath = ignoreIndex !== -1 ? args[ignoreIndex + 1] : null;
const includeHidden = args.includes('--include-hidden');
const manager = new FolderStructureManager({ includeHidden });
// Load additional ignore rules from .gitignore if provided
if (gitignorePath) {
manager.loadIgnoreRules(path.resolve(gitignorePath));
}
try {
switch (command) {
case 'c':
case 'create-folders':
case 'create':
const inputFile = path.resolve(arg1);
const targetDir = path.resolve(arg2);
manager.createStructureFromFile(inputFile, targetDir);
console.log(`Structure created successfully in ${targetDir}`);
break;
case 'g':
case 'generate-file':
case 'generate':
const sourceDir = path.resolve(arg1);
const outputFile = path.resolve(arg2);
manager.generateStructureText(sourceDir, outputFile);
console.log(`Structure file generated successfully at ${outputFile}`);
break;
default:
showHelp();
process.exit(1);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
}
module.exports = { FolderStructureManager, StructureValidator };