UNPKG

canvaslms-cli

Version:

A command line tool for interacting with Canvas LMS API

890 lines (804 loc) 30 kB
/** * Interactive prompt utilities */ import readline from 'readline'; import fs from 'fs'; import path from 'path'; import AdmZip from 'adm-zip'; import chalk from 'chalk'; // Key codes for raw terminal input const KEYS = { UP: '\u001b[A', DOWN: '\u001b[B', LEFT: '\u001b[D', RIGHT: '\u001b[C', SPACE: ' ', ENTER: '\r', ESCAPE: '\u001b', BACKSPACE: '\u007f', TAB: '\t' }; /** * Create readline interface for user input */ function createReadlineInterface() { return readline.createInterface({ input: process.stdin, output: process.stdout }); } /** * Prompt user for input */ function askQuestion(rl, question) { return new Promise((resolve) => { rl.question(question, (answer) => { resolve(answer.trim()); }); }); } /** * Ask a question with validation and retry logic */ function askQuestionWithValidation(rl, question, validator, errorMessage) { return new Promise(async (resolve) => { let answer; do { answer = await askQuestion(rl, question); if (validator(answer)) { resolve(answer.trim()); return; } else { console.log(errorMessage || 'Invalid input. Please try again.'); } } while (true); }); } /** * Ask for confirmation (Y/n format) */ async function askConfirmation(rl, question, defaultYes = true, options = {}) { const { requireExplicit = false } = options; const suffix = defaultYes ? " (Y/n)" : " (y/N)"; while (true) { const answer = await askQuestion(rl, question + suffix + ": "); const lower = answer.toLowerCase(); // If user presses Enter → return defaultYes (true by default) if (lower === "") { if (!requireExplicit) { return defaultYes; } console.log(chalk.yellow('Please enter "y" or "n" to confirm.')); continue; } // Convert input to boolean if (lower === "y" || lower === "yes") { return true; } if (lower === "n" || lower === "no") { return false; } if (!requireExplicit) { // If input is something else, fallback to defaultYes return defaultYes; } console.log(chalk.yellow('Please enter "y" or "n" to confirm.')); } } /** * Select from a list of options */ async function selectFromList(rl, items, displayProperty = null, allowCancel = true) { if (!items || items.length === 0) { console.log('No items to select from.'); return null; } console.log('\nSelect an option:'); items.forEach((item, index) => { const displayText = displayProperty ? item[displayProperty] : item; console.log(`${index + 1}. ${displayText}`); }); if (allowCancel) { console.log('0. Cancel'); } const validator = (input) => { const num = parseInt(input); return !isNaN(num) && num >= (allowCancel ? 0 : 1) && num <= items.length; }; const answer = await askQuestionWithValidation( rl, '\nEnter your choice: ', validator, `Please enter a number between ${allowCancel ? '0' : '1'} and ${items.length}.` ); const choice = parseInt(answer); if (choice === 0 && allowCancel) { return null; } return items[choice - 1]; } function getSubfoldersRecursive(startDir = process.cwd()) { const result = []; function walk(dir) { const items = fs.readdirSync(dir); for (const item of items) { const fullPath = path.join(dir, item); try { const stat = fs.statSync(fullPath); if (stat.isDirectory()) { const baseName = path.basename(fullPath); if (['node_modules', '.git', 'dist', 'build'].includes(baseName)) continue; result.push(fullPath); walk(fullPath); // recurse into subdirectory } } catch (err) { // Optionally log: console.warn(`Skipped unreadable folder: ${fullPath}`); } } } walk(startDir); return result; } /** * Get files matching a wildcard pattern */ function getFilesMatchingWildcard(pattern, currentDir = process.cwd()) { try { // Gather all subfolders const allFolders = [currentDir, ...getSubfoldersRecursive(currentDir)]; let allFiles = []; for (const folder of allFolders) { const files = fs.readdirSync(folder).map(f => path.join(folder, f)); for (const filePath of files) { try { if (fs.statSync(filePath).isFile()) { allFiles.push(filePath); } } catch (e) {} } } // Convert wildcard pattern to regex let regexPattern; let matchFullPath = false; if (pattern === '*' || (!pattern.includes('.') && !pattern.includes('/'))) { regexPattern = new RegExp('.*', 'i'); matchFullPath = true; } else if (pattern.startsWith('*.')) { const extension = pattern.slice(2); regexPattern = new RegExp(`\\.${extension.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i'); } else if (pattern.includes('*')) { regexPattern = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'), 'i'); } else { regexPattern = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i'); } const matchedFiles = allFiles.filter(filePath => { if (matchFullPath) { const relPath = path.relative(currentDir, filePath); return regexPattern.test(relPath); } else { return regexPattern.test(path.basename(filePath)); } }); return matchedFiles; } catch (error) { console.error(`Error reading directory: ${error.message}`); return []; } } function pad(str, len) { return str + ' '.repeat(Math.max(0, len - str.length)); } /** * Enhanced file selection with wildcard support */ async function selectFilesImproved(rl, currentDir = process.cwd()) { const selectedFiles = []; console.log(chalk.cyan.bold('\n' + '-'.repeat(50))); console.log(chalk.cyan.bold('File Selection')); console.log(chalk.cyan('-'.repeat(50))); console.log(chalk.yellow('Tips:')); console.log(' • Type filename to add individual files'); console.log(' • Use wildcards: *.html, *.js, *.pdf, etc.'); console.log(' • Type "browse" to see available files'); console.log(' • Type "remove" to remove files from selection'); console.log(' • Type ".." or "back" to return to previous menu'); console.log(' • Press Enter with no input to finish selection\n'); while (true) { if (selectedFiles.length > 0) { console.log(chalk.cyan('\n' + '-'.repeat(50))); console.log(chalk.cyan.bold(`Currently selected (${selectedFiles.length} files):`)); selectedFiles.forEach((file, index) => { const stats = fs.statSync(file); const size = (stats.size / 1024).toFixed(1) + ' KB'; console.log(pad(chalk.white((index + 1) + '.'), 5) + pad(path.basename(file), 35) + chalk.gray(size)); }); console.log(chalk.cyan('-'.repeat(50))); } const input = await askQuestion(rl, chalk.bold.cyan('\nAdd file (or press Enter to finish): ')); if (!input.trim()) break; if (input === '..' || input.toLowerCase() === 'back') { return selectedFiles; } if (input.toLowerCase() === 'browse') { console.log(chalk.cyan('\n' + '-'.repeat(50))); console.log(chalk.cyan.bold('Browsing available files:')); try { const listedFiles = []; function walk(dir) { const entries = fs.readdirSync(dir); for (const entry of entries) { const fullPath = path.join(dir, entry); const relPath = path.relative(currentDir, fullPath); if (['node_modules', '.git', 'dist', 'build'].includes(entry)) continue; try { const stat = fs.statSync(fullPath); if (stat.isDirectory()) { walk(fullPath); } else if (stat.isFile()) { listedFiles.push({ path: fullPath, rel: relPath, size: stat.size }); } } catch (e) { continue; } } } walk(currentDir); if (listedFiles.length === 0) { console.log(chalk.red(' No suitable files found.')); } else { listedFiles.forEach((file, index) => { const sizeKB = (file.size / 1024).toFixed(1); console.log(pad(chalk.white((index + 1) + '.'), 5) + pad(file.rel, 35) + chalk.gray(sizeKB + ' KB')); }); } } catch (error) { console.log(chalk.red(' Error reading directory: ' + error.message)); } continue; } if (input.toLowerCase() === 'remove') { if (selectedFiles.length === 0) { console.log(chalk.red('No files selected to remove.')); continue; } console.log(chalk.cyan('\nSelect file to remove:')); selectedFiles.forEach((file, index) => { console.log(pad(chalk.white((index + 1) + '.'), 5) + path.basename(file)); }); const removeChoice = await askQuestion(rl, chalk.bold.cyan('\nEnter number to remove (or press Enter to cancel): ')); if (removeChoice.trim()) { const removeIndex = parseInt(removeChoice) - 1; if (removeIndex >= 0 && removeIndex < selectedFiles.length) { const removedFile = selectedFiles.splice(removeIndex, 1)[0]; console.log(chalk.green(`Removed: ${path.basename(removedFile)}`)); } else { console.log(chalk.red('Invalid selection.')); } } continue; } let filePath = input; let zipRequested = false; if (filePath.endsWith(' -zip')) { filePath = filePath.slice(0, -5).trim(); zipRequested = true; } if (!path.isAbsolute(filePath)) { filePath = path.join(currentDir, filePath); } try { if (!fs.existsSync(filePath)) { console.log(chalk.red('Error: File not found: ' + input)); continue; } const stats = fs.statSync(filePath); if (zipRequested) { const baseName = path.basename(filePath); const zipName = baseName.replace(/\.[^/.]+$/, '') + '.zip'; const zipPath = path.join(currentDir, zipName); const zip = new AdmZip(); process.stdout.write(chalk.yellow('Zipping, please wait... ')); if (stats.isDirectory()) { zip.addLocalFolder(filePath); } else if (stats.isFile()) { zip.addLocalFile(filePath); } else { console.log(chalk.red('Not a file or folder.')); continue; } zip.writeZip(zipPath); console.log(chalk.green('Done.')); console.log(chalk.green(`Created ZIP: ${zipName}`)); if (selectedFiles.includes(zipPath)) { console.log(chalk.yellow(`File already selected: ${zipName}`)); continue; } selectedFiles.push(zipPath); const size = (fs.statSync(zipPath).size / 1024).toFixed(1) + ' KB'; console.log(chalk.green(`Added: ${zipName} (${size})`)); continue; } if (stats.isDirectory()) { const baseName = path.basename(filePath); if (['node_modules', '.git', 'dist', 'build'].includes(baseName)) continue; const collectedFiles = []; function walk(dir) { const entries = fs.readdirSync(dir); for (const entry of entries) { const fullPath = path.join(dir, entry); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { const baseName = path.basename(fullPath); if (['node_modules', '.git', 'dist', 'build'].includes(baseName)) continue; walk(fullPath); } else if (stat.isFile()) { collectedFiles.push(fullPath); } } } walk(filePath); if (collectedFiles.length === 0) { console.log(chalk.yellow(`Folder is empty: ${input}`)); continue; } console.log(chalk.cyan(`\nFound ${collectedFiles.length} file(s) in folder "${path.relative(currentDir, filePath)}":`)); let totalSize = 0; collectedFiles.forEach((f, i) => { const stat = fs.statSync(f); totalSize += stat.size; const relativePath = path.relative(currentDir, f); console.log(pad(chalk.white((i + 1) + '.'), 5) + pad(relativePath, 35) + chalk.gray((stat.size / 1024).toFixed(1) + ' KB')); }); console.log(chalk.cyan('-'.repeat(50))); console.log(chalk.cyan(`Total size: ${(totalSize / 1024).toFixed(1)} KB`)); const confirmFolder = await askConfirmation(rl, chalk.bold.cyan(`Add all ${collectedFiles.length} files from this folder?`), true); if (confirmFolder) { const newFiles = collectedFiles.filter(f => !selectedFiles.includes(f)); selectedFiles.push(...newFiles); console.log(chalk.green(`Added ${newFiles.length} new files (${collectedFiles.length - newFiles.length} already selected)`)); } continue; } if (selectedFiles.includes(filePath)) { console.log(chalk.yellow(`File already selected: ${path.basename(filePath)}`)); continue; } selectedFiles.push(filePath); const size = (stats.size / 1024).toFixed(1) + ' KB'; console.log(chalk.green(`Added: ${path.basename(filePath)} (${size})`)); } catch (error) { console.log(chalk.red('Error accessing file: ' + error.message)); } } return selectedFiles; } /** * Interactive file selector with tree view and keyboard navigation */ async function selectFilesKeyboard(rl, currentDir = process.cwd()) { const selectedFiles = []; let expandedFolders = new Set(); let fileTree = []; let fileList = []; let currentPath = currentDir; let currentIndex = 0; let isNavigating = true; let viewStartIndex = 0; const maxVisibleItems = 15; // Setup raw mode for keyboard input process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); // Helper function to build file tree function buildFileTree(basePath = currentDir, level = 0, parentPath = '') { const tree = []; try { const entries = fs.readdirSync(basePath).sort(); // Add directories first entries.forEach(entry => { const fullPath = path.join(basePath, entry); const relativePath = parentPath ? `${parentPath}/${entry}` : entry; try { const stats = fs.statSync(fullPath); if (stats.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.vscode', '.next'].includes(entry)) { const isExpanded = expandedFolders.has(fullPath); tree.push({ name: entry, path: fullPath, relativePath, type: 'directory', level, isExpanded, size: 0 }); // If expanded, add children if (isExpanded) { const children = buildFileTree(fullPath, level + 1, relativePath); tree.push(...children); } } } catch (e) {} }); // Add files entries.forEach(entry => { const fullPath = path.join(basePath, entry); const relativePath = parentPath ? `${parentPath}/${entry}` : entry; try { const stats = fs.statSync(fullPath); if (stats.isFile()) { tree.push({ name: entry, path: fullPath, relativePath, type: 'file', level, size: stats.size }); } } catch (e) {} }); } catch (error) { console.log(chalk.red('Error reading directory: ' + error.message)); } return tree; } // Helper function to get file icon based on extension function getFileIcon(filename) { const ext = path.extname(filename).toLowerCase(); const icons = { '.pdf': '📄', '.doc': '📄', '.docx': '📄', '.txt': '📄', '.js': '📜', '.ts': '📜', '.py': '📜', '.java': '📜', '.cpp': '📜', '.c': '📜', '.html': '🌐', '.css': '🎨', '.scss': '🎨', '.less': '🎨', '.json': '⚙️', '.xml': '⚙️', '.yml': '⚙️', '.yaml': '⚙️', '.zip': '📦', '.rar': '📦', '.7z': '📦', '.tar': '📦', '.jpg': '🖼️', '.jpeg': '🖼️', '.png': '🖼️', '.gif': '🖼️', '.svg': '🖼️', '.mp4': '🎬', '.avi': '🎬', '.mov': '🎬', '.mkv': '🎬', '.mp3': '🎵', '.wav': '🎵', '.flac': '🎵' }; return icons[ext] || '📋'; } // Helper function to build breadcrumb path function buildBreadcrumb() { const relativePath = path.relative(currentDir, currentPath); if (!relativePath || relativePath === '.') { return '' } const parts = relativePath.split(path.sep); const breadcrumb = parts.map((part, index) => { if (index === parts.length - 1) { return chalk.white.bold(part); } return chalk.gray(part); }).join(chalk.gray(' › ')); return chalk.yellow('📂 ') + breadcrumb; } // Helper to clear the terminal without dropping scrollback history function safeClearScreen() { if (!process.stdout.isTTY) return; process.stdout.write('\x1b[H\x1b[J'); // Move cursor to top and clear below, not full console.clear() } // Helper function to display the file browser function displayBrowser() { safeClearScreen(); // Keyboard controls - compact format at top const controls = [ ]; // Breadcrumb path console.log(buildBreadcrumb()); console.log(chalk.gray('💡 ↑↓←→:Navigate', 'Space:Select', 'Enter:Open/Finish', 'Backspace:Up', 'a:All', 'c:Clear', 'Esc:Exit')); // Selected files count if (selectedFiles.length > 0) { const totalSize = selectedFiles.reduce((sum, file) => { try { return sum + fs.statSync(file).size; } catch { return sum; } }, 0); console.log(chalk.green(`✅ Selected: ${selectedFiles.length} files (${(totalSize / 1024).toFixed(1)} KB) - Press Enter to finish`)); } console.log(); if (fileList.length === 0) { console.log(chalk.yellow('📭 No files found in this directory.')); return; } // Display files with tree-like structure displayFileTree(); } // Helper function to display files in a horizontal grid layout function displayFileTree() { const terminalWidth = process.stdout.columns || 80; const maxDisplayItems = 50; // Show more items in grid view const startIdx = Math.max(0, currentIndex - Math.floor(maxDisplayItems / 2)); const endIdx = Math.min(fileList.length, startIdx + maxDisplayItems); const visibleItems = fileList.slice(startIdx, endIdx); // Show scroll indicators if (startIdx > 0) { console.log(chalk.gray(` ⋮ (${startIdx} items above)`)); } // Calculate item width and columns const maxItemWidth = Math.max(...visibleItems.map(item => { const name = path.basename(item.path); return name.length + 4; // 2 for icon + space, 2 for padding })); const itemWidth = Math.min(Math.max(maxItemWidth, 15), 25); // Min 15, max 25 chars const columnsPerRow = Math.floor((terminalWidth - 4) / itemWidth); // Leave 4 chars margin const actualColumns = Math.max(1, columnsPerRow); // Group items into rows let currentRow = ''; let itemsInCurrentRow = 0; visibleItems.forEach((item, index) => { const actualIndex = startIdx + index; const isSelected = selectedFiles.includes(item.path); const isCurrent = actualIndex === currentIndex; // Get icon and name let icon = ''; if (item.type === 'parent' || item.type === 'directory') { icon = '📁'; } else { icon = getFileIcon(path.basename(item.path)); } const name = item.name || path.basename(item.path); const truncatedName = name.length > itemWidth - 4 ? name.slice(0, itemWidth - 7) + '...' : name; // Build item display string let itemDisplay = `${icon} ${truncatedName}`; // Apply styling based on state if (isCurrent) { if (isSelected) { itemDisplay = chalk.black.bgGreen(` ${itemDisplay}`.padEnd(itemWidth - 1)); } else if (item.type === 'parent') { itemDisplay = chalk.white.bgBlue(` ${itemDisplay}`.padEnd(itemWidth - 1)); } else if (item.type === 'directory') { itemDisplay = chalk.black.bgCyan(` ${itemDisplay}`.padEnd(itemWidth - 1)); } else { itemDisplay = chalk.black.bgWhite(` ${itemDisplay}`.padEnd(itemWidth - 1)); } } else { if (isSelected) { itemDisplay = chalk.green(`✓${itemDisplay}`.padEnd(itemWidth)); } else if (item.type === 'parent') { itemDisplay = chalk.blue(` ${itemDisplay}`.padEnd(itemWidth)); } else if (item.type === 'directory') { itemDisplay = chalk.cyan(` ${itemDisplay}`.padEnd(itemWidth)); } else { itemDisplay = chalk.white(` ${itemDisplay}`.padEnd(itemWidth)); } } // Add to current row currentRow += itemDisplay; itemsInCurrentRow++; // Check if we need to start a new row if (itemsInCurrentRow >= actualColumns || index === visibleItems.length - 1) { console.log(currentRow); currentRow = ''; itemsInCurrentRow = 0; } }); // Show scroll indicators if (endIdx < fileList.length) { console.log(chalk.gray(` ⋮ (${fileList.length - endIdx} items below)`)); } console.log(); // Show current position and navigation info if (fileList.length > maxDisplayItems) { console.log(chalk.gray(`Showing ${startIdx + 1}-${endIdx} of ${fileList.length} items | Current: ${currentIndex + 1}`)); } else { console.log(chalk.gray(`${fileList.length} items | Current: ${currentIndex + 1}`)); } // Show grid info console.log(chalk.gray(`Grid: ${actualColumns} columns × ${itemWidth} chars | Terminal width: ${terminalWidth}`)); } // Helper function to refresh file list for current directory function refreshFileList() { fileList = []; try { // Add parent directory option if not at root if (currentPath !== currentDir) { fileList.push({ type: 'parent', path: path.dirname(currentPath), name: '..' }); } // Read current directory const entries = fs.readdirSync(currentPath).sort(); // Add directories first entries.forEach(entry => { const fullPath = path.join(currentPath, entry); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { fileList.push({ type: 'directory', path: fullPath, name: entry }); } }); // Add files entries.forEach(entry => { const fullPath = path.join(currentPath, entry); const stat = fs.statSync(fullPath); if (stat.isFile()) { fileList.push({ type: 'file', path: fullPath, name: entry, size: stat.size }); } }); // Ensure currentIndex is within bounds if (currentIndex >= fileList.length) { currentIndex = Math.max(0, fileList.length - 1); } } catch (error) { console.error('Error reading directory:', error.message); fileList = []; } } // Main keyboard event handler function handleKeyInput(key) { // Calculate grid dimensions for navigation const terminalWidth = process.stdout.columns || 80; const maxItemWidth = Math.max(...fileList.map(item => { const name = path.basename(item.path); return name.length + 4; })); const itemWidth = Math.min(Math.max(maxItemWidth, 15), 25); const columnsPerRow = Math.max(1, Math.floor((terminalWidth - 4) / itemWidth)); switch (key) { case KEYS.UP: // Move up by one row (subtract columns) const newUpIndex = currentIndex - columnsPerRow; if (newUpIndex >= 0) { currentIndex = newUpIndex; displayBrowser(); } break; case KEYS.DOWN: // Move down by one row (add columns) const newDownIndex = currentIndex + columnsPerRow; if (newDownIndex < fileList.length) { currentIndex = newDownIndex; displayBrowser(); } break; case KEYS.LEFT: // Move left by one column if (currentIndex > 0) { currentIndex--; displayBrowser(); } break; case KEYS.RIGHT: // Move right by one column if (currentIndex < fileList.length - 1) { currentIndex++; displayBrowser(); } break; case KEYS.SPACE: if (fileList.length > 0) { const item = fileList[currentIndex]; if (item.type === 'file') { const index = selectedFiles.indexOf(item.path); if (index === -1) { selectedFiles.push(item.path); } else { selectedFiles.splice(index, 1); } displayBrowser(); } } break; case KEYS.ENTER: if (fileList.length > 0) { const item = fileList[currentIndex]; if (item.type === 'parent' || item.type === 'directory') { currentPath = item.path; currentIndex = 0; refreshFileList(); displayBrowser(); } else { // When Enter is pressed on a file, finish selection if files are selected if (selectedFiles.length > 0) { isNavigating = false; } } } else { // Finish selection when directory is empty and Enter is pressed (if files selected) if (selectedFiles.length > 0) { isNavigating = false; } } break; case KEYS.BACKSPACE: if (currentPath !== currentDir) { currentPath = path.dirname(currentPath); currentIndex = 0; refreshFileList(); displayBrowser(); } break; case 'a': // Select all files in current directory let addedCount = 0; fileList.forEach(item => { if (item.type === 'file' && !selectedFiles.includes(item.path)) { selectedFiles.push(item.path); addedCount++; } }); if (addedCount > 0) { displayBrowser(); } break; case 'c': // Clear all selections selectedFiles.length = 0; displayBrowser(); break; case KEYS.ESCAPE: case '\u001b': // ESC key isNavigating = false; break; default: // Ignore other keys break; } } // Initialize and start navigation return new Promise((resolve) => { refreshFileList(); displayBrowser(); process.stdin.on('data', (key) => { if (!isNavigating) { return; } const keyStr = key.toString(); handleKeyInput(keyStr); if (!isNavigating) { // Cleanup process.stdin.removeAllListeners('data'); process.stdin.setRawMode(false); process.stdin.pause(); // Display completion summary if (selectedFiles.length > 0) { console.log(chalk.green.bold('✅ File Selection Complete!')); console.log(chalk.cyan('-'.repeat(50))); const totalSize = selectedFiles.reduce((sum, file) => { try { return sum + fs.statSync(file).size; } catch { return sum; } }, 0); console.log(chalk.white(`Selected ${selectedFiles.length} files (${(totalSize / 1024).toFixed(1)} KB total):`)); selectedFiles.forEach((file, index) => { try { const stats = fs.statSync(file); const size = (stats.size / 1024).toFixed(1) + ' KB'; console.log(pad(chalk.green(`${index + 1}.`), 5) + pad(path.basename(file), 35) + chalk.gray(size)); } catch (e) { console.log(pad(chalk.red(`${index + 1}.`), 5) + chalk.red(path.basename(file) + ' (Error reading file)')); } }); console.log(chalk.cyan('-'.repeat(50))); } else { console.log(chalk.yellow('No files selected.')); } resolve(selectedFiles); } }); }); } export { createReadlineInterface, askQuestion, askQuestionWithValidation, askConfirmation, selectFromList, selectFilesImproved, selectFilesKeyboard, getFilesMatchingWildcard, getSubfoldersRecursive, pad };