diff --git a/src/commands.js b/src/commands.js new file mode 100644 index 0000000..72f1082 --- /dev/null +++ b/src/commands.js @@ -0,0 +1,13 @@ +import { countHandler } from './commands/count.js' +import { csvToJsonHandler } from './commands/csvToJson.js' +import { jsonToCsvHandler } from './commands/jsonToCsv.js' +import { upHandler, cdHandler, lsHandler } from './navigation.js' + +export const COMMAND_HANDLERS_MAP = { + up: upHandler, + cd: cdHandler, + ls: lsHandler, + 'csv-to-json': csvToJsonHandler, + 'json-to-csv': jsonToCsvHandler, + count: countHandler, +} diff --git a/src/commands/count.js b/src/commands/count.js index e69de29..bc49c3d 100644 --- a/src/commands/count.js +++ b/src/commands/count.js @@ -0,0 +1,56 @@ + +import { extname } from 'node:path'; +import { createReadStream } from 'node:fs'; +import { argParser } from '../utils/argParser.js'; +import { pathResolver } from '../utils/pathResolver.js'; +import { InvalidInputError } from '../utils/errors.js'; + +const LINE_SEPARATOR = /\r?\n/; +const WORD_SEPARATOR = /\s+/; + +const getWordsFromString = (str) => { + return str.split(WORD_SEPARATOR).filter(w => w.length > 0); +} + +export const countHandler = async (args) => { + const inputArg = argParser(args, 'input', true); + const inputPath = pathResolver(inputArg); + + const inputFileExt = extname(inputPath).toLowerCase(); + + if (inputFileExt !== '.txt') { + throw new InvalidInputError('Invalid file extention'); + } + + const readableStream = createReadStream(inputPath, { encoding: 'utf-8' }); + + let buffer = ''; + let linesCount = 0; + let wordsCount = 0; + let charsCount = 0; + + for await (const chunk of readableStream) { + buffer += chunk; + charsCount += chunk.length; + + const lines = buffer.split(LINE_SEPARATOR); + buffer = lines.pop(); + + linesCount += lines.length; + + const words = getWordsFromString(lines.join(' ')); + wordsCount += words.length; + } + + if (buffer.length) { + charsCount += buffer.length; + linesCount++; + + const words = getWordsFromString(buffer); + wordsCount += words.length; + } + + console.log(`Lines: ${linesCount}`); + console.log(`Words: ${wordsCount}`); + console.log(`Characters: ${charsCount}`); +} \ No newline at end of file diff --git a/src/commands/csvToJson.js b/src/commands/csvToJson.js index e69de29..af3c1bb 100644 --- a/src/commands/csvToJson.js +++ b/src/commands/csvToJson.js @@ -0,0 +1,79 @@ +import { Transform } from 'node:stream'; +import { fileConfersionHandler } from '../utils/fileConversionHandler.js'; + +const EXTRA_HEADER_NAME = 'Extra'; +const INDENT = 2; +const LINE_SEPARATOR = /\r?\n/; + +const getCsvToJsonTransformSteam = () => { + let transformBuffer = ''; + let headers = []; + let isFirstDataRow = true; + + const getDataPrefix = () => { + let prefix = `,\n${' '.repeat(INDENT)}`; + if (isFirstDataRow) { + prefix = ' '.repeat(INDENT); + isFirstDataRow = false; + } + return prefix; + } + + const transformDataToJsonString = (data) => { + const obj = {}; + let unknownHeaderCounter = 1; + data.forEach((d, i) => { + let header; + if (headers[i]) { + header = headers[i]; + } else { + header = `${EXTRA_HEADER_NAME}${unknownHeaderCounter}`; + unknownHeaderCounter++; + } + obj[header] = d; + }); + return JSON.stringify(obj); + } + + return new Transform({ + transform(chunk, _, callback) { + transformBuffer += chunk; + + const lines = transformBuffer.split(LINE_SEPARATOR); + transformBuffer = lines.pop(); + + lines.forEach((line) => { + if (!line.trim()) { + return; + } + + const parsedLineData = line.split(','); + + if (!headers.length) { + headers = parsedLineData.map(h => h.trim()); + this.push('[\n'); + } else { + this.push(`${getDataPrefix()}${transformDataToJsonString(parsedLineData)}`); + } + }); + + callback(); + }, + flush(callback) { + if (!transformBuffer.trim()) { + return '\n]'; + } + + const parsedLineData = transformBuffer.split(','); + this.push(`${getDataPrefix()}${transformDataToJsonString(parsedLineData)}\n]\n`); + + transformBuffer = ''; + callback(); + } + }); +} + +export const csvToJsonHandler = async (args) => { + const transformStream = getCsvToJsonTransformSteam(); + return fileConfersionHandler(args, '.csv', transformStream); +} diff --git a/src/commands/jsonToCsv.js b/src/commands/jsonToCsv.js index e69de29..34ea0ee 100644 --- a/src/commands/jsonToCsv.js +++ b/src/commands/jsonToCsv.js @@ -0,0 +1,38 @@ +import { Transform } from 'node:stream'; +import { fileConfersionHandler } from '../utils/fileConversionHandler.js'; + +const getJsonToCsvTransformSteam = () => { + let jsonStringBuffer = ''; + + return new Transform({ + transform(chunk, _, callback) { + jsonStringBuffer += chunk; + callback(); + }, + flush(callback) { + const jsonData = JSON.parse(jsonStringBuffer); + jsonStringBuffer = ''; + if (!Array.isArray(jsonData)) { + throw new Error('Invalid json type'); + } + + const headerSet = new Set(); + jsonData.map((row) => Object.keys(row).forEach(h => headerSet.add(h))); + const headers = Array.from(headerSet); + + this.push(`${headers.join(',')}\n`) + + jsonData.forEach(row => { + const valuesInHeadersOrder = headers.map((header) => row[header] || ''); + this.push(`${valuesInHeadersOrder.join(',')}\n`); + }) + + callback(); + } + }); +} + +export const jsonToCsvHandler = async (args) => { + const transformStream = getJsonToCsvTransformSteam(); + return fileConfersionHandler(args, '.json', transformStream); +} diff --git a/src/cwdState.js b/src/cwdState.js new file mode 100644 index 0000000..e118cd3 --- /dev/null +++ b/src/cwdState.js @@ -0,0 +1,17 @@ +import os from 'node:os'; +import { InvalidInputError } from './utils/errors.js'; + +export const initialCwd = os.homedir(); +let cwd = initialCwd; + +export const getCwd = () => { + return cwd; +} + +// dir - absolute path +export const chCwd = (dir) => { + if (!dir.startsWith(initialCwd)) { + throw new InvalidInputError('Cannot go higher than initial directory'); + } + cwd = dir; +} \ No newline at end of file diff --git a/src/main.js b/src/main.js index e69de29..acff6a3 100644 --- a/src/main.js +++ b/src/main.js @@ -0,0 +1,7 @@ +import { initRepl } from './repl.js'; + +const init = () => { + initRepl(); +} + +init(); diff --git a/src/navigation.js b/src/navigation.js index e69de29..bbac816 100644 --- a/src/navigation.js +++ b/src/navigation.js @@ -0,0 +1,56 @@ +import fs from 'node:fs/promises'; +import { getCwd, chCwd, initialCwd } from './cwdState.js'; +import { InvalidInputError } from './utils/errors.js'; +import { pathResolver } from './utils/pathResolver.js'; + + +export const upHandler = () => { + if (getCwd() !== initialCwd) { + const newDirectory = pathResolver(`${getCwd}/..`); + chDir(newDirectory); + } +} + +export const cdHandler = async (args) => { + const pathToDirectory = args[0]; + if (!pathToDirectory) { + throw new InvalidInputError('No path/to/directory'); + } + const newDirPath = pathResolver(pathToDirectory); + + const stats = await fs.stat(newDirPath); + + if (stats.isDirectory()) { + chCwd(newDirPath) + } else { + throw new InvalidInputError('Path is not a directory'); + } +} + +export const lsHandler = async () => { + const dirEntities = await fs.readdir(getCwd(), { withFileTypes: true }); + + const folders = []; + const files = []; + + let maxEntitName = 0; + + dirEntities.forEach((dirEnt) => { + maxEntitName = Math.max(maxEntitName, dirEnt.name.length); + + if (dirEnt.isDirectory()) { + folders.push(dirEnt.name); + } else if (dirEnt.isFile()) { + files.push(dirEnt.name); + } + }); + + const formatEntityName = (entitiName) => `${entitiName}${' '.repeat(maxEntitName - entitiName.length)}` + + folders.sort((a, b) => a.localeCompare(b)).forEach((ent) => { + console.log(`${formatEntityName(ent)} [folder]`) + }); + files.sort((a, b) => a.localeCompare(b)).forEach((ent) => { + console.log(`${formatEntityName(ent)} [file]`) + }); +} diff --git a/src/repl.js b/src/repl.js index e69de29..57492f6 100644 --- a/src/repl.js +++ b/src/repl.js @@ -0,0 +1,81 @@ + + +import readline from 'node:readline'; +import process from 'node:process'; +import { getCwd } from './cwdState.js'; +import { InvalidInputError, INVALID_INPUT_ERROR_CODE } from './utils/errors.js'; +import { COMMAND_HANDLERS_MAP } from './commands.js'; + +const WELCOME_TEXT = 'Welcome to Data Processing CLI!'; +const EXIT_TEXT = 'Thank you for using Data Processing CLI!'; +const INVALID_COMMAND_TEXT = 'Invalid input'; +const OPERATION_FAILED_TEXT = 'Operation failed'; +const CURRENT_DIRECTORY_PREFIX = 'You are currently in'; + +const ANSI_COLORS = { + red: '\x1b[31m', + green: '\x1b[32m', +}; +const ANSI_COLOR_RESET = '\x1b[0m'; + +const onSuccess = () => { + console.log(`${ANSI_COLORS.green}${CURRENT_DIRECTORY_PREFIX} ${getCwd()}${ANSI_COLOR_RESET}`); +} + +const onError = (err) => { + // For testing only, TODO: delete before review next line + console.log(err); + if (err.code === INVALID_INPUT_ERROR_CODE) { + console.log(`${ANSI_COLORS.red}${INVALID_COMMAND_TEXT}${ANSI_COLOR_RESET}`); + } else { + console.log(`${ANSI_COLORS.red}${OPERATION_FAILED_TEXT}${ANSI_COLOR_RESET}`); + } +} + +const handleCommand = async(command, commandArgs) => { + if (command in COMMAND_HANDLERS_MAP) { + try { + await COMMAND_HANDLERS_MAP[command](commandArgs); + onSuccess() + } catch (err) { + onError(err) + } + + } else { + onError(new InvalidInputError('Invalid command')); + } +} + +export const initRepl = () => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: '> ' + }); + + rl.on('line', async (line) => { + const lineTrimmed = line.trim(); + const [command, ...commandArgs] = lineTrimmed.split(' '); + + if (command === '.exit' && commandArgs.length === 0) { + rl.close() + } else { + await handleCommand(command, commandArgs); + rl.prompt(); + } + }); + + rl.on('SIGINT', () => { + rl.close(); + }); + + rl.on('close', () => { + console.log(EXIT_TEXT) + process.exit(0); + }); + + console.log(WELCOME_TEXT); + // Initial display of directory + onSuccess(); + rl.prompt(); +} diff --git a/src/utils/argParser.js b/src/utils/argParser.js index e69de29..8051e95 100644 --- a/src/utils/argParser.js +++ b/src/utils/argParser.js @@ -0,0 +1,32 @@ +import { InvalidInputError } from './errors.js'; + +export const argParser = (args, argName, required = false) => { + const argIndex = args.findIndex(arg => arg.startsWith(`--${argName}`)); + + if (argIndex === -1) { + if (required) { + throw new InvalidInputError('Argument is required'); + } else { + return; + } + } + + const arg = args[argIndex]; + let argValue; + + if (arg.includes('=')) { + argValue = arg.split('=')[1]; + } else { + const nextArgvValue = args[argIndex + 1]; + + if (nextArgvValue && !nextArgvValue.startsWith('--')) { + argValue = nextArgvValue; + } + } + + if (!argValue && required) { + throw new InvalidInputError('Argument is required'); + } + + return argValue; +} \ No newline at end of file diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 0000000..21e3055 --- /dev/null +++ b/src/utils/errors.js @@ -0,0 +1,11 @@ +export const INVALID_INPUT_ERROR_CODE = 'INVALID_INPUT'; + +export class InvalidInputError extends Error { + constructor(message) { + super(message); + this.name = 'InvalidInputError'; + this.code = INVALID_INPUT_ERROR_CODE; + + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/src/utils/fileConversionHandler.js b/src/utils/fileConversionHandler.js new file mode 100644 index 0000000..5320df4 --- /dev/null +++ b/src/utils/fileConversionHandler.js @@ -0,0 +1,25 @@ +import { pipeline } from 'node:stream/promises'; +import { extname } from 'node:path'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { argParser } from '../utils/argParser.js'; +import { pathResolver } from '../utils/pathResolver.js'; +import { InvalidInputError } from '../utils/errors.js'; + +export const fileConfersionHandler = async (args, fileExtention, transformStream) => { + const inputArg = argParser(args, 'input', true); + const outputArg = argParser(args, 'output', true); + + const inputPath = pathResolver(inputArg); + const outputPath = pathResolver(outputArg); + + const inputFileExt = extname(inputPath).toLowerCase(); + + if (inputFileExt !== fileExtention) { + throw new InvalidInputError('Invalid file extention'); + } + + const inputStream = createReadStream(inputPath, { encoding: 'utf-8' }); + const outputStream = createWriteStream(outputPath, { encoding: 'utf-8' }); + + await pipeline(inputStream, transformStream, outputStream); +}; \ No newline at end of file diff --git a/src/utils/pathResolver.js b/src/utils/pathResolver.js index e69de29..2b2cb9c 100644 --- a/src/utils/pathResolver.js +++ b/src/utils/pathResolver.js @@ -0,0 +1,6 @@ +import path from 'node:path'; +import { getCwd } from '../cwdState.js'; + +export const pathResolver = (pathArg) => { + return path.resolve(getCwd(), pathArg); +} \ No newline at end of file