diff --git a/README.org b/README.org index aec2536..2acdd86 100644 --- a/README.org +++ b/README.org @@ -64,3 +64,10 @@ - Choices (~new ChoiceQuestion~) - ~addOption('config')~ +** API calls + + +*** Get csv file + + curl 'https://drunomics.myjetbrains.com/youtrack/api/reports/83-554/export/csv?&$top=-1' -H 'Accept: application/json, text/plain, */*' -H 'Accept-Language: en-US,en;q=0.5' --compressed -H "Authorization: Bearer $TKN" > ~/Documents/Drunomics/workhours/2021/21-09.csv + diff --git a/app/composer.json b/app/composer.json index 437a36e..8820a0d 100644 --- a/app/composer.json +++ b/app/composer.json @@ -18,8 +18,7 @@ "guzzlehttp/guzzle": "^7.3", "php-di/php-di": "^6.3", "symfony/yaml": "^5.2", - "mpdf/mpdf": "^8.0", - "symfony/translation": "^5.2" + "mpdf/mpdf": "^8.0" }, "autoload": { "psr-4": { diff --git a/app/composer.lock b/app/composer.lock index 8fe2e36..400ceb3 100644 --- a/app/composer.lock +++ b/app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e106a8658dd1c87ae4ec9212251d4943", + "content-hash": "e6f2abc31fc53724d858e127ce02978e", "packages": [ { "name": "guzzlehttp/guzzle", @@ -1506,143 +1506,6 @@ ], "time": "2021-03-17T17:12:15+00:00" }, - { - "name": "symfony/translation", - "version": "v5.2.6", - "source": { - "type": "git", - "url": "https://github.com/symfony/translation.git", - "reference": "2cc7f45d96db9adfcf89adf4401d9dfed509f4e1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/2cc7f45d96db9adfcf89adf4401d9dfed509f4e1", - "reference": "2cc7f45d96db9adfcf89adf4401d9dfed509f4e1", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "^1.15", - "symfony/translation-contracts": "^2.3" - }, - "conflict": { - "symfony/config": "<4.4", - "symfony/dependency-injection": "<5.0", - "symfony/http-kernel": "<5.0", - "symfony/twig-bundle": "<5.0", - "symfony/yaml": "<4.4" - }, - "provide": { - "symfony/translation-implementation": "2.3" - }, - "require-dev": { - "psr/log": "~1.0", - "symfony/config": "^4.4|^5.0", - "symfony/console": "^4.4|^5.0", - "symfony/dependency-injection": "^5.0", - "symfony/finder": "^4.4|^5.0", - "symfony/http-kernel": "^5.0", - "symfony/intl": "^4.4|^5.0", - "symfony/service-contracts": "^1.1.2|^2", - "symfony/yaml": "^4.4|^5.0" - }, - "suggest": { - "psr/log-implementation": "To use logging capability in translator", - "symfony/config": "", - "symfony/yaml": "" - }, - "type": "library", - "autoload": { - "files": [ - "Resources/functions.php" - ], - "psr-4": { - "Symfony\\Component\\Translation\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides tools to internationalize your application", - "homepage": "https://symfony.com", - "time": "2021-03-23T19:33:48+00:00" - }, - { - "name": "symfony/translation-contracts", - "version": "v2.3.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/translation-contracts.git", - "reference": "e2eaa60b558f26a4b0354e1bbb25636efaaad105" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/e2eaa60b558f26a4b0354e1bbb25636efaaad105", - "reference": "e2eaa60b558f26a4b0354e1bbb25636efaaad105", - "shasum": "" - }, - "require": { - "php": ">=7.2.5" - }, - "suggest": { - "symfony/translation-implementation": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.3-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Translation\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to translation", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "time": "2020-09-28T13:05:58+00:00" - }, { "name": "symfony/yaml", "version": "v5.2.5", diff --git a/app/config/rprt.example.config.yaml b/app/config/rprt.example.config.yaml index 79887dd..b9a73ed 100644 --- a/app/config/rprt.example.config.yaml +++ b/app/config/rprt.example.config.yaml @@ -4,6 +4,13 @@ tracking service: youtrack: auth token: '' +# reports: +# report short name: +# table: +# header: +# # overrides for table header +# hours: Quantity +# source: projects: '': name: '' @@ -14,3 +21,10 @@ projects: time column: 4 # time format m - minutes, h - hours time format: 'm' + labels: + project: Project + # hours: Quantity + hours: Hours + rate: 'Price per hour' + price: Price +locale: 'en_GB' diff --git a/app/dependencies.php b/app/dependencies.php index 0ee65f9..8f41a27 100644 --- a/app/dependencies.php +++ b/app/dependencies.php @@ -9,17 +9,34 @@ use RprtCli\Utils\Configuration\ConfigurationService; use RprtCli\Utils\CsvReport\CsvReport; use RprtCli\Utils\CsvReport\CsvReportInterface; use GuzzleHttp\Client; +use RprtCli\Utils\TimeTrackingServices\YoutrackInterface; +use RprtCli\Utils\TimeTrackingServices\YoutrackService; + +# use Symfony\Component\Translation\Translator; +#use Symfony\Component\Translation\Loader\PoFileLoader; return [ 'config.file' => 'rprt.config.yml', 'config.path' => '~/.config/rprt-cli/', - 'guzzle' => create()->constructor(Client::class), + 'default_locale' => 'en', + // 'translator' => ['default_path' => '%kernel.project_dir%/translations'], + // 'guzzle' => create()->constructor(Client::class), + 'guzzle' => get(Client::class), ConfigurationInterface::class => get(ConfigurationService::class), ConfigurationService::class => create()->constructor( get('config.path'), get('config.file') ), 'config.service' => get(ConfigurationInterface::class), + YoutrackInterface::class => get(YoutrackService::class), + YoutrackService::class => create()->constructor( + get('config.service'), + get('guzzle') + ), + 'youtrack.service' => get(YoutrackInterface::class), + // 'locale' => get('config.service')->method('get', 'en'), + // Translator::class => create()->constructor('sl')->method('addLoader', 'po', new PoFileLoader), + // 'translator' => get(Translator::class), CsvReportInterface::class => get(CsvReport::class), CsvReport::class => create()->constructor( get('config.service') @@ -27,6 +44,7 @@ return [ 'csv.report' => get(CsvReportInterface::class), RprtCommand::class => create()->constructor( get('csv.report'), - get('config.service') - ) + get('config.service'), + get('youtrack.service') + ), ]; diff --git a/app/rprt.php b/app/rprt.php index 0668733..3c5ffc8 100755 --- a/app/rprt.php +++ b/app/rprt.php @@ -10,6 +10,7 @@ require __DIR__ . '/vendor/autoload.php'; $builder = new ContainerBuilder(); $builder->addDefinitions('dependencies.php'); $container = $builder->build(); + $application = new Application(); $rprtCommand = $container->get(RprtCommand::class); diff --git a/app/src/Commands/RprtCommand.php b/app/src/Commands/RprtCommand.php index abbc80a..784bb8b 100644 --- a/app/src/Commands/RprtCommand.php +++ b/app/src/Commands/RprtCommand.php @@ -1,105 +1,159 @@ csv = $csv; - $this->configuration = $configuration; - parent::__construct(); - } + protected $youtrack; - /** - * Get configuration. - */ - protected function configure(): void { - $this->setName('rprt'); - $this->setDescription('Generate monthly report'); - // @TODO $this->addUsage(''); - $this->addOption('file', 'f', InputOption::VALUE_REQUIRED, 'Specify the input csv file to generate report from.'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int { - - if ($file = $input->getOption('file')) { - $data = $this->csv->getReportData($file); - $table = $this->generateTable($output, $data); - $table->render(); - return Command::SUCCESS; + public function __construct( + CsvReportInterface $csv, + ConfigurationInterface $configuration, + YoutrackInterface $youtrack, + ?string $name = null + ) { + $this->csv = $csv; + $this->configuration = $configuration; + $this->youtrack = $youtrack; + parent::__construct($name); } - $this->dummyOutput($input, $output); - return Command::SUCCESS; - } + /** + * Get configuration. + */ + protected function configure() : void + { + $this->setName('rprt'); + $this->setDescription('Generate monthly report'); + // @TODO $this->addUsage(''); + $this->addOption( + 'file', + 'f', + InputOption::VALUE_REQUIRED, + 'Specify the input csv file to generate report from.' + ); + $this->addOption( + 'youtrack', + 'y', + InputOption::VALUE_NONE, + 'Use youtrack api to get a report. If this option is used --file does not have any effect.' + ); + $this->addOption( + 'pdf', + 'p', + InputOption::VALUE_NONE, + 'Create invoice pdf from template.' + ); + $this->addOption( + 'test', + 't', + InputOption::VALUE_NONE, + 'Test login into youtrack service.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) : int + { + if ($input->getOption('test')) { + $test = $this->youtrack->testYoutrackapi(); + $output->writeln($test); + } + if ($file = $input->getOption('file')) { + $data = $this->csv->getReportData($file); + $table = $this->generateTable($output, $data); + $table->render(); + return Command::SUCCESS; + } + + $this->dummyOutput($input, $output); + return Command::SUCCESS; + } /** * Create table from data that is already inline with configuration. */ - protected function generateTable($output, $data) { - $table = new Table($output); - $table->setHeaders(['Project', 'Hours', 'Rate', 'Price']); - list($rows, $total_hours, $total_price) = [[], 0, 0]; - $projects_config = $this->configuration->get('projects'); - foreach ($projects_config as $name => $config) { - if (!isset($data[$name])) { - // @TODO Proper error handling. - var_dump('Project ' . $name . ' is not set!'); - continue; - } - $hours = $data[$name]; - if ($config['time_format'] === 'm') { - $hours = $hours/60; - } - $price = $hours * (int) $config['price']; - $row = [ - $config['name'], - $hours, - $config['price'], - $hours * $config['price'], - ]; - $rows[] = $row; - $total_hours += $hours; - $total_price += $price; + protected function generateTable(OutputInterface $output, array $data) : Table + { + $table = new Table($output); + $table->setHeaders([ + // $this->translator->trans('Project', [], 'messages', 'sl_SI'), + // $this->translator->trans('Hours', [], 'messages', 'sl_SI'), + // $this->translator->trans('Rate'), + // $this->translator->trans('Price'), + 'Project', 'Hours', 'Rate', 'Price', + ]); + [$rows, $totalHours, $totalPrice] = [[], 0, 0]; + $projectsConfig = $this->configuration->get('projects'); + foreach ($projectsConfig as $name => $config) { + if (! isset($data[$name])) { + // @TODO Proper error handling. + var_dump('Project ' . $name . ' is not set!'); + continue; + } + $hours = $data[$name]; + if ($config['time_format'] === 'm') { + $hours /= 60; + } + $price = $hours * (int) $config['price']; + $row = [ + $config['name'], + $hours, + $config['price'], + $hours * $config['price'], + ]; + $rows[] = $row; + $totalHours += $hours; + $totalPrice += $price; + } + $rows[] = new TableSeparator(); + // @TODO Check rate in final result. + // $rows[] = [$this->translator->trans('Sum'), $totalHours, $config['price'], $totalPrice]; + $rows[] = ['Sum', $totalHours, $config['price'], $totalPrice]; + + $table->setRows($rows); + return $table; } - $rows[] = new TableSeparator(); - // @TODO Check rate in final result. - $rows[] = ['Zusamen', $total_hours, $config['price'], $total_price]; - $table->setRows($rows); - return $table; - } /** * Dummy output for testing. */ - protected function dummyOutput(InputInterface $input, OutputInterface $output): void { - $output->writeln('I will output a nice table.'); - $table = New Table($output); - $table->setHeaders(['Project', 'Hours', 'Price']); - $table->setRows([ - ['LDP', 100, 2600], - ['WV', 50, 1300], - new TableSeparator(), - ['Zusamen', 150, 3900], - ]); - // $table->setStyle('borderless'); - $table->render(); - - } - + protected function dummyOutput(InputInterface $input, OutputInterface $output) : void + { + // $txt = $this->translator->trans('From [start-date] to [end-date].', [], 'rprt', 'sl_SI'); + // $output->writeln($txt); + $table = new Table($output); + $table->setHeaders(['Project', 'Hours', 'Price']); + $table->setRows([ + ['LDP', 100, 2600], + ['WV', 50, 1300], + new TableSeparator(), + ['Zusamen', 150, 3900], + ]); + // $table->setStyle('borderless'); + $table->render(); + } } diff --git a/app/src/Utils/Configuration/ConfigurationInterface.php b/app/src/Utils/Configuration/ConfigurationInterface.php index d3399f7..b8ee77b 100644 --- a/app/src/Utils/Configuration/ConfigurationInterface.php +++ b/app/src/Utils/Configuration/ConfigurationInterface.php @@ -1,32 +1,25 @@ configFileName = $filename; - $this->configFilePath = $this->findConfig($file); - if ($this->configFilePath) { - $this->getConfig(); + public function __construct(string $filepath, string $filename) + { + $file = $filepath . $filename; + $this->configFileName = $filename; + $this->configFilePath = $this->findConfig($file); + if ($this->configFilePath) { + $this-> + getConfig(); + } } - } /** * Checks for config file. * + * @param string $filename + * Name of the configuration file. * @return string|bool * Full path to config file or FALSE if it doesn't exist. */ - public function findConfig($filename) { - if (file_exists($filename)) { - return $filename; + public function findConfig($filename) + { + if (file_exists($filename)) { + return $filename; + } + foreach (self::PATHS as $path) { + $fullPath = $_SERVER['HOME'] . $path . $this->configFileName; + if (file_exists($fullPath)) { + return $fullPath; + } + } + // @TODO This should be some kind of error! + var_dump('Config File Not Found!'); + return false; } - foreach (self::PATHS as $path) { - $fullPath = $_SERVER['HOME'] . $path . $this->configFileName; - if (file_exists($fullPath)) { - return $fullPath; - } - } - // @TODO This should be some kind of error! - var_dump('Config File Not Found!'); - return FALSE; - } /** * Get and read the configuration from file. */ - public function getConfig() { - if ($this->configFilePath) { - $config = Yaml::parseFile($this->configFilePath); - $this->data = $config; - return TRUE; + public function getConfig() : bool + { + if ($this->configFilePath) { + $config = Yaml::parseFile($this->configFilePath); + $this->data = $config; + return true; + } + // Maybe write an exception for missing config. + // Ask for reconfiguration. + // @TODO This should be some kind of error! + var_dump('Config File Path not found!'); + return false; } - // Maybe write an exception for missing config. - // Ask for reconfiguration. - // @TODO This should be some kind of error! - var_dump('Config File Path not found!'); - return FALSE; - } - /** - * {@inheritdoc} - */ - public function get($key, $default = null) { - $this->default = $default; - $segments = explode('.', $key); - $data = $this->data; - foreach ($segments as $segment) { - if (isset($data[$segment])) { - $data = $data[$segment]; - } else { - $data = $this->default; - break; - } + /** + * Get a specific configuration for key. + * + * @param string $key + * Config key. + * @param null|mixed $default + * Default value if config for key is not yet specified. + * @return mixed + * Data. + */ + public function get($key, $default = null) + { + $this->default = $default; + $segments = explode('.', $key); + $data = $this->data; + foreach ($segments as $segment) { + if (isset($data[$segment])) { + $data = $data[$segment]; + } else { + $data = $this->default; + break; + } + } + return $data; } - return $data; - } - /** - * {@inheritdoc} - */ - public function exists($key) { - return $this->get($key) !== $this->default; - } + /** + * Checks if key exists in the configuration file. + * + * @param string $key + * Key to check for. + * + * Value of configuration key is not equal to default. + */ + public function exists($key) : bool + { + return $this->get($key) !== $this->default; + } } diff --git a/app/src/Utils/Configuration/TranslationService.php b/app/src/Utils/Configuration/TranslationService.php new file mode 100644 index 0000000..e4e587f --- /dev/null +++ b/app/src/Utils/Configuration/TranslationService.php @@ -0,0 +1,22 @@ +config = $configuration; + + } + + + +} diff --git a/app/src/Utils/CsvReport/CsvReport.php b/app/src/Utils/CsvReport/CsvReport.php index bcb3dd0..5d25d27 100644 --- a/app/src/Utils/CsvReport/CsvReport.php +++ b/app/src/Utils/CsvReport/CsvReport.php @@ -1,75 +1,102 @@ configurationService = $config; - } - - public function getReportData(string $file_path): array { - $output = []; - // @TODO replace with config service. - // $config = $this->dummyConfig()['projects']; - $config = $this->configurationService->get('projects'); - foreach (array_keys($config) as $key) { - $output[$key] = 0; + public function __construct(ConfigurationInterface $config) + { + $this->configurationService = $config; } - if ($file = fopen($file_path, 'r')) { - while (($line = fgetcsv($file)) !== FALSE) { - $parsed = $this->parseCsvFile($line); - // $key = reset(array_keys($parsed)); - $key = array_key_first($parsed); - if (isset($output[$key])) { - $output[$key] += (int) reset($parsed); + + /** + * {@inheritdoc} + */ + public function getReportData(string $filePath) : array + { + $output = []; + // @TODO replace with config service. + // $config = $this->dummyConfig()['projects']; + $config = $this->configurationService->get('projects'); + foreach (array_keys($config) as $key) { + $output[$key] = 0; } - } + if ($file = fopen($filePath, 'r')) { + while (($line = fgetcsv($file)) !== false) { + $parsed = $this->parseCsvFile($line); + // $key = reset(array_keys($parsed)); + $key = array_key_first($parsed); + if (isset($output[$key])) { + $output[$key] += (int) reset($parsed); + } + } + } + return $output; } - return $output; - } - protected function parseCsvFile(array $raw_data): array { - // $config = $this->dummyConfig(); - $config = $this->configurationService->get('projects'); - // var_dump($raw_data); - foreach ($config as $key => $project) { - if (preg_match('/'.$project['pattern'].'/', $raw_data[1])) { - return [$key => $raw_data[4]]; - } + /** + * Get correct values from the raw data lines of csv. + * + * + * Columns with data are specified in config. + * + * Project key and unit of time spent. + */ + protected function parseCsvFile(array $rawData) : array + { + $config = $this->configurationService->get('projects'); + foreach ($config as $key => $project) { + if (preg_match('/' . $project['pattern'] . '/', $rawData[1])) { + return [$key => $rawData[4]]; + } + } + return []; } - return []; - } - protected function dummyConfig(): array { - $config = [ - 'projects' => [ - 'LDP' => [ - 'name' => 'lupus.digital', - 'pattern' => 'LDP-[0-9]+', - 'price' => 26, + protected function dummyConfig() : array + { + return [ + 'projects' => [ + 'LDP' => [ + 'name' => 'lupus.digital', + 'pattern' => 'LDP-[0-9]+', + 'price' => 26, // optional specify columns - ], - 'WV' => [ - 'name' => 'Wirtschaftsverlag', - 'pattern' => 'WV-[0-9]+', - 'price' => 26, + ], + 'WV' => [ + 'name' => 'Wirtschaftsverlag', + 'pattern' => 'WV-[0-9]+', + 'price' => 26, // optional specify columns - ], - 'Other' => [ - 'name' => 'Other projects', - 'pattern' => '(?!.\bLDP\b)(?!.\bWV\b)', - 'price' => 26, + ], + 'Other' => [ + 'name' => 'Other projects', + 'pattern' => '(?!.\bLDP\b)(?!.\bWV\b)', + 'price' => 26, // optional specify columns - ], - ], - ]; - - return $config; - } + ], + ], + ]; + } } diff --git a/app/src/Utils/CsvReport/CsvReportInterface.php b/app/src/Utils/CsvReport/CsvReportInterface.php index 69a1090..eff3e6a 100644 --- a/app/src/Utils/CsvReport/CsvReportInterface.php +++ b/app/src/Utils/CsvReport/CsvReportInterface.php @@ -1,22 +1,21 @@ config = $config; + $this->httpClient = $http_client; + } + + public function testYoutrackapi(): ?string + { + // Get base url from config or add input. + // Get token or add input. + $path = 'youtrack/api/admin/users/me'; + $yt_url = $this->getYtUrl($path); + $yt_token = $this->getYtToken(); + $query = ['fields' => 'id,email,fullName']; + $headers = [ + "Authorization" => "Bearer $yt_token", + 'Cache-Control' =>'no-cache', + ]; + $me_response = $this->httpClient->request('GET', $yt_url, [ + 'query' => $query, + 'headers' => $headers + ]); + $test = (string) $me_response->getBody()->getContents(); + $me_json = (array) json_decode($test); + if ($me_json && isset($me_json['fullName'])) { + return $me_json['fullName']; + } + + return NULL; + } + + public function getReportId(): ?string + { + // --report option value should take precedence. + // @TODO error handling. + $yt_report_id = $this->config->get('youtrack.report_id'); + if (!$yt_report_id) { + $yt_report_id = readline('Enter the report id: '); + } + return $yt_report_id; + } + + public function downloadReport(string $report_id): ?string + { + $path = "youtrack/api/reports/$report_id/export/csv"; + $query = ['$top' => -1]; + $yt_token = $this->getYtToken(); + $headers = [ + 'Accept' => 'text/plain, */*', + 'Accept-Language' => 'en-US,en;q=0.5', + "Authorization" => "Bearer $yt_token", + ]; + + $csv_response = $this->httpClient->request('GET', $this->getYtUrl($path), [ + 'headers' => $headers, + 'query' => $query, + ]); + + // Write csv response test into temporary file. + $csv_file = tempnam(sys_get_temp_dir(), 'yt_csv_'); + file_put_contents($csv_file, $csv_response->getBody()); + return $csv_file; + } + + protected function getYtToken(): string { + $yt_token = $this->config->get('tracking_service.youtrack.auth_token', FALSE); // + if (!$yt_token) { + $yt_token = readline('Enter your youtrack authentication token: '); + } + return $yt_token; + } + + protected function getYtUrl(string $path = ''): ?string { + $yt_base_url = $this->config->get('tracking_service.youtrack.base_url', FALSE); + if (!$yt_base_url) { + $yt_base_url = readline('Enter base url for of the youtrack service: '); + } + if (!empty($path)) { + $yt_base_url = $yt_base_url . '/' . trim($path, '/'); + } + return $yt_base_url; + } +}