diff --git a/mocks/mock-offers.tsv b/mocks/mock-offers.tsv new file mode 100644 index 0000000..674d5f8 --- /dev/null +++ b/mocks/mock-offers.tsv @@ -0,0 +1,2 @@ +Champerret Heliopolis The Champerret Heliopolis offers charm and tranquility in the heart of Paris' 17th Arrondissement near Espace Champerret and Porte Maillot conference center. 2024-09-11T20:27:34Z Paris previewParis.jpg paris1.jpg;paris2.jpg;paris3.jpg;paris4.jpg;paris5.jpg;paris6.jpg 1 4.5 hotel 1 2 1000 Breakfast;Air conditioning /users/1 13 48.85661;2.351499 +Düsseldorf 1995 Düsseldorf 1995 is located in Düsseldorf, just 2.4 miles from Central Station Düsseldorf and 2.8 miles from Südpark. The property is around 2.9 miles from Theater an der Kö, 3 miles from Königsallee, and 3.1 miles from German Opera on the Rhine. 2024-08-10T20:27:34Z Dusseldorf previewDusseldorf.jpg dusseldorf1.jpg;dusseldorf2.jpg;dusseldorf3.jpg;dusseldorf4.jpg;dusseldorf5.jpg;dusseldorf6.jpg 0 3.9 apartment 3 5 74000 Baby seat;Washer;Towels;Fridge /users/2 3 51.225402;6.776314 diff --git a/package-lock.json b/package-lock.json index d36d6cc..67affc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,15 @@ { "name": "six-cities", - "version": "7.0.0", + "version": "0.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "six-cities", - "version": "7.0.0", + "version": "0.0.1", + "dependencies": { + "chalk": "^5.3.0" + }, "devDependencies": { "@types/node": "20.12.7", "@typescript-eslint/eslint-plugin": "6.7.0", @@ -821,16 +824,11 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -1401,6 +1399,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -4324,14 +4338,9 @@ "dev": true }, "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==" }, "ci-info": { "version": "3.8.0", @@ -4586,6 +4595,18 @@ "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } } }, "eslint-config-htmlacademy": { diff --git a/package.json b/package.json index 02b8cab..ae77f66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "six-cities", - "version": "7.0.0", + "version": "0.0.1", "description": "Проект «Шесть городов» от HTML Academy", "keywords": [ "rest", @@ -14,7 +14,7 @@ "lint": "eslint src/ --ext .ts", "compile": "tsc -p tsconfig.json", "clean": "rimraf dist", - "ts": "tsc --noEmit && node --no-warnings=--no-warnings=ExperimentalWarning --loader ts-node/esm" + "ts": "tsc --noEmit && node --no-warnings=ExperimentalWarning --loader ts-node/esm" }, "devDependencies": { "@types/node": "20.12.7", @@ -30,5 +30,8 @@ "engines": { "node": "^20.0.0", "npm": ">=10" + }, + "dependencies": { + "chalk": "5.3.0" } } diff --git a/src/cli/cli-application.ts b/src/cli/cli-application.ts new file mode 100644 index 0000000..883eea2 --- /dev/null +++ b/src/cli/cli-application.ts @@ -0,0 +1,39 @@ +import { ICommand } from './commands/types/command.interface.js'; +import { parseCommand } from './commands/utils/parse-command.util.js'; + +export class CLIApplication { + private readonly commands: Record = {}; + + constructor( + private readonly defaultCommand: string = '--help', + ) {} + + public registerCommands(commandList: ICommand[]): void { + commandList.reduce((acc, command) => { + const commandName = command.getName(); + if (acc[commandName]) { + throw new Error(`Command ${commandName} is already registered`); + } + acc[commandName] = command; + return acc; + }, this.commands); + } + + public getDefaultCommand(): ICommand { + if (!this.commands[this.defaultCommand]) { + throw new Error(`The default command (${this.defaultCommand}) is not registered.`); + } + return this.commands[this.defaultCommand]; + } + + public getCommand(commandName: string): ICommand { + return this.commands[commandName] ?? this.getDefaultCommand(); + } + + public async processCommand(argv: string[]): Promise { + const parsedCommand = parseCommand(argv); + const [commandName] = Object.keys(parsedCommand); + const commandArguments = parsedCommand[commandName] ?? []; + await this.getCommand(commandName).execute(...commandArguments); + } +} diff --git a/src/cli/commands/help.command.ts b/src/cli/commands/help.command.ts new file mode 100644 index 0000000..81f85e6 --- /dev/null +++ b/src/cli/commands/help.command.ts @@ -0,0 +1,22 @@ +import { ICommand } from './types/command.interface.js'; +import chalk from 'chalk'; + +export class HelpCommand implements ICommand { + public getName(): string { + return '--help'; + } + + public async execute(..._parameters: string[]): Promise { + console.info(` + ${chalk.whiteBright('Программа для подготовки данных для REST API сервера.')} + + ${chalk.italic('Пример:')} ${chalk.whiteBright('cli.js')} ${chalk.cyanBright('--')} ${chalk.yellowBright('[--arguments]')} + + ${chalk.italic('Команды:')} + ${chalk.cyanBright('--version:')} ${chalk.italic.dim('# выводит номер версии приложения')} + ${chalk.cyanBright('--help:')} ${chalk.italic.dim('# печатает справку по командам')} + ${chalk.cyanBright('--import')} ${chalk.yellowBright('')}: ${chalk.italic.dim('# импортирует данные из TSV файла')} + ${chalk.cyanBright('--generate')} ${chalk.yellowBright(' ')}: ${chalk.italic.dim('# генерирует произвольное количество тестовых данных')} + `); + } +} diff --git a/src/cli/commands/import.command.ts b/src/cli/commands/import.command.ts new file mode 100644 index 0000000..3cb8fe8 --- /dev/null +++ b/src/cli/commands/import.command.ts @@ -0,0 +1,31 @@ +import { ICommand } from './types/command.interface.js'; +import { TSVFileReader } from '../../shared/libs/file-reader/index.js'; + +export class ImportCommand implements ICommand { + public getName(): string { + return '--import'; + } + + public async execute(...parameters: string[]): Promise { + const [fileName] = parameters; + + if (!fileName || fileName.trim() === '') { + throw new Error('File name is missing.'); + } + + const fileReader = new TSVFileReader(fileName.trim()); + + try { + fileReader.read(); + console.log(fileReader.toArray()); + } catch (err) { + + if (!(err instanceof Error)) { + throw err; + } + + console.error(`Can't import data from file: ${fileName}`); + console.error(`Details: ${err.message}`); + } + } +} diff --git a/src/cli/commands/types/command.interface.ts b/src/cli/commands/types/command.interface.ts new file mode 100644 index 0000000..a238ead --- /dev/null +++ b/src/cli/commands/types/command.interface.ts @@ -0,0 +1,4 @@ +export interface ICommand { + getName(): string; + execute(...parameters: string[]): Promise; +} diff --git a/src/cli/commands/types/package-json-config.interface.ts b/src/cli/commands/types/package-json-config.interface.ts new file mode 100644 index 0000000..3a2e936 --- /dev/null +++ b/src/cli/commands/types/package-json-config.interface.ts @@ -0,0 +1,3 @@ +export interface IPackageJSONConfig { + version: string; +} diff --git a/src/cli/commands/utils/parse-command.util.ts b/src/cli/commands/utils/parse-command.util.ts new file mode 100644 index 0000000..fe1f83b --- /dev/null +++ b/src/cli/commands/utils/parse-command.util.ts @@ -0,0 +1,15 @@ +export const parseCommand = (cliArgs: string[]): Record => { + const parsedCommand: Record = {}; + let currentCommand = ''; + + for (const argument of cliArgs) { + if (argument.startsWith('--')) { + parsedCommand[argument] = []; + currentCommand = argument; + } else if (currentCommand && argument) { + parsedCommand[currentCommand].push(argument); + } + } + + return parsedCommand; +}; diff --git a/src/cli/commands/version.command.ts b/src/cli/commands/version.command.ts new file mode 100644 index 0000000..b9cb67f --- /dev/null +++ b/src/cli/commands/version.command.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { ICommand } from './types/command.interface.js'; +import { IPackageJSONConfig } from './types/package-json-config.interface.js'; + +export class VersionCommand implements ICommand { + constructor( + private readonly filePath: string = 'package.json' + ) {} + + private isPackageJSONConfig(value: unknown): asserts value is IPackageJSONConfig { + if (!( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.hasOwn(value, 'version') + )) { + throw new Error(); + } + } + + private readVersion(): string { + const jsonContent = readFileSync(resolve(this.filePath), { encoding: 'utf-8' }); + const importedContent: unknown = JSON.parse(jsonContent); + this.isPackageJSONConfig(importedContent); + + return importedContent.version; + } + + public getName(): string { + return '--version'; + } + + public async execute(..._parameters: string[]): Promise { + try { + const version = this.readVersion(); + console.info(version); + } catch (error) { + console.error(`Failed to read version from ${this.filePath}`); + + if (error instanceof Error) { + console.error(error.message); + } + } + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..13c4d8f --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,4 @@ +export { CLIApplication } from './cli-application.js'; +export { HelpCommand } from './commands/help.command.js'; +export { VersionCommand } from './commands/version.command.js'; +export { ImportCommand } from './commands/import.command.js'; diff --git a/src/main.cli.ts b/src/main.cli.ts new file mode 100644 index 0000000..f6cbb8e --- /dev/null +++ b/src/main.cli.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import { CLIApplication, HelpCommand, VersionCommand, ImportCommand } from './cli/index.js'; + +const bootstrap = async () => { + const cliApplication = new CLIApplication(); + cliApplication.registerCommands([ + new HelpCommand(), + new VersionCommand(), + new ImportCommand(), + ]); + + await cliApplication.processCommand(process.argv); +}; + +await bootstrap(); diff --git a/src/shared/libs/file-reader/constants/decimal-radix.const.ts b/src/shared/libs/file-reader/constants/decimal-radix.const.ts new file mode 100644 index 0000000..1311ccd --- /dev/null +++ b/src/shared/libs/file-reader/constants/decimal-radix.const.ts @@ -0,0 +1 @@ +export const DECIMAL_RADIX = 10; diff --git a/src/shared/libs/file-reader/index.ts b/src/shared/libs/file-reader/index.ts new file mode 100644 index 0000000..47b4308 --- /dev/null +++ b/src/shared/libs/file-reader/index.ts @@ -0,0 +1 @@ +export { TSVFileReader } from './tsv-file-reader.js'; diff --git a/src/shared/libs/file-reader/tsv-file-reader.ts b/src/shared/libs/file-reader/tsv-file-reader.ts new file mode 100644 index 0000000..038ff20 --- /dev/null +++ b/src/shared/libs/file-reader/tsv-file-reader.ts @@ -0,0 +1,99 @@ +import { readFileSync } from 'node:fs'; + +import { IFileReader } from './types/file-reader.interface.js'; +import { IOffer } from '../../types/offer.interface.js'; +import { ECity } from '../../types/city.enum.js'; +import { EFacility } from '../../types/facility.enum.js'; +import { ICoordinates } from '../../types/coordinates.interface.js'; +import { EHousingType } from '../../types/housing-type.enum.js'; +import { DECIMAL_RADIX } from './constants/decimal-radix.const.js'; + +export class TSVFileReader implements IFileReader { + private rawData = ''; + + constructor( + private readonly filename: string + ) {} + + private validateRawData(): void { + if (!this.rawData) { + throw new Error('File was not read'); + } + } + + private parseSemicolonSeparatedValues(valuesString: string): T { + return valuesString.split(';') as T; + } + + private parseIntNumber(numberString: string): number { + return Number.parseInt(numberString, DECIMAL_RADIX); + } + + private parseNumberWithDot(numberString: string): number { + return Number.parseFloat(numberString); + } + + private parseCoordinates(coordinatesString: string): ICoordinates { + const coordinatesList = coordinatesString.split(';'); + return { + latitude: this.parseNumberWithDot(coordinatesList[0]), + longitude: this.parseNumberWithDot(coordinatesList[1]), + } as ICoordinates; + } + + private parseLineToOffer(line: string): IOffer { + const [ + title, + description, + createDate, + city, + previewImage, + images, + isPremium, + rating, + housingType, + roomsNumber, + guestsNumber, + price, + facilities, + author, + commentsNumber, + coordinates, + ] = line.split('\t'); + + return { + title, + description, + date: new Date(createDate), + city: city as ECity, + previewImage, + images: this.parseSemicolonSeparatedValues(images), + isPremium: !!this.parseIntNumber(isPremium), + rating: this.parseNumberWithDot(rating), + housingType: housingType as EHousingType, + roomsNumber: this.parseIntNumber(roomsNumber), + guestsNumber: this.parseIntNumber(guestsNumber), + price: this.parseIntNumber(price), + facilities: this.parseSemicolonSeparatedValues(facilities), + author, + commentsNumber: this.parseIntNumber(commentsNumber), + coordinates: this.parseCoordinates(coordinates), + }; + } + + private parseRawDataToOffers(): IOffer[] { + return this.rawData + .split('\n') + .filter((row) => row.trim().length) + .map((line) => this.parseLineToOffer(line)); + } + + public read(): void { + this.rawData = readFileSync(this.filename, { encoding: 'utf-8' }); + } + + public toArray(): IOffer[] { + this.validateRawData(); + return this.parseRawDataToOffers(); + } +} diff --git a/src/shared/libs/file-reader/types/file-reader.interface.ts b/src/shared/libs/file-reader/types/file-reader.interface.ts new file mode 100644 index 0000000..14c7784 --- /dev/null +++ b/src/shared/libs/file-reader/types/file-reader.interface.ts @@ -0,0 +1,3 @@ +export interface IFileReader { + read(): void; +} diff --git a/src/shared/types/city.enum.ts b/src/shared/types/city.enum.ts new file mode 100644 index 0000000..5be1855 --- /dev/null +++ b/src/shared/types/city.enum.ts @@ -0,0 +1,8 @@ +export enum ECity { + Paris = 'Paris', + Cologne = 'Cologne', + Brussels = 'Brussels', + Amsterdam = 'Amsterdam', + Hamburg = 'Hamburg', + Dusseldorf = 'Dusseldorf' +} diff --git a/src/shared/types/comment.interface.ts b/src/shared/types/comment.interface.ts new file mode 100644 index 0000000..b14aa61 --- /dev/null +++ b/src/shared/types/comment.interface.ts @@ -0,0 +1,6 @@ +export interface IComment { + text: string, + date: Date, + rating: number, + author: string, +} diff --git a/src/shared/types/coordinates.interface.ts b/src/shared/types/coordinates.interface.ts new file mode 100644 index 0000000..7ef0b12 --- /dev/null +++ b/src/shared/types/coordinates.interface.ts @@ -0,0 +1,4 @@ +export interface ICoordinates { + latitude: number, + longitude: number, +} diff --git a/src/shared/types/facility.enum.ts b/src/shared/types/facility.enum.ts new file mode 100644 index 0000000..d3808f6 --- /dev/null +++ b/src/shared/types/facility.enum.ts @@ -0,0 +1,9 @@ +export enum EFacility { + Breakfast = 'Breakfast', + AirConditioning = 'Air conditioning', + LaptopFriendly = 'Laptop friendly workspace', + BabySeat = 'Baby seat', + Washer = 'Washer', + Towels = 'Towels', + Fridge = 'Fridge' +} diff --git a/src/shared/types/housing-type.enum.ts b/src/shared/types/housing-type.enum.ts new file mode 100644 index 0000000..df4e577 --- /dev/null +++ b/src/shared/types/housing-type.enum.ts @@ -0,0 +1,6 @@ +export enum EHousingType { + Apartment = 'apartment', + House = 'house', + Room = 'room', + Hotel = 'hotel' +} diff --git a/src/shared/types/offer.interface.ts b/src/shared/types/offer.interface.ts new file mode 100644 index 0000000..05c09e4 --- /dev/null +++ b/src/shared/types/offer.interface.ts @@ -0,0 +1,23 @@ +import { ECity } from './city.enum.js'; +import { EHousingType } from './housing-type.enum.js'; +import { EFacility } from './facility.enum.js'; +import { ICoordinates } from './coordinates.interface.js'; + +export interface IOffer { + title: string, + description: string, + date: Date, + city: ECity, + previewImage: string, + images: string[], + isPremium: boolean, + rating: number, + housingType: EHousingType, + roomsNumber: number, + guestsNumber: number, + price: number, + facilities: EFacility[], + author: string, + commentsNumber: number, + coordinates: ICoordinates, +} diff --git a/src/shared/types/user-type.enum.ts b/src/shared/types/user-type.enum.ts new file mode 100644 index 0000000..32468d2 --- /dev/null +++ b/src/shared/types/user-type.enum.ts @@ -0,0 +1,4 @@ +export enum EUserType { + Regular = 'обычный', + Pro = 'pro', +} diff --git a/src/shared/types/user.interface.ts b/src/shared/types/user.interface.ts new file mode 100644 index 0000000..5a0f024 --- /dev/null +++ b/src/shared/types/user.interface.ts @@ -0,0 +1,9 @@ +import { EUserType } from './user-type.enum.js'; + +export interface IUser { + name: string, + email: string, + avatar: string, + password: string, + userType: EUserType, +}