UNPKG

@typecad/jlcpcb-parts

Version:

Intelligent fuzzy search for JLCPCB electrical components with CLI interface

585 lines 26.7 kB
import chalk from 'chalk'; import { ProgressIndicator } from './ProgressIndicator.js'; import { ErrorHandler } from './ErrorHandler.js'; import * as readline from 'readline'; /** * Command-line interface implementation for the JLCPCB component search tool */ export class CommandLineInterface { searchEngine; programName; /** * Creates a new CommandLineInterface instance * @param searchEngine - The search engine to use for queries * @param programName - The name of the program (default: 'jlcpcb-search') */ constructor(searchEngine, programName = 'jlcpcb-search') { this.searchEngine = searchEngine; this.programName = programName; } /** * Main entry point for CLI execution * @param args - Command line arguments (process.argv) */ async run(args) { // Remove the first two arguments (node executable and script path) const cliArgs = args.slice(2); let parsedArgs; try { // Parse command-line arguments parsedArgs = this.parseArguments(cliArgs); // Handle help flag if (parsedArgs.help) { this.displayHelp(); return; } // Handle version flag if (parsedArgs.version) { this.displayVersion(); return; } // Check if we have a search query if (!parsedArgs.query) { // In JSON format, don't prompt - just return error immediately if (parsedArgs.format === 'json') { this.displayJsonError(new Error('No search query provided'), 'MISSING_QUERY'); process.exit(1); } // Prompt for user input if no query provided (non-JSON mode only) parsedArgs.query = await this.promptForSearchQuery(); // If user still didn't provide a query, exit gracefully if (!parsedArgs.query || parsedArgs.query.trim().length === 0) { console.log(chalk.yellow('No search query provided. Exiting.')); process.exit(0); } } // Validate search query if (!this.validateQuery(parsedArgs.query)) { if (parsedArgs.format === 'json') { this.displayJsonError(new Error(`Invalid search query: "${parsedArgs.query}"`), 'INVALID_QUERY'); } else { console.error(chalk.red(`Error: Invalid search query: "${parsedArgs.query}"`)); console.error(chalk.yellow('Search query must not be empty and should contain valid characters')); console.error(chalk.yellow('Try using alphanumeric characters, spaces, and common symbols.')); } process.exit(1); } // Create progress indicator only if not in JSON mode const progress = parsedArgs.format === 'json' ? null : new ProgressIndicator(); // Start progress indicator for search (only if not in JSON mode) if (progress) { progress.start(`Searching for components matching: "${parsedArgs.query}"`); } // Suppress console output in JSON mode let originalConsoleLog = null; if (parsedArgs.format === 'json') { originalConsoleLog = console.log; console.log = () => { }; // Suppress all console.log output } try { // Perform search const results = await this.searchEngine.search(parsedArgs.query); // Restore console.log if it was suppressed if (originalConsoleLog) { console.log = originalConsoleLog; } // Stop progress indicator (only if not in JSON mode) if (progress) { progress.stop(); } // If no results, provide suggestions (only if not in JSON mode) if (results.length === 0 && parsedArgs.format !== 'json') { console.log(chalk.yellow('No components found matching your search criteria.')); console.log(chalk.yellow('Suggestions:')); console.log(chalk.yellow('- Try using more general terms (e.g., "capacitor" instead of "ceramic capacitor")')); console.log(chalk.yellow('- Check your spelling and try alternative terms')); console.log(chalk.yellow('- Remove specific parameters that might be too restrictive')); console.log(chalk.yellow('- Try searching for a different package size or value')); return; } // Sort results if needed if (parsedArgs.sortBy !== 'score') { this.sortResults(results, parsedArgs.sortBy); } // Limit results if needed const limitedResults = parsedArgs.limit > 0 && parsedArgs.limit < results.length ? results.slice(0, parsedArgs.limit) : results; // Display results this.displayResults(limitedResults, parsedArgs.format); // Show additional information if results were limited (only if not in JSON mode) if (results.length > limitedResults.length && parsedArgs.format !== 'json') { console.log(chalk.blue(`Showing ${limitedResults.length} of ${results.length} matching components. Use --limit option to see more.`)); } // Display top match summary at the end (only if not in JSON mode) if (results.length > 0 && parsedArgs.format !== 'json') { const topMatch = results[0]; console.log(chalk.bold.blue(`Top match: #C${topMatch.lcsc} - ${topMatch.description} (Score: ${topMatch.score})`)); } } catch (searchError) { // Restore console.log if it was suppressed if (parsedArgs.format === 'json' && originalConsoleLog) { console.log = originalConsoleLog; } // Stop progress indicator (only if not in JSON mode) if (progress) { progress.stop(); } // Handle search errors in JSON format if needed if (parsedArgs.format === 'json') { this.displayJsonError(searchError, 'SEARCH_ERROR'); process.exit(1); } throw searchError; } } catch (error) { // Handle errors based on output format // Try to parse arguments to determine format, but handle parsing errors gracefully let isJsonFormat = false; try { const tempParsedArgs = this.parseArguments(cliArgs); isJsonFormat = tempParsedArgs.format === 'json'; } catch (parseError) { // If parsing fails, check if JSON format was explicitly requested in args isJsonFormat = cliArgs.includes('--format') && cliArgs.includes('json') || cliArgs.includes('-f') && cliArgs.includes('json'); } if (isJsonFormat) { // Output error in JSON format using dedicated method this.displayJsonError(error, 'SYSTEM_ERROR'); } else { // Use ErrorHandler to provide user-friendly error messages const errorMessage = ErrorHandler.handleError(error); console.error(chalk.red(errorMessage)); } // Exit with error code process.exit(1); } } /** * Parses command-line arguments into a structured object * @param args - Command-line arguments * @returns Parsed arguments object */ parseArguments(args) { const result = { query: '', help: false, version: false, format: 'detailed', sortBy: 'score', limit: 5 }; // First pass: check if JSON format is requested to suppress warnings let isJsonFormat = false; for (let i = 0; i < args.length; i++) { if ((args[i] === '--format' || args[i] === '-f') && args[i + 1] === 'json') { isJsonFormat = true; break; } } // Process arguments for (let i = 0; i < args.length; i++) { const arg = args[i]; // Handle flags if (arg === '--help' || arg === '-h') { result.help = true; continue; } if (arg === '--version' || arg === '-v') { result.version = true; continue; } // Handle format option if (arg === '--format' || arg === '-f') { const formatValue = args[++i]; if (formatValue === 'detailed' || formatValue === 'compact' || formatValue === 'table' || formatValue === 'json') { result.format = formatValue; } else { if (!isJsonFormat) { console.warn(chalk.yellow(`Warning: Invalid format '${formatValue}'. Using default 'detailed' format.`)); } } continue; } // Handle sort option if (arg === '--sort' || arg === '-s') { const sortValue = args[++i]; if (sortValue === 'score' || sortValue === 'lcsc' || sortValue === 'manufacturer' || sortValue === 'package') { result.sortBy = sortValue; } else { if (!isJsonFormat) { console.warn(chalk.yellow(`Warning: Invalid sort option '${sortValue}'. Using default 'score' sort.`)); } } continue; } // Handle limit option if (arg === '--limit' || arg === '-l') { const limitValue = parseInt(args[++i], 10); if (!isNaN(limitValue) && limitValue > 0) { result.limit = limitValue; } else { if (!isJsonFormat) { console.warn(chalk.yellow(`Warning: Invalid limit '${args[i]}'. Using default limit of 5.`)); } } continue; } // If not a flag, treat as part of the query if (!arg.startsWith('-')) { // If we already have a query, append with space if (result.query) { result.query += ' '; } result.query += arg; } } return result; } /** * Validates a search query * @param query - Search query to validate * @returns True if the query is valid, false otherwise */ validateQuery(query) { // Query must not be empty if (!query || query.trim().length === 0) { return false; } // Query must not contain invalid characters const invalidCharsRegex = /[^\w\s\d.,\-+%()[\]{}:;'"\/\\&@#$^*=<>?!]/g; if (invalidCharsRegex.test(query)) { return false; } return true; } /** * Prompts the user for a search query when none is provided * @returns Promise that resolves to the user's input */ async promptForSearchQuery() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { console.log(chalk.blue(`\n${this.programName} - KiCad Symbols Search Tool`)); console.log(chalk.yellow('No search query provided. Please enter a search term:')); console.log(chalk.gray('Examples: "capacitor", "LM358", "4xxx:14528", "op amp"')); console.log(chalk.gray('Press Ctrl+C to exit\n')); rl.question(chalk.cyan('Search query: '), (answer) => { rl.close(); resolve(answer.trim()); }); }); } /** * Sorts search results based on the specified criteria * @param results - Array of search results to sort * @param sortBy - Sorting criteria */ sortResults(results, sortBy) { switch (sortBy) { case 'lcsc': results.sort((a, b) => a.lcsc.localeCompare(b.lcsc)); break; case 'manufacturer': results.sort((a, b) => a.manufacturer.localeCompare(b.manufacturer)); break; case 'package': results.sort((a, b) => a.package.localeCompare(b.package)); break; // 'score' is the default and already sorted by the search engine default: break; } } /** * Gets color for score based on its value * @param score - Score value * @returns Chalk color function */ getScoreColor(score) { if (score >= 90) { return chalk.green; } else if (score >= 75) { return chalk.yellow; } else if (score >= 50) { return chalk.hex('#FFA500'); // Orange } else { return chalk.red; } } /** * Displays search results in a formatted manner * @param results - Array of search results to display * @param format - Format to use for display (detailed, compact, table, or json) */ displayResults(results, format = 'detailed') { // Handle JSON format separately if (format === 'json') { this.displayJsonResults(results); return; } if (results.length === 0) { console.log(chalk.yellow('No components found matching your search criteria.')); console.log(chalk.yellow('Try using more general terms or check your spelling.')); return; } console.log(chalk.green(`\nFound ${results.length} matching components:\n`)); // Display results based on format switch (format) { case 'detailed': this.displayDetailedResults(results); break; case 'compact': this.displayCompactResults(results); break; case 'table': this.displayTableResults(results); break; default: this.displayDetailedResults(results); } } /** * Displays results in detailed format * @param results - Array of search results to display */ displayDetailedResults(results) { results.forEach((result, index) => { const scoreColor = this.getScoreColor(result.score); console.log(chalk.bold(`${index + 1}. #C${result.lcsc} - ${result.description}`)); console.log(` ${chalk.cyan('Manufacturer:')} ${result.manufacturer}`); console.log(` ${chalk.cyan('Part Number:')} ${result.partNumber}`); console.log(` ${chalk.cyan('Package:')} ${result.package}`); console.log(` ${chalk.cyan('Match Score:')} ${scoreColor(result.score.toFixed(1))}`); console.log(` ${chalk.cyan('Match Details:')} ${result.matchSummary}`); console.log(); // Empty line between results }); } /** * Displays results in compact format * @param results - Array of search results to display */ displayCompactResults(results) { results.forEach((result, index) => { const scoreColor = this.getScoreColor(result.score); console.log(`${index + 1}. ${chalk.bold('#C' + result.lcsc)} - ` + `${result.description.substring(0, 40)}${result.description.length > 40 ? '...' : ''} - ` + `${result.package} - ` + `${scoreColor(result.score.toFixed(1))}`); }); console.log(); // Empty line at the end } /** * Displays results in table format * @param results - Array of search results to display */ displayTableResults(results) { // Calculate column widths (accounting for #C prefix) const lcscWidth = Math.max(8, ...results.map(r => ('#C' + r.lcsc).length)); const mfrWidth = Math.max(12, ...results.map(r => r.manufacturer.length > 15 ? 15 : r.manufacturer.length)); const partWidth = Math.max(12, ...results.map(r => r.partNumber.length > 15 ? 15 : r.partNumber.length)); const pkgWidth = Math.max(7, ...results.map(r => r.package.length)); const descWidth = Math.min(40, Math.max(11, ...results.map(r => r.description.length))); // Print table header console.log(chalk.bold(`${'#'.padEnd(3)} ` + `${'LCSC'.padEnd(lcscWidth)} ` + `${'Manufacturer'.padEnd(mfrWidth)} ` + `${'Part Number'.padEnd(partWidth)} ` + `${'Package'.padEnd(pkgWidth)} ` + `${'Score'.padEnd(6)} ` + `${'Description'.padEnd(descWidth)}`)); // Print separator console.log(`${''.padEnd(3, '-')} ` + `${''.padEnd(lcscWidth, '-')} ` + `${''.padEnd(mfrWidth, '-')} ` + `${''.padEnd(partWidth, '-')} ` + `${''.padEnd(pkgWidth, '-')} ` + `${''.padEnd(6, '-')} ` + `${''.padEnd(descWidth, '-')}`); // Print table rows results.forEach((result, index) => { const scoreColor = this.getScoreColor(result.score); // Truncate long text const manufacturer = result.manufacturer.length > mfrWidth ? result.manufacturer.substring(0, mfrWidth - 3) + '...' : result.manufacturer; const partNumber = result.partNumber.length > partWidth ? result.partNumber.substring(0, partWidth - 3) + '...' : result.partNumber; const description = result.description.length > descWidth ? result.description.substring(0, descWidth - 3) + '...' : result.description; console.log(`${(index + 1).toString().padEnd(3)} ` + `${chalk.cyan(('#C' + result.lcsc).padEnd(lcscWidth))} ` + `${manufacturer.padEnd(mfrWidth)} ` + `${partNumber.padEnd(partWidth)} ` + `${result.package.padEnd(pkgWidth)} ` + `${scoreColor(result.score.toFixed(1).padEnd(6))} ` + `${description}`); }); console.log(); // Empty line at the end } /** * Displays results in JSON format * @param results - Array of search results to display */ displayJsonResults(results) { // Handle empty results by outputting empty JSON array if (results.length === 0) { console.log('[]'); return; } // Convert SearchResult array to JsonSearchResult array for consistent serialization const jsonResults = results.map(result => ({ lcsc: result.lcsc, manufacturer: result.manufacturer, partNumber: result.partNumber, description: result.description, package: result.package, score: result.score, matchSummary: result.matchSummary })); // Output clean JSON with no additional formatting or colors console.log(JSON.stringify(jsonResults)); } /** * Formats and outputs errors in JSON format * @param error - The error to format * @param code - Optional error code for programmatic handling */ displayJsonError(error, code) { let errorMessage; let errorCode = code; // Get clean error message without ANSI color codes for JSON output if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } // If no code provided, try to determine it from the error if (!errorCode) { if (error instanceof Error) { if (error.message.includes('No search query provided')) { errorCode = 'MISSING_QUERY'; } else if (error.message.includes('Invalid search query')) { errorCode = 'INVALID_QUERY'; } else if (error.message.includes('timed out') || error.message.includes('timeout')) { errorCode = 'TIMEOUT_ERROR'; } else if (error.message.includes('network') || error.message.includes('ENOTFOUND') || error.message.includes('ETIMEDOUT')) { errorCode = 'NETWORK_ERROR'; } else if (error.message.includes('file') || error.message.includes('ENOENT') || error.message.includes('EACCES')) { errorCode = 'FILE_SYSTEM_ERROR'; } else if (error.message.includes('parse') || error.message.includes('parsing') || error.message.includes('Unexpected token')) { errorCode = 'PARSING_ERROR'; } else { errorCode = 'SYSTEM_ERROR'; } } else { errorCode = 'UNKNOWN_ERROR'; } } else { // If a code was provided, use it but still try to refine it based on error content // This allows for more specific error codes when the provided code is generic if (error instanceof Error) { if (errorCode === 'SEARCH_ERROR' || errorCode === 'SYSTEM_ERROR') { // Refine the error code based on the actual error message if (error.message.includes('timed out') || error.message.includes('timeout')) { errorCode = 'TIMEOUT_ERROR'; } else if (error.message.includes('network') || error.message.includes('ENOTFOUND') || error.message.includes('ETIMEDOUT')) { errorCode = 'NETWORK_ERROR'; } else if (error.message.includes('file') || error.message.includes('ENOENT') || error.message.includes('EACCES')) { errorCode = 'FILE_SYSTEM_ERROR'; } else if (error.message.includes('parse') || error.message.includes('parsing') || error.message.includes('Unexpected token')) { errorCode = 'PARSING_ERROR'; } // Keep the provided code if no more specific match is found } } else { // For non-Error objects, if a generic code was provided, use UNKNOWN_ERROR instead if (errorCode === 'SEARCH_ERROR' || errorCode === 'SYSTEM_ERROR') { errorCode = 'UNKNOWN_ERROR'; } } } // Create JSON error response const jsonError = { error: true, message: errorMessage, code: errorCode }; // Output JSON error to stdout (not stderr) for consistent JSON output console.log(JSON.stringify(jsonError)); } /** * Displays help text and usage information */ displayHelp() { console.log(chalk.bold(`\n${this.programName} - JLCPCB Component Search Tool\n`)); console.log('A command-line tool for searching JLCPCB components using fuzzy matching.\n'); console.log(chalk.bold('Usage:')); console.log(` ${this.programName} [options] <search query>\n`); console.log(chalk.bold('Options:')); console.log(' -h, --help Display this help message'); console.log(' -v, --version Display version information'); console.log(' -f, --format <format> Output format: detailed, compact, table, or json (default: detailed)'); console.log(' -s, --sort <field> Sort by: score, lcsc, manufacturer, or package (default: score)'); console.log(' -l, --limit <number> Limit number of results (default: 5)\n'); console.log(chalk.bold('Output Formats:')); console.log(' detailed Human-readable detailed output with colors and formatting'); console.log(' compact Compact single-line format for each component'); console.log(' table Tabular format with aligned columns'); console.log(' json Machine-readable JSON format (suppresses all formatting and progress indicators)\n'); console.log(chalk.bold('JSON Format:')); console.log(' The JSON format outputs clean, machine-readable JSON without any formatting, colors, or progress indicators.'); console.log(' This format is ideal for programmatic integration and automated scripts.'); console.log(' When no results are found, an empty array [] is returned.'); console.log(' Error responses include an "error" field set to true with a "message" and optional "code".\n'); console.log(chalk.bold('Examples:')); console.log(` ${this.programName} "10k resistor 0603"`); console.log(` ${this.programName} "100uF capacitor 16V" --format table`); console.log(` ${this.programName} "USB-C connector" --sort manufacturer --limit 10`); console.log(` ${this.programName} "LM358 op amp" --format compact`); console.log(` ${this.programName} "capacitor" --format json`); console.log(` ${this.programName} "resistor" --format json --limit 3 | jq '.[0].lcsc'`); console.log(); console.log(chalk.italic('This is part of the typeCAD project. Visit https://typecad.net to learn more about code-as-schematic.')); console.log(); } /** * Displays version information */ displayVersion() { // For simplicity in testing, just use a hardcoded version // In a real implementation, we would read from package.json console.log(`${this.programName} version 1.0.0`); } } //# sourceMappingURL=CommandLineInterface.js.map