Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)

This commit is contained in:
Kevin Papst
2022-12-31 21:19:55 +01:00
committed by GitHub
parent 95e06746bd
commit 90a0fd8a22
2164 changed files with 83700 additions and 86426 deletions

View File

@@ -94,10 +94,7 @@ abstract class AbstractBundleInstallerCommand extends Command
return $parts[1];
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName($this->getInstallerCommandName())
@@ -106,12 +103,7 @@ abstract class AbstractBundleInstallerCommand extends Command
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
@@ -132,7 +124,7 @@ abstract class AbstractBundleInstallerCommand extends Command
sprintf('Failed to install database for bundle %s. %s', $bundleName, $ex->getMessage())
);
return 1;
return Command::FAILURE;
}
if ($this->hasAssets()) {
@@ -143,7 +135,7 @@ abstract class AbstractBundleInstallerCommand extends Command
sprintf('Failed to install assets for bundle %s. %s', $bundleName, $ex->getMessage())
);
return 1;
return Command::FAILURE;
}
}
@@ -153,7 +145,7 @@ abstract class AbstractBundleInstallerCommand extends Command
sprintf('Congratulations! Plugin was successful installed: %s', $bundleName)
);
return 0;
return Command::SUCCESS;
}
protected function installAssets(SymfonyStyle $io, OutputInterface $output)

View File

@@ -26,17 +26,19 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/
abstract class AbstractResetCommand extends Command
{
public function __construct(private string $kernelEnvironment)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setName('kimai:reset:' . $this->getEnvName())
->setAliases(['kimai:reset-' . $this->getEnvName()])
->setDescription('Resets the "' . $this->getEnvName() . '" environment')
->setHelp(
<<<EOT
This command will drop and re-create the database and its schemas, load data and clear the cache.
Use the <info>-n</info> switch to skip the question.
EOT
This command will drop and re-create the database and its schemas, load data and clear the cache.
Use the <info>-n</info> switch to skip the question.
EOT
)
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache flushing')
;
@@ -44,16 +46,7 @@ EOT
public function isEnabled(): bool
{
return $this->getEnv() !== 'prod';
}
private function getEnv(): string
{
/** @var Application $application */
$application = $this->getApplication();
$kernel = $application->getKernel();
return $kernel->getEnvironment();
return $this->kernelEnvironment !== 'prod';
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -68,12 +61,12 @@ EOT
} catch (Exception $ex) {
$io->error('Failed to create database: ' . $ex->getMessage());
return 1;
return Command::FAILURE;
}
}
if ($this->askConfirmation($input, $output, 'Do you want to drop and re-create the schema y/N ?')) {
if (($result = $this->dropSchema($io, $output)) !== 0) {
if (($result = $this->dropSchema($io, $output)) !== Command::SUCCESS) {
return $result;
}
@@ -85,7 +78,7 @@ EOT
} catch (Exception $ex) {
$io->error('Failed to execute a migrations: ' . $ex->getMessage());
return 5;
return Command::FAILURE;
}
}
@@ -94,7 +87,7 @@ EOT
} catch (Exception $ex) {
$io->error('Failed to import data: ' . $ex->getMessage());
return 6;
return Command::FAILURE;
}
if (!$input->getOption('no-cache')) {
@@ -104,11 +97,11 @@ EOT
} catch (Exception $ex) {
$io->error('Failed to clear cache: ' . $ex->getMessage());
return 7;
return Command::FAILURE;
}
}
return 0;
return Command::SUCCESS;
}
protected function dropSchema(SymfonyStyle $io, OutputInterface $output): int
@@ -119,7 +112,7 @@ EOT
} catch (Exception $ex) {
$io->error('Failed to drop database schema: ' . $ex->getMessage());
return 2;
return Command::FAILURE;
}
try {
@@ -128,7 +121,7 @@ EOT
} catch (Exception $ex) {
$io->error('Failed to drop migration_versions table: ' . $ex->getMessage());
return 3;
return Command::FAILURE;
}
try {
@@ -137,10 +130,10 @@ EOT
} catch (Exception $ex) {
$io->error('Failed to drop kimai2_sessions table: ' . $ex->getMessage());
return 4;
return Command::FAILURE;
}
return 0;
return Command::SUCCESS;
}
private function askConfirmation(InputInterface $input, OutputInterface $output, string $question): bool
@@ -156,7 +149,5 @@ EOT
return $questionHelper->ask($input, $output, $question);
}
abstract protected function getEnvName(): string;
abstract protected function loadData(InputInterface $input, OutputInterface $output): void;
}

View File

@@ -20,18 +20,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractRoleCommand extends Command
{
private $userService;
public function __construct(UserService $userService)
public function __construct(private UserService $userService)
{
parent::__construct();
$this->userService = $userService;
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setDefinition([
@@ -41,10 +35,7 @@ abstract class AbstractRoleCommand extends Command
]);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$username = $input->getArgument('username');
$role = $input->getArgument('role');
@@ -62,7 +53,7 @@ abstract class AbstractRoleCommand extends Command
$this->executeRoleCommand($this->userService, new SymfonyStyle($input, $output), $user, $super, $role);
return 0;
return Command::SUCCESS;
}
abstract protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role);

View File

@@ -9,11 +9,13 @@
namespace App\Command;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractUserCommand extends Command
{
@@ -37,4 +39,17 @@ abstract class AbstractUserCommand extends Command
return $helper->ask($input, $output, $passwordQuestion);
}
protected function validationError(ValidationFailedException $exception, SymfonyStyle $style): void
{
$errors = $exception->getViolations();
if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach ($errors as $error) {
$style->error(
$error->getPropertyPath() . ': ' . $error->getMessage()
);
}
}
}
}

View File

@@ -10,47 +10,38 @@
namespace App\Command;
use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ActivateUserCommand extends Command
#[AsCommand(name: 'kimai:user:activate')]
final class ActivateUserCommand extends Command
{
private $userService;
public function __construct(UserService $userService)
public function __construct(private UserService $userService)
{
parent::__construct();
$this->userService = $userService;
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:user:activate')
->setAliases(['fos:user:activate'])
->setDescription('Activate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->setHelp(
<<<'EOT'
The <info>kimai:user:activate</info> command activates a user (so they will be able to log in):
The <info>kimai:user:activate</info> command activates a user (so they will be able to log in):
<info>php %command.full_name% susan_super</info>
EOT
<info>php %command.full_name% susan_super</info>
EOT
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$username = $input->getArgument('username');
$user = $this->userService->findUserByUsernameOrThrowException($username);
@@ -65,6 +56,6 @@ EOT
$io->warning(sprintf('User "%s" is already active.', $username));
}
return 0;
return Command::SUCCESS;
}
}

View File

@@ -10,27 +10,25 @@
namespace App\Command;
use App\User\UserService;
use App\Utils\CommandStyle;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:password')]
final class ChangePasswordCommand extends AbstractUserCommand
{
private $userService;
public function __construct(UserService $userService)
public function __construct(private UserService $userService)
{
parent::__construct();
$this->userService = $userService;
}
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:user:password')
->setAliases(['fos:user:change-password'])
->setDescription('Change the password of a user.')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
@@ -38,24 +36,21 @@ final class ChangePasswordCommand extends AbstractUserCommand
])
->setHelp(
<<<'EOT'
The <info>kimai:user:password</info> command changes the password of a user:
The <info>kimai:user:password</info> command changes the password of a user:
<info>php %command.full_name% matthieu</info>
<info>php %command.full_name% matthieu</info>
This interactive shell will first ask you for a password.
This interactive shell will first ask you for a password.
You can alternatively specify the password as a second argument:
You can alternatively specify the password as a second argument:
<info>php %command.full_name% susan_super newpassword</info>
<info>php %command.full_name% susan_super newpassword</info>
EOT
EOT
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$username = $input->getArgument('username');
@@ -67,18 +62,18 @@ EOT
$user = $this->userService->findUserByUsernameOrThrowException($username);
$io = new CommandStyle($input, $output);
$io = new SymfonyStyle($input, $output);
try {
$user->setPlainPassword($password);
$this->userService->updateUser($user, ['PasswordUpdate']);
$io->success(sprintf('Changed password for user "%s".', $username));
} catch (ValidationFailedException $ex) {
$io->validationError($ex);
$this->validationError($ex, $io);
return 2;
return Command::FAILURE;
}
return 0;
return Command::SUCCESS;
}
}

View File

@@ -11,32 +11,27 @@ namespace App\Command;
use App\Entity\User;
use App\User\UserService;
use App\Utils\CommandStyle;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:create')]
final class CreateUserCommand extends AbstractUserCommand
{
private $userService;
public function __construct(UserService $userService)
public function __construct(private UserService $userService)
{
parent::__construct();
$this->userService = $userService;
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$roles = implode(',', [User::DEFAULT_ROLE, User::ROLE_ADMIN]);
$this
->setName('kimai:user:create')
->setAliases(['kimai:create-user'])
->setDescription('Create a new user')
->setHelp('This command allows you to create a new user.')
->addArgument('username', InputArgument::REQUIRED, 'A name for the new user (must be unique)')
@@ -51,12 +46,9 @@ final class CreateUserCommand extends AbstractUserCommand
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new CommandStyle($input, $output);
$io = new SymfonyStyle($input, $output);
$username = $input->getArgument('username');
$email = $input->getArgument('email');
@@ -71,7 +63,7 @@ final class CreateUserCommand extends AbstractUserCommand
$role = $role ?: User::DEFAULT_ROLE;
$user = $this->userService->createNewUser();
$user->setUsername($username);
$user->setUserIdentifier($username);
$user->setPlainPassword($password);
$user->setEmail($email);
$user->setEnabled(true);
@@ -81,11 +73,11 @@ final class CreateUserCommand extends AbstractUserCommand
$this->userService->saveNewUser($user);
$io->success(sprintf('Success! Created user: %s', $username));
} catch (ValidationFailedException $ex) {
$io->validationError($ex);
$this->validationError($ex, $io);
return 2;
return Command::FAILURE;
}
return 0;
return Command::SUCCESS;
}
}

View File

@@ -10,47 +10,38 @@
namespace App\Command;
use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class DeactivateUserCommand extends Command
#[AsCommand(name: 'kimai:user:deactivate')]
final class DeactivateUserCommand extends Command
{
private $userService;
public function __construct(UserService $userService)
public function __construct(private UserService $userService)
{
parent::__construct();
$this->userService = $userService;
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:user:deactivate')
->setAliases(['fos:user:deactivate'])
->setDescription('Deactivate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->setHelp(
<<<'EOT'
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)
<info>php %command.full_name% susan_super</info>
EOT
<info>php %command.full_name% susan_super</info>
EOT
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$username = $input->getArgument('username');
$user = $this->userService->findUserByUsernameOrThrowException($username);
@@ -65,6 +56,6 @@ EOT
$io->warning(sprintf('User "%s" is already deactivated.', $username));
}
return 0;
return Command::SUCCESS;
}
}

View File

@@ -11,28 +11,25 @@ namespace App\Command;
use App\Entity\User;
use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle;
class DemoteUserCommand extends AbstractRoleCommand
#[AsCommand(name: 'kimai:user:demote')]
final class DemoteUserCommand extends AbstractRoleCommand
{
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
parent::configure();
$this
->setName('kimai:user:demote')
->setAliases(['fos:user:demote'])
->setDescription('Demote a user by removing a role')
->setHelp(
<<<'EOT'
The <info>kimai:user:demote</info> command demotes a user by removing a role
The <info>kimai:user:demote</info> command demotes a user by removing a role
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
<info>php %command.full_name% --super susan_super</info>
EOT
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
<info>php %command.full_name% --super susan_super</info>
EOT
);
}
@@ -41,7 +38,7 @@ EOT
*/
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
{
$username = $user->getUsername();
$username = $user->getUserIdentifier();
if ($super) {
if ($user->isSuperAdmin()) {
$user->setSuperAdmin(false);

View File

@@ -10,7 +10,6 @@
namespace App\Command;
use App\Export\ServiceExport;
use App\Mail\KimaiMailer;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ExportQuery;
@@ -19,6 +18,7 @@ use App\Repository\TeamRepository;
use App\Repository\UserRepository;
use App\Timesheet\DateTimeFactory;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -27,35 +27,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ExportCreateCommand extends Command
#[AsCommand(name: 'kimai:export:create')]
final class ExportCreateCommand extends Command
{
private $serviceExport;
private $customerRepository;
private $projectRepository;
private $teamRepository;
private $userRepository;
private $translator;
private $mailer;
public function __construct(
ServiceExport $serviceExport,
CustomerRepository $customerRepository,
ProjectRepository $projectRepository,
TeamRepository $teamRepository,
UserRepository $userRepository,
TranslatorInterface $translator,
KimaiMailer $mailer
private ServiceExport $serviceExport,
private CustomerRepository $customerRepository,
private ProjectRepository $projectRepository,
private TeamRepository $teamRepository,
private UserRepository $userRepository,
private TranslatorInterface $translator,
private MailerInterface $mailer
) {
$this->serviceExport = $serviceExport;
$this->customerRepository = $customerRepository;
$this->projectRepository = $projectRepository;
$this->teamRepository = $teamRepository;
$this->userRepository = $userRepository;
$this->translator = $translator;
$this->mailer = $mailer;
parent::__construct();
}
@@ -65,7 +52,6 @@ class ExportCreateCommand extends Command
protected function configure()
{
$this
->setName('kimai:export:create')
->setDescription('Create exports')
->setHelp('Create exports by several different filters and sent them via email.')
->addOption('username', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the export (e.g. used for permissions and decimal setting)')
@@ -107,7 +93,7 @@ class ExportCreateCommand extends Command
default:
$io->error('Unknown "exported" filter given');
return 1;
return Command::FAILURE;
}
$locale = $input->getOption('locale');
@@ -151,18 +137,18 @@ class ExportCreateCommand extends Command
if ($template === null) {
$io->error('You must pass the "template" option');
return 1;
return Command::FAILURE;
}
$renderer = $this->serviceExport->getRendererById($template);
if ($renderer === null) {
$io->error('Unknown export "template", available are:');
$rows = [];
foreach ($this->serviceExport->getRenderer() as $renderer) {
$rows[] = [$renderer->getId()];
foreach ($this->serviceExport->getRenderer() as $tmp) {
$rows[] = [$tmp->getId()];
}
$io->table(['ID'], $rows);
return 1;
return Command::FAILURE;
}
$start = $input->getOption('start');
@@ -172,7 +158,7 @@ class ExportCreateCommand extends Command
} catch (\Exception $ex) {
$io->error('Invalid start date given');
return 1;
return Command::FAILURE;
}
}
if (!$start instanceof \DateTime) {
@@ -187,7 +173,7 @@ class ExportCreateCommand extends Command
} catch (\Exception $ex) {
$io->error('Invalid end date given');
return 1;
return Command::FAILURE;
}
}
@@ -195,10 +181,6 @@ class ExportCreateCommand extends Command
$end = $dateFactory->getEndOfMonth($start);
}
if (!$end instanceof \DateTime) {
$end = $dateFactory->getEndOfMonth();
}
$end->setTime(23, 59, 59);
$directory = rtrim(sys_get_temp_dir(), '/') . '/';
@@ -209,7 +191,7 @@ class ExportCreateCommand extends Command
if (!is_dir($directory) || !is_writable($directory)) {
$io->error('Invalid "directory" given: ' . $directory);
return 1;
return Command::FAILURE;
}
$subject = 'Export data available';
@@ -223,7 +205,7 @@ class ExportCreateCommand extends Command
if ($result === false) {
$io->error('Invalid "email" given: ' . $email);
return 1;
return Command::FAILURE;
}
$emails[] = $email;
}
@@ -240,14 +222,15 @@ class ExportCreateCommand extends Command
$query = new ExportQuery();
$username = $input->getOption('username');
if (!empty($username)) {
$user = $this->userRepository->loadUserByUsername($username);
if (null === $user) {
if (\is_string($username) && !empty($username)) {
try {
$user = $this->userRepository->loadUserByIdentifier($username);
} catch(\Exception) {
$io->error(
sprintf('The given username "%s" could not be resolved', $username)
);
return 1;
return Command::FAILURE;
}
$query->setCurrentUser($user);
}
@@ -268,7 +251,7 @@ class ExportCreateCommand extends Command
if (\count($entries) === 0) {
$io->success('No entries found, skipping');
return 0;
return Command::SUCCESS;
}
$response = $renderer->render($entries, $query);
@@ -299,7 +282,7 @@ class ExportCreateCommand extends Command
$io->success('Saved export to: ' . $file);
}
return 0;
return Command::SUCCESS;
}
private function savePreview(Response $response, string $directory): string

View File

@@ -1,167 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Importer\ImporterService;
use App\Importer\ImportNotFoundException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* This command can change anytime, don't rely on its API for the future!
*/
class ImportCustomerCommand extends Command
{
private $importer;
public function __construct(ImporterService $importer)
{
parent::__construct();
$this->importer = $importer;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:import:customer')
->setDescription('Import customer from CSV file')
->setHelp(
'Import customers from a CSV file.' . PHP_EOL .
'Customer will be matched by name or number, and if not found created on the fly.' . PHP_EOL
)
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
->addOption('importer', null, InputOption::VALUE_REQUIRED, 'The importer to use (supported: default, grandtotal)', 'default')
->addOption('reader', null, InputOption::VALUE_REQUIRED, 'The reader to use (supported: csv, csv-semicolon)', 'csv')
->addOption('no-update', null, InputOption::VALUE_NONE, 'If you want to create new customers, but not update existing ones')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai importer: Customers');
$skipUpdate = $input->getOption('no-update');
$doImport = true;
$row = 1;
$errors = 0;
$customers = [];
$importer = null;
try {
$importer = $this->importer->getCustomerImporter($input->getOption('importer'));
$reader = $this->importer->getReader($input->getOption('reader'));
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return 1;
}
$importerFile = $input->getArgument('file');
try {
$records = $reader->read($importerFile);
} catch (ImportNotFoundException $ex) {
$io->error('File not existing or not readable: ' . $importerFile);
return 2;
}
$amount = iterator_count($records);
$records->rewind();
$io->text(sprintf('Found %s rows to process, converting now ...', $amount));
$progressBar = new ProgressBar($output, $amount);
foreach ($records as $record) {
try {
$customers[] = $importer->convertEntryToCustomer($record);
} catch (\Exception $ex) {
$io->error(sprintf('Invalid row %s: %s', $row, $ex->getMessage()));
$doImport = false;
$errors++;
}
$progressBar->advance();
$row++;
}
$progressBar->finish();
$io->writeln('');
if (!$doImport) {
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
return 3;
}
$amount = \count($customers);
$io->text(sprintf('Converted %s customers, importing into Kimai now ...', $amount));
$progressBar = new ProgressBar($output, $amount);
$created = 0;
$updated = 0;
$noUpdatedCustomers = 0;
foreach ($customers as $customer) {
try {
$progressBar->advance();
if ($customer->getId() === null) {
$this->importer->importCustomer($customer);
$created++;
} elseif ($skipUpdate === false) {
$this->importer->importCustomer($customer);
$updated++;
} else {
$noUpdatedCustomers++;
}
} catch (\Exception $ex) {
$io->error(sprintf('Failed importing customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
return 4;
}
}
$progressBar->finish();
$io->writeln('');
$io->writeln('');
if ($created > 0) {
$io->success(sprintf('Imported %s customer', $created));
}
if ($updated > 0) {
$io->success(sprintf('Updated %s customer', $updated));
}
if ($noUpdatedCustomers > 0) {
$io->success(sprintf('Skipped %s existing customer', $noUpdatedCustomers));
}
if ($updated === 0 && $created === 0) {
$io->text('Nothing was imported');
}
return 0;
}
}

View File

@@ -1,252 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Entity\Team;
use App\Importer\ImporterService;
use App\Importer\ImportNotFoundException;
use App\Repository\TeamRepository;
use App\Repository\UserRepository;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* This command can change anytime, don't rely on its API for the future!
*/
class ImportProjectCommand extends Command
{
private $importerService;
private $teams;
private $users;
public function __construct(ImporterService $importerService, TeamRepository $teams, UserRepository $users)
{
parent::__construct();
$this->importerService = $importerService;
$this->teams = $teams;
$this->users = $users;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:import:project')
->setDescription('Import projects from CSV file')
->setHelp(
'Import projects from a CSV file, creating customers (if not existing) and optional empty teams for each project.' . PHP_EOL .
'Imported customer will be matched by name and optionally created on the fly.' . PHP_EOL
)
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
->addOption('importer', null, InputOption::VALUE_REQUIRED, 'The importer to use (supported: default)', 'default')
->addOption('reader', null, InputOption::VALUE_REQUIRED, 'The reader to use (supported: csv, csv-semicolon)', 'csv')
->addOption('teamlead', null, InputOption::VALUE_REQUIRED, 'If you want to create empty teams for each project, give the username of the teamlead to be assigned')
->addOption('no-update', null, InputOption::VALUE_NONE, 'If you want to create new project, but not update existing ones')
->addOption('date-format', null, InputOption::VALUE_REQUIRED, 'Date format for imports', 'Y-m-d')
->addOption('timezone', null, InputOption::VALUE_REQUIRED, 'Timezone for imports', date_default_timezone_get())
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai importer: Projects');
// validate teamlead
$teamlead = $input->getOption('teamlead');
if (null !== $teamlead) {
$tmpUser = $this->users->findOneBy(['username' => $teamlead]);
if ($tmpUser === null) {
$tmpUser = $this->users->findOneBy(['email' => $teamlead]);
if ($tmpUser === null) {
$io->error(
sprintf(
'You requested to create empty teams for each project, but the given teamlead cannot be found.' . PHP_EOL .
'Please create a user with the name (or email) %s first, before continuing.' . PHP_EOL,
$teamlead
)
);
return 3;
}
}
$teamlead = $tmpUser;
}
$skipUpdate = $input->getOption('no-update');
$doImport = true;
$row = 1;
$errors = 0;
$projects = [];
try {
$importer = $this->importerService->getProjectImporter($input->getOption('importer'));
$reader = $this->importerService->getReader($input->getOption('reader'));
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return 1;
}
$importerFile = $input->getArgument('file');
$io->text('Reading import file ...');
try {
$records = $reader->read($importerFile);
} catch (ImportNotFoundException $ex) {
$io->error('File not existing or not readable: ' . $importerFile);
return 2;
}
$amount = iterator_count($records);
$records->rewind();
$io->text(sprintf('Found %s rows to process, converting now ...', $amount));
$progressBar = new ProgressBar($output, $amount);
$options = [];
if (null !== ($dateFormat = $input->getOption('date-format'))) {
$options['dateformat'] = $dateFormat;
}
if (null !== ($timezone = $input->getOption('timezone'))) {
$options['timezone'] = $timezone;
}
foreach ($records as $record) {
try {
$projects[] = $importer->convertEntryToProject($record, $options);
} catch (\Exception $ex) {
$io->error(sprintf('Invalid row %s: %s', $row, $ex->getMessage()));
$doImport = false;
$errors++;
}
$progressBar->advance();
$row++;
}
$progressBar->finish();
$io->writeln('');
if (!$doImport) {
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
return 3;
}
$createdProjects = 0;
$updatedProjects = 0;
$noUpdatedProjects = 0;
$createdCustomers = 0;
$createdTeams = 0;
$amount = \count($projects);
$io->text(sprintf('Converted %s projects, importing into Kimai now ...', $amount));
$progressBar = new ProgressBar($output, $amount);
foreach ($projects as $project) {
$progressBar->advance();
try {
if ($project->getCustomer()->getId() === null) {
$this->importerService->importCustomer($project->getCustomer());
$createdCustomers++;
}
$createTeam = false;
if ($project->getId() === null) {
$this->importerService->importProject($project);
$createdProjects++;
$createTeam = (null !== $teamlead);
} elseif ($skipUpdate === false) {
$this->importerService->importProject($project);
$updatedProjects++;
} else {
$noUpdatedProjects++;
}
if (!$createTeam) {
continue;
}
$team = new Team();
$team->setName($project->getName());
$team->addTeamlead($teamlead);
$this->teams->saveTeam($team);
$project->addTeam($team);
$team->addProject($project);
$this->teams->saveTeam($team);
$createdTeams++;
} catch (ValidationFailedException $ex) {
$io->error(sprintf('Failed importing project "%s" with: %s', $project->getName(), $ex->getMessage()));
for ($i = 0; $i < $ex->getViolations()->count(); $i++) {
$violation = $ex->getViolations()->get($i);
$io->error(sprintf('Failed validating field "%s" with value "%s": %s', $violation->getPropertyPath(), $violation->getInvalidValue(), $violation->getMessage()));
}
return 4;
} catch (\Exception $ex) {
$io->error(sprintf('Failed importing project "%s" with: %s', $project->getName(), $ex->getMessage()));
return 4;
}
}
$progressBar->finish();
$io->writeln('');
$io->writeln('');
if ($createdCustomers === 0 && $updatedProjects === 0 && $createdProjects === 0) {
if ($noUpdatedProjects > 0) {
$io->success(sprintf('Skipped %s existing projects', $noUpdatedProjects));
} else {
$io->text('Nothing was imported');
}
} else {
if ($createdCustomers > 0) {
$io->success(sprintf('Imported %s customers', $createdCustomers));
}
if ($updatedProjects > 0) {
$io->success(sprintf('Updated %s projects', $updatedProjects));
}
if ($noUpdatedProjects > 0) {
$io->success(sprintf('Skipped %s existing projects', $noUpdatedProjects));
}
if ($createdProjects > 0) {
$io->success(sprintf('Imported %s projects', $createdProjects));
}
if ($createdTeams > 0) {
$io->success(sprintf('Created %s teams', $createdTeams));
}
}
return 0;
}
}

View File

@@ -1,726 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Configuration\SystemConfiguration;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Importer\InvalidFieldsException;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\TagRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Utils\Duration;
use League\Csv\Reader;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
/**
* This command can change anytime, don't rely on its API for the future!
*
* @internal
* @codeCoverageIgnore
*/
class ImportTimesheetCommand extends Command
{
protected static $defaultName = 'kimai:import:timesheet';
// if we use 00:00 we might run into summer/winter time problems which happen between 02:00 and 03:00
public const DEFAULT_BEGIN = '04:00';
public const DEFAULT_CUSTOMER = 'Imported customer - %s';
private static $supportedHeader = [
'Date',
'From',
'To',
'Duration',
'Rate',
'User',
'Customer',
'Project',
'Activity',
'Description',
'Exported',
'Tags',
'Hourly rate',
'Fixed rate',
];
private $customers;
private $projects;
private $activities;
private $users;
private $tagRepository;
private $timesheets;
private $configuration;
private $encoder;
/**
* @var Customer
*/
private $customerFallback;
/**
* @var Customer[]
*/
private $customerCache = [];
/**
* @var Project[]
*/
private $projectCache = [];
/**
* @var User[]
*/
private $userCache = [];
/**
* @var Tag[]
*/
private $tagCache = [];
/**
* Comment that will be added to new customers, projects and activities.
*
* @var string
*/
private $comment = '';
/**
* The datetime of this import as formatted string.
*
* @var string
*/
private $dateTime = '';
/**
* @var string
*/
private $begin = self::DEFAULT_BEGIN;
// some statistics to display to the user
private $createdProjects = 0;
private $createdUsers = 0;
private $createdCustomers = 0;
private $createdActivities = 0;
public function __construct(
CustomerRepository $customers,
ProjectRepository $projects,
ActivityRepository $activities,
UserRepository $users,
TagRepository $tagRepository,
TimesheetRepository $timesheets,
SystemConfiguration $configuration,
UserPasswordEncoderInterface $encoder
) {
parent::__construct();
$this->customers = $customers;
$this->projects = $projects;
$this->activities = $activities;
$this->users = $users;
$this->tagRepository = $tagRepository;
$this->timesheets = $timesheets;
$this->configuration = $configuration;
$this->encoder = $encoder;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName(self::$defaultName)
->setDescription('Import timesheets from CSV file')
->setHelp(
'This command allows to import timesheets from a CSV file, which are formatted like CSV exports.' . PHP_EOL .
'Imported customer, projects and activities will be matched by name.' . PHP_EOL .
'Supported columns names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
)
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'The timezone to be used. Supports: "valid timezone names", the string "user" (using the configured users timezone) and the string "server" (PHP default timezone)', 'user')
->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'A customer ID or name to assign for empty entries. Defaults to creating a new customer which is used for all un-linked projects')
->addOption('activity', null, InputOption::VALUE_OPTIONAL, 'Whether new activities should be "global" or "project" specific. Allowed values are "global" and "project"', 'project')
->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
->addOption('begin', null, InputOption::VALUE_OPTIONAL, 'Default begin if none was provided in the format HH:MM', self::DEFAULT_BEGIN)
->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers, projects and activities. %s will be replaced with the current datetime', 'Created by import at %s')
->addOption('create-users', null, InputOption::VALUE_NONE, 'If set, accounts for not found users will be created')
->addOption('ignore-errors', null, InputOption::VALUE_NONE, 'If set, invalid rows will be skipped')
->addOption('batch', null, InputOption::VALUE_NONE, 'If set, timesheets will be written in batches of 100')
->addOption('domain', null, InputOption::VALUE_OPTIONAL, 'Domain name used for email addresses of new created users. If provided usernames already include a domain, this option will be skipped.', 'example.com')
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password for new created users.', 'password')
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai importer: Timesheets');
$csvFile = $input->getArgument('file');
if (!file_exists($csvFile)) {
$io->error('File not existing: ' . $csvFile);
return 1;
}
if (!is_readable($csvFile)) {
$io->error('File cannot be read: ' . $csvFile);
return 2;
}
$this->dateTime = (new \DateTime())->format('Y.m.d H:i');
$this->comment = sprintf($input->getOption('comment'), $this->dateTime);
$this->begin = $input->getOption('begin');
$timezone = $input->getOption('timezone');
switch ($timezone) {
case 'server':
$timezone = new \DateTimeZone(date_default_timezone_get());
break;
case 'user':
// null means fetch from user
$timezone = null;
break;
default:
try {
if (!\in_array($timezone, \DateTimeZone::listIdentifiers())) {
throw new \InvalidArgumentException('Not a known PHP timezone');
}
$timezone = new \DateTimeZone($timezone);
} catch (\Exception $ex) {
$io->error('Invalid timezone given, import canceled.');
return 3;
}
break;
}
$activityType = $input->getOption('activity');
$allowedActivityTypes = ['project', 'global'];
if (!\in_array($activityType, $allowedActivityTypes)) {
$io->error(sprintf('Invalid activity type "%s" given, allowed values are: %s', $activityType, implode(', ', $allowedActivityTypes)));
return 4;
}
$csv = Reader::createFromPath($csvFile, 'r');
$csv->setDelimiter($input->getOption('delimiter'));
$csv->setHeaderOffset(0);
$header = $csv->getHeader();
if (!$this->validateHeader($header)) {
$io->error(
sprintf(
'Found invalid CSV. The header: ' . PHP_EOL .
'%s' . PHP_EOL .
'did not match the expected structure: ' . PHP_EOL .
'%s',
implode(', ', $header),
implode(', ', self::$supportedHeader)
)
);
return 5;
}
$all = $csv->getRecords();
$total = iterator_count($all);
$io->text(sprintf('Found %s timesheets to import, pre-validating now', $total));
$records = [];
$doImport = true;
$row = 1;
$errors = 0;
$createUsers = $input->getOption('create-users');
$ignoreErrors = $input->getOption('ignore-errors');
// ======================= validate rows =======================
$progressBar = new ProgressBar($output, $total);
$countAll = 0;
foreach ($all as $record) {
$this->convertRow($record);
try {
$this->validateRow($record);
} catch (InvalidFieldsException $ex) {
$io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
$doImport = false;
$errors++;
}
if (!$createUsers) {
if (null === $this->getUser($record['User'])) {
if (!$ignoreErrors) {
$io->error(sprintf('Unknown user %s in row %s', $record['User'], $row));
}
$doImport = false;
$errors++;
}
}
$row++;
if ($doImport) {
$records[] = $record;
}
$countAll++;
$progressBar->advance();
}
$progressBar->finish();
$io->writeln('');
if (!$ignoreErrors && !$doImport) {
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
return 5;
}
$io->writeln('');
$io->text(sprintf('Processing %s of %s rows, skipping %s with pre-validation errors.', \count($records), iterator_count($all), $errors));
// values for new users
$password = $input->getOption('password');
$domain = $input->getOption('domain');
$progressBar = new ProgressBar($output, \count($records));
$durationParser = new Duration();
$row = 0;
$imported = 0;
$failed = 0;
$isBatchUpdate = $input->getOption('batch');
$batches = [];
foreach ($records as $record) {
$row++;
try {
$project = $this->getProject($record['Project'], $record['Customer'], $input->getOption('customer'));
$activity = $this->getActivity($record['Activity'], $project, $activityType);
$user = $this->getUser($record['User']);
if (null === $user) {
$user = $this->createUser($record['User'], $domain, $password);
}
$begin = null;
$end = null;
$duration = 0;
if (!empty($record['Duration'])) {
if (\is_int($record['Duration'])) {
$duration = $record['Duration'];
} else {
$duration = $durationParser->parseDurationString($record['Duration']);
}
}
if (null === $timezone) {
$timezone = new \DateTimeZone($user->getTimezone());
}
if (empty($record['From']) && empty($record['To'])) {
$begin = new \DateTime($record['Date'] . ' ' . $this->begin, $timezone);
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
} elseif (empty($record['From'])) {
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
$begin = (new \DateTime())->setTimezone($timezone)->setTimestamp($end->getTimestamp() - $duration);
} elseif (empty($record['To'])) {
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
} else {
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
// fix dates, which are running over midnight
if ($end < $begin) {
if ($duration > 0) {
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
} else {
$end->add(new \DateInterval('P1D'));
}
}
}
$timesheet = new Timesheet();
$timesheet->setActivity($activity);
$timesheet->setProject($project);
$timesheet->setBegin($begin);
$timesheet->setEnd($end);
$timesheet->setUser($user);
$timesheet->setDescription($record['Description']);
$timesheet->setExported((bool) $record['Exported']);
if (!empty($record['Tags'])) {
foreach (explode(',', $record['Tags']) as $tagName) {
if (empty($tagName)) {
continue;
}
$tag = $this->getTag($tagName);
$timesheet->addTag($tag);
}
}
if (!empty($record['Rate'])) {
$timesheet->setRate($record['Rate']);
}
if (!empty($record['Hourly rate'])) {
$timesheet->setHourlyRate($record['Hourly rate']);
}
if (!empty($record['Fixed rate'])) {
$timesheet->setFixedRate($record['Fixed rate']);
}
if ($isBatchUpdate) {
$batches[] = $timesheet;
if ($row % 100 === 0) {
$this->timesheets->saveMultiple($batches);
$batches = [];
}
} else {
$this->timesheets->save($timesheet);
}
$imported++;
} catch (\Exception $ex) {
$io->error(sprintf('Failed importing timesheet row %s with: %s', $row, $ex->getMessage()));
$failed++;
}
$progressBar->advance();
}
if ($isBatchUpdate && \count($batches) > 0) {
$this->timesheets->saveMultiple($batches);
}
$progressBar->finish();
$io->writeln('');
$io->writeln('');
if ($this->createdUsers > 0) {
$io->success(sprintf('Created %s users', $this->createdUsers));
}
if ($this->createdCustomers > 0) {
$io->success(sprintf('Created %s customers', $this->createdCustomers));
}
if ($this->createdProjects > 0) {
$io->success(sprintf('Created %s projects', $this->createdProjects));
}
if ($this->createdActivities > 0) {
$io->success(sprintf('Created %s activities', $this->createdActivities));
}
if ($failed > 0) {
$io->warning(sprintf('Failed validating %s rows', $failed));
}
if ($imported > 0) {
$io->success(sprintf('Imported %s rows', $imported));
}
return 0;
}
private function createUser($username, $domain, $password): User
{
$user = new User();
$user->setUsername($username);
if (stripos($username, '@') === false) {
$email = preg_replace('/[[:^print:]]/', '', $username) . '@' . $domain;
$email = strtolower($email);
} else {
$email = $username;
}
$user->setEmail($email);
$user->setPassword($this->encoder->encodePassword($user, $password));
$this->users->saveUser($user);
$this->createdUsers++;
$this->userCache[$username] = $user;
return $user;
}
private function getUser($user): ?User
{
if (!\array_key_exists($user, $this->userCache)) {
$tmpUser = $this->users->findOneBy(['username' => $user]);
if (null === $tmpUser) {
$tmpUser = $this->users->findOneBy(['email' => $user]);
if (null === $tmpUser) {
return null;
}
}
$this->userCache[$user] = $tmpUser;
}
return $this->userCache[$user];
}
private function getTag(string $tagName): Tag
{
if (\array_key_exists($tagName, $this->tagCache)) {
return $this->tagCache[$tagName];
}
$tag = $this->tagRepository->findTagByName($tagName);
if ($tag === null) {
$tag = (new Tag())->setName($tagName);
}
$this->tagCache[$tagName] = $tag;
return $this->tagCache[$tagName];
}
private function getActivity($activity, Project $project, $activityType): Activity
{
$tmpActivity = null;
$tmpActivities = $this->activities->findBy(['project' => $project->getId(), 'name' => $activity]);
if (\count($tmpActivities) === 0) {
$tmpActivity = $this->activities->findOneBy(['project' => null, 'name' => $activity]);
} elseif (\count($tmpActivities) === 1) {
$tmpActivity = $tmpActivities[0];
}
if (null === $tmpActivity) {
$tmpActivity = new Activity();
$tmpActivity->setName($activity);
$tmpActivity->setComment($this->comment);
if ($activityType === 'project') {
$tmpActivity->setProject($project);
}
$this->activities->saveActivity($tmpActivity);
$this->createdActivities++;
}
return $tmpActivity;
}
private function getProject($project, $customer, $fallbackCustomer): Project
{
$cacheKey = $project . '_____' . $customer;
if (!\array_key_exists($cacheKey, $this->projectCache)) {
$tmpCustomer = $this->getCustomer($customer, $fallbackCustomer);
/** @var Project $tmpProject */
$tmpProject = null;
/** @var Project[] $tmpProjects */
$tmpProjects = $this->projects->findBy(['name' => $project]);
if (\count($tmpProjects) > 1) {
/** @var Project $prj */
foreach ($tmpProjects as $prj) {
if (strcasecmp($prj->getCustomer()->getName(), $tmpCustomer->getName()) !== 0) {
continue;
}
$tmpProject = $prj;
break;
}
} elseif (\count($tmpProjects) === 1) {
$tmpProject = $tmpProjects[0];
}
if (null !== $tmpProject) {
if (strcasecmp($tmpProject->getCustomer()->getName(), $tmpCustomer->getName()) !== 0) {
$tmpProject = null;
}
}
if ($tmpProject === null) {
$tmpProject = new Project();
$tmpProject->setName($project);
$tmpProject->setComment($this->comment);
$tmpProject->setCustomer($tmpCustomer);
$this->projects->saveProject($tmpProject);
$this->createdProjects++;
}
$this->projectCache[$cacheKey] = $tmpProject;
}
return $this->projectCache[$cacheKey];
}
private function getCustomer($customer, $fallback): Customer
{
if (!empty($customer)) {
if (!\array_key_exists($customer, $this->customerCache)) {
$tmpCustomer = $this->customers->findBy(['name' => $customer]);
if (\count($tmpCustomer) > 1) {
throw new \Exception(sprintf('Found multiple customers with the name: %s', $customer));
} elseif (\count($tmpCustomer) === 1) {
$tmpCustomer = $tmpCustomer[0];
}
if ($tmpCustomer instanceof Customer) {
$this->customerCache[$customer] = $tmpCustomer;
}
}
if (\array_key_exists($customer, $this->customerCache)) {
return $this->customerCache[$customer];
}
}
if (null !== $this->customerFallback && !empty($fallback)) {
return $this->customerFallback;
}
$tmpFallback = null;
if (!empty($fallback)) {
if (is_numeric($fallback)) {
$tmpFallback = $this->customers->find((int) $fallback);
} else {
/** @var Customer|null $tmpFallback */
$tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
}
}
if (null === $tmpFallback) {
$newName = $customer;
if (empty($customer)) {
$newName = self::DEFAULT_CUSTOMER;
if (!empty($fallback) && \is_string($fallback)) {
$newName = $fallback;
}
}
$tmpFallback = new Customer();
$tmpFallback->setName(sprintf($newName, $this->dateTime));
$tmpFallback->setComment($this->comment);
$tmpFallback->setCountry($this->configuration->getCustomerDefaultCountry());
$timezone = date_default_timezone_get();
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
$timezone = $this->configuration->getCustomerDefaultTimezone();
}
$tmpFallback->setTimezone($timezone);
$this->customers->saveCustomer($tmpFallback);
$this->createdCustomers++;
}
$this->customerFallback = $tmpFallback;
return $this->customerFallback;
}
/**
* @param array $row
* @return bool
* @throws InvalidFieldsException
*/
private function validateRow(array $row)
{
$fields = [];
if (empty($row['Project'])) {
$fields[] = 'Project';
}
if (empty($row['Activity'])) {
$fields[] = 'Activity';
}
if (empty($row['Date'])) {
$fields[] = 'Date';
}
if ((empty($row['From']) || empty($row['To'])) && empty($row['Duration'])) {
$fields[] = 'Duration';
}
if (!empty($fields)) {
throw new InvalidFieldsException($fields);
}
return true;
}
private function validateHeader(array $header)
{
$result = array_diff(self::$supportedHeader, $header);
return empty($result);
}
/**
* Add project specific conversion logic here
*
* @param array $row
*/
private function convertRow(array &$row)
{
// negative durations
if ($row['Duration'][0] === '-') {
$row['Duration'] = substr($row['Duration'], 1);
}
if (!\array_key_exists('Tags', $row)) {
$row['Tags'] = null;
}
if (empty($row['Date'])) {
$row['Date'] = '1970-01-01';
}
if (!\array_key_exists('Exported', $row)) {
$row['Exported'] = false;
}
if (!\array_key_exists('Rate', $row)) {
$row['Rate'] = null;
}
if (!\array_key_exists('Hourly rate', $row)) {
$row['Hourly rate'] = null;
}
if (!\array_key_exists('Fixed rate', $row)) {
$row['Fixed rate'] = null;
}
if (!empty($row['From'])) {
$len = \strlen($row['From']);
if ($len === 1) {
$row['From'] = '0' . $row['From'] . ':00';
} elseif ($len == 2) {
$row['From'] = $row['From'] . ':00';
}
}
if (!empty($row['To'])) {
$len = \strlen($row['To']);
if ($len === 1) {
$row['To'] = '0' . $row['To'] . ':00';
} elseif ($len == 2) {
$row['To'] = $row['To'] . ':00';
}
}
}
}

View File

@@ -11,7 +11,7 @@ namespace App\Command;
use App\Constants;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
@@ -20,69 +20,42 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\KernelInterface;
/**
* Command used to do the basic installation steps for Kimai.
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:install')]
final class InstallCommand extends Command
{
public const ERROR_PERMISSIONS = 1;
public const ERROR_CACHE_CLEAN = 2;
public const ERROR_CACHE_WARMUP = 4;
public const ERROR_DATABASE = 8;
public const ERROR_MIGRATIONS = 32;
/**
* @var Connection
*/
private $connection;
public function __construct(Connection $connection)
public function __construct(private Connection $connection, private string $kernelEnvironment)
{
parent::__construct();
$this->connection = $connection;
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:install')
->setDescription('Basic installation for Kimai')
->setHelp('This command will perform the basic installation steps to get Kimai up and running.')
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache re-generation')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai installation running ...');
/** @var Application $application */
$application = $this->getApplication();
/** @var KernelInterface $kernel */
$kernel = $application->getKernel();
$environment = $kernel->getEnvironment();
// create the database, in case it is not yet existing
try {
$this->createDatabase($io, $input, $output);
} catch (\Exception $ex) {
$io->error('Failed to create database: ' . $ex->getMessage());
return self::ERROR_DATABASE;
return Command::FAILURE;
}
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
@@ -91,22 +64,22 @@ final class InstallCommand extends Command
} catch (\Exception $ex) {
$io->error('Failed to set migration status: ' . $ex->getMessage());
return self::ERROR_MIGRATIONS;
return Command::FAILURE;
}
if (!$input->getOption('no-cache')) {
// flush the cache, just to make sure ... and ignore result
$this->rebuildCaches($environment, $io, $input, $output);
$this->rebuildCaches($this->kernelEnvironment, $io, $input, $output);
}
$io->success(
sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION)
);
return 0;
return Command::SUCCESS;
}
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
{
$io->text('Rebuilding your cache, please be patient ...');
@@ -116,7 +89,7 @@ final class InstallCommand extends Command
} catch (\Exception $ex) {
$io->error('Failed to clear cache: ' . $ex->getMessage());
return self::ERROR_CACHE_CLEAN;
return Command::FAILURE;
}
$command = $this->getApplication()->find('cache:warmup');
@@ -125,13 +98,13 @@ final class InstallCommand extends Command
} catch (\Exception $ex) {
$io->error('Failed to warmup cache: ' . $ex->getMessage());
return self::ERROR_CACHE_WARMUP;
return Command::FAILURE;
}
return 0;
return Command::SUCCESS;
}
protected function importMigrations(SymfonyStyle $io, OutputInterface $output)
private function importMigrations(SymfonyStyle $io, OutputInterface $output): void
{
$command = $this->getApplication()->find('doctrine:migrations:migrate');
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
@@ -141,16 +114,21 @@ final class InstallCommand extends Command
$io->writeln('');
}
protected function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output)
private function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output): void
{
if ($this->connection->isConnected()) {
$io->note(sprintf('Database is existing and connection could be established'));
try {
if ($this->connection->isConnected()) {
$io->note(sprintf('Database is existing and connection could be established'));
return;
}
return;
}
if (!$this->askConfirmation($input, $output, sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
throw new \Exception('Skipped database creation, aborting installation');
if (!$this->askConfirmation($input, $output, sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
throw new \Exception('Skipped database creation, aborting installation');
}
} catch (\Exception $exception) {
// this likely means that the database does not exist. the latest doctrine release
// changed the behavior: in previous version this code did not throw an exception.
}
$options = ['--if-not-exists' => true];
@@ -170,7 +148,7 @@ final class InstallCommand extends Command
* @param bool $default
* @return bool
*/
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false)
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false): bool
{
/** @var QuestionHelper $questionHelper */
$questionHelper = $this->getHelperSet()->get('question');

View File

@@ -22,6 +22,7 @@ use App\Repository\Query\TimesheetQuery;
use App\Repository\UserRepository;
use App\Timesheet\DateTimeFactory;
use App\Utils\SearchTerm;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
@@ -33,62 +34,26 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class InvoiceCreateCommand extends Command
#[AsCommand(name: 'kimai:invoice:create')]
final class InvoiceCreateCommand extends Command
{
/**
* @var ServiceInvoice
*/
private $serviceInvoice;
/**
* @var CustomerRepository
*/
private $customerRepository;
/**
* @var ProjectRepository
*/
private $projectRepository;
/**
* @var InvoiceTemplateRepository
*/
private $invoiceTemplateRepository;
/**
* @var UserRepository
*/
private $userRepository;
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
/**
* @var string|null
*/
private $previewDirectory;
private $previewUniqueFile = false;
private ?string $previewDirectory = null;
private bool $previewUniqueFile = false;
public function __construct(
ServiceInvoice $serviceInvoice,
CustomerRepository $customerRepository,
ProjectRepository $projectRepository,
InvoiceTemplateRepository $invoiceTemplateRepository,
UserRepository $userRepository,
EventDispatcherInterface $eventDispatcher
private ServiceInvoice $serviceInvoice,
private CustomerRepository $customerRepository,
private ProjectRepository $projectRepository,
private InvoiceTemplateRepository $invoiceTemplateRepository,
private UserRepository $userRepository,
private EventDispatcherInterface $eventDispatcher
) {
$this->serviceInvoice = $serviceInvoice;
$this->customerRepository = $customerRepository;
$this->projectRepository = $projectRepository;
$this->invoiceTemplateRepository = $invoiceTemplateRepository;
$this->userRepository = $userRepository;
$this->eventDispatcher = $eventDispatcher;
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:invoice:create')
->setDescription('Create invoices')
->setHelp('This command allows to create invoices by several different filters.')
->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices')
@@ -101,7 +66,6 @@ class InvoiceCreateCommand extends Command
->addOption('by-project', null, InputOption::VALUE_NONE, 'If set, one invoice for each active project in the given timerange is created')
->addOption('set-exported', null, InputOption::VALUE_NONE, 'Whether the invoice items should be marked as exported')
->addOption('template', null, InputOption::VALUE_OPTIONAL, 'Invoice template', null)
->addOption('template-meta', null, InputOption::VALUE_OPTIONAL, 'Fetch invoice template from a meta-field', null)
->addOption('search', null, InputOption::VALUE_OPTIONAL, 'Search term to filter invoice entries', null)
->addOption('exported', null, InputOption::VALUE_OPTIONAL, 'Exported filter for invoice entries (possible values: exported, all), by default only "not exported" items are fetched', null)
->addOption('preview', null, InputOption::VALUE_OPTIONAL, 'Absolute path for a rendered preview of the invoice, which will neither be saved nor the items be marked as exported.', null)
@@ -109,10 +73,7 @@ class InvoiceCreateCommand extends Command
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
@@ -122,16 +83,17 @@ class InvoiceCreateCommand extends Command
if (empty($username)) {
$io->error('You must set a "user" to create invoices');
return 1;
return Command::FAILURE;
}
$user = $this->userRepository->loadUserByUsername($username);
if (null === $user) {
try {
$user = $this->userRepository->loadUserByIdentifier($username);
} catch (\Exception $exception) {
$io->error(
sprintf('The given username "%s" could not be resolved', $username)
);
return 1;
return Command::FAILURE;
}
$exportedFilter = TimesheetQuery::STATE_NOT_EXPORTED;
@@ -150,7 +112,7 @@ class InvoiceCreateCommand extends Command
default:
$io->error('Unknown "exported" filter given');
return 1;
return Command::FAILURE;
}
$timezone = $input->getOption('timezone');
@@ -164,7 +126,7 @@ class InvoiceCreateCommand extends Command
if (!empty($input->getOption('start')) && empty($input->getOption('end'))) {
$io->error('You need to supply a end date if a start date was given');
return 1;
return Command::FAILURE;
}
$byActiveCustomer = $input->getOption('by-customer');
@@ -173,7 +135,7 @@ class InvoiceCreateCommand extends Command
if ($byActiveCustomer && $byActiveProject) {
$io->error('You cannot mix "by-customer" and "by-project"');
return 1;
return Command::FAILURE;
}
$customersIDs = $input->getOption('customer');
@@ -181,13 +143,7 @@ class InvoiceCreateCommand extends Command
if (!$byActiveCustomer && !$byActiveProject && empty($customersIDs) && empty($projectIDs)) {
$io->error('Could not determine generation mode, you need to set one of: customer, project, by-customer, by-project');
return 1;
}
if (null === $input->getOption('template') && null === $input->getOption('template-meta')) {
$io->error('You must either pass the "template" or "template-meta" option');
return 1;
return Command::FAILURE;
}
$start = $input->getOption('start');
@@ -197,7 +153,7 @@ class InvoiceCreateCommand extends Command
} catch (\Exception $ex) {
$io->error('Invalid start date given');
return 1;
return Command::FAILURE;
}
}
if (!$start instanceof \DateTime) {
@@ -212,7 +168,7 @@ class InvoiceCreateCommand extends Command
} catch (\Exception $ex) {
$io->error('Invalid end date given');
return 1;
return Command::FAILURE;
}
}
if (!$end instanceof \DateTime) {
@@ -227,12 +183,12 @@ class InvoiceCreateCommand extends Command
$markAsExported = false;
if ($input->getOption('preview') !== null) {
$this->previewUniqueFile = $input->getOption('preview-unique');
$this->previewUniqueFile = (bool) $input->getOption('preview-unique');
$this->previewDirectory = rtrim($input->getOption('preview'), '/') . '/';
if (!is_dir($this->previewDirectory) || !is_writable($this->previewDirectory)) {
$io->error('Invalid preview directory given');
return 1;
return Command::FAILURE;
}
} elseif ($input->getOption('set-exported')) {
$markAsExported = true;
@@ -245,7 +201,6 @@ class InvoiceCreateCommand extends Command
$defaultQuery->setEnd($end);
$defaultQuery->setCurrentUser($user);
$defaultQuery->setSearchTerm($searchTerm);
$defaultQuery->setMarkAsExported($markAsExported);
$defaultQuery->setExported($exportedFilter);
/** @var Invoice[] $invoices */
@@ -261,7 +216,7 @@ class InvoiceCreateCommand extends Command
if (null === $tmp) {
$io->error('Unknown customer ID: ' . $id);
return 1;
return Command::FAILURE;
}
$customers[] = $tmp;
}
@@ -276,7 +231,7 @@ class InvoiceCreateCommand extends Command
if (null === $tmp) {
$io->error('Unknown project ID: ' . $id);
return 1;
return Command::FAILURE;
}
$projects[] = $tmp;
}
@@ -290,7 +245,7 @@ class InvoiceCreateCommand extends Command
} else {
$io->error('Could not determine generation mode'); //-///9==8=//99/96//////-*/-*//96* <= by Ayumi
return 1;
return Command::FAILURE;
}
return $this->renderInvoiceResult($input, $output, $invoices);
@@ -312,11 +267,16 @@ class InvoiceCreateCommand extends Command
$invoices = [];
foreach ($projects as $project) {
$customer = $project->getCustomer();
if ($customer === null) {
throw new \Exception('Project has no customer: ' . $project->getId());
}
$query = clone $defaultQuery;
$query->addProject($project);
$query->addCustomer($project->getCustomer());
$query->addCustomer($customer);
$tpl = $this->getTemplateForProject($input, $project);
$tpl = $this->getTemplateForCustomer($input, $customer);
if (null === $tpl) {
$io->warning(sprintf('Could not find invoice template for project "%s", skipping!', $project->getName()));
continue;
@@ -325,9 +285,9 @@ class InvoiceCreateCommand extends Command
try {
if (null !== $this->previewDirectory) {
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($query, $this->eventDispatcher));
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher));
} else {
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
@@ -396,9 +356,9 @@ class InvoiceCreateCommand extends Command
try {
if (null !== $this->previewDirectory) {
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($query, $this->eventDispatcher));
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher));
} else {
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
@@ -421,7 +381,7 @@ class InvoiceCreateCommand extends Command
if (empty($invoices)) {
$io->warning('No invoice was generated');
return 0;
return Command::SUCCESS;
}
if (null !== $this->previewDirectory) {
@@ -437,7 +397,7 @@ class InvoiceCreateCommand extends Command
$table->render();
return 0;
return Command::SUCCESS;
}
$columns = ['ID', 'Customer', 'Total', 'Filename'];
@@ -465,56 +425,24 @@ class InvoiceCreateCommand extends Command
$table->render();
return 0;
return Command::SUCCESS;
}
private function getTemplateForCustomer(InputInterface $input, Customer $customer): ?InvoiceTemplate
{
$template = $input->getOption('template');
$meta = $input->getOption('template-meta');
if (!empty($meta)) {
$metaField = $customer->getMetaField($meta);
if (null !== $metaField && !empty($metaField->getValue())) {
$template = $metaField->getValue();
}
}
if (null === $template) {
return null;
return $customer->getInvoiceTemplate();
}
return $this->findTemplate($template);
}
private function findTemplate(string $idOrName): ?InvoiceTemplate
{
$tpl = $this->invoiceTemplateRepository->find($idOrName);
$tpl = $this->invoiceTemplateRepository->find($template);
if (null !== $tpl) {
return $tpl;
}
return $this->invoiceTemplateRepository->findOneBy(['name' => $idOrName]);
}
private function getTemplateForProject(InputInterface $input, Project $project): ?InvoiceTemplate
{
$template = $this->getTemplateForCustomer($input, $project->getCustomer());
$meta = $input->getOption('template-meta');
if (!empty($meta)) {
$metaField = $project->getMetaField($meta);
if (null !== $metaField && !empty($metaField->getValue())) {
$template = $metaField->getValue();
}
}
if (null === $template) {
return null;
}
return $this->findTemplate($template);
return $this->invoiceTemplateRepository->findOneBy(['name' => $template]);
}
/**

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
namespace App\Command;
use App\Plugin\PluginManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -18,35 +19,23 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to fetch plugin information.
*/
class PluginCommand extends Command
#[AsCommand(name: 'kimai:plugins')]
final class PluginCommand extends Command
{
/**
* @var PluginManager
*/
private $plugins;
public function __construct(PluginManager $plugins)
public function __construct(private PluginManager $plugins)
{
$this->plugins = $plugins;
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:plugins')
->setDescription('Receive plugin information')
->setHelp('This command prints detailed plugin information.')
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
@@ -54,7 +43,7 @@ class PluginCommand extends Command
if (empty($plugins)) {
$io->warning('No plugins installed');
return 0;
return Command::SUCCESS;
}
$rows = [];
@@ -70,6 +59,6 @@ class PluginCommand extends Command
}
$io->table(['Name', 'Version', 'Requires', 'Directory'], $rows);
return 0;
return Command::SUCCESS;
}
}

View File

@@ -11,37 +11,31 @@ namespace App\Command;
use App\Entity\User;
use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle;
class PromoteUserCommand extends AbstractRoleCommand
#[AsCommand(name: 'kimai:user:promote')]
final class PromoteUserCommand extends AbstractRoleCommand
{
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
parent::configure();
$this
->setName('kimai:user:promote')
->setAliases(['fos:user:promote'])
->setDescription('Promotes a user by adding a role')
->setHelp(
<<<'EOT'
The <info>kimai:user:promote</info> command promotes a user by adding a role
The <info>kimai:user:promote</info> command promotes a user by adding a role
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
<info>php %command.full_name% --super susan_super</info>
EOT
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
<info>php %command.full_name% --super susan_super</info>
EOT
);
}
/**
* {@inheritdoc}
*/
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
{
$username = $user->getUsername();
$username = $user->getUserIdentifier();
if ($super) {
if (!$user->isSuperAdmin()) {
$user->setSuperAdmin(true);

View File

@@ -0,0 +1,133 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Configuration\LocaleService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Intl\Locales;
/**
* Command used to create the locale definition.
*
* We do NOT calculate that on every system again, because we want to make sure that we have the same
* settings in every environment. Some environments (e.g. Github-Actions) have diverging settings from
* the local system.
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:reset:locales')]
final class RegenerateLocalesCommand extends Command
{
private string $defaultDate = 'dd.MM.y';
private string $defaultTime = 'HH:mm';
private array $rtlLocales = [
'ar' => true,
'fa' => true,
'he' => true,
];
public function __construct(private LocaleService $localeService, private string $projectDirectory, private string $kernelEnvironment)
{
parent::__construct();
}
public function isEnabled(): bool
{
return $this->kernelEnvironment !== 'prod';
}
protected function configure(): void
{
$this->setDescription('Regenerate the locale definition file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$locales = $this->localeService->getAllLocales();
// detect all registered locales and allow to choose them as well, so people get to
// choose the language for translation with the correct format of their location
/*
$secondLevel = [];
foreach (Locales::getLocales() as $locale) {
if (substr_count($locale, '_') === 1) {
$baseLocale = substr($locale, 0, strpos($locale, '_'));
if (in_array($baseLocale, $locales)) {
$subLocale = substr($locale, strpos($locale, '_') + 1);
if (!is_numeric($subLocale)) {
$secondLevel[] = $locale;
}
}
}
}
$locales = array_merge($locales, $secondLevel);
*/
$appLocales = [];
$defaults = [
'date' => $this->defaultDate,
'time' => $this->defaultTime,
'rtl' => false,
];
// make sure all allowed locales are registered
foreach ($locales as $locale) {
if (!Locales::exists($locale)) {
continue;
}
$appLocales[$locale] = $defaults;
}
// make sure all keys are registered for every locale
foreach ($appLocales as $locale => $settings) {
// these are completely new since v2
// calculate everything with IntlFormatter
$shortDate = new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE);
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT);
$settings['date'] = $shortDate->getPattern();
$settings['time'] = $shortTime->getPattern();
// make sure that sub-locales of a RTL language are also flagged as RTL
$rtlLocale = $locale;
if (substr_count($rtlLocale, '_') === 1) {
$rtlLocale = substr($rtlLocale, 0, strpos($rtlLocale, '_'));
}
if (\array_key_exists($rtlLocale, $this->rtlLocales)) {
$settings['rtl'] = $this->rtlLocales[$rtlLocale];
}
// pre-fill all formats with the default locale settings
$appLocales[$locale] = $settings;
}
ksort($appLocales);
$filename = 'config/locales.php';
$targetFile = $this->projectDirectory . DIRECTORY_SEPARATOR . $filename;
$content = '<?php return ' . var_export($appLocales, true) . ';';
$content = str_replace('array (', '[', $content);
$content = str_replace(')', ']', $content);
file_put_contents($targetFile, $content);
$io->success('Created new locale definition at: ' . $filename);
return Command::SUCCESS;
}
}

View File

@@ -9,26 +9,26 @@
namespace App\Command;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\KernelInterface;
/**
* Command used to update a Kimai installation.
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:reload')]
final class ReloadCommand extends Command
{
public const ERROR_CACHE_CLEAN = 2;
public const ERROR_CACHE_WARMUP = 4;
public const ERROR_LINT_CONFIG = 8;
public const ERROR_LINT_TRANSLATIONS = 16;
public function __construct(private string $projectDirectory, private string $kernelEnvironment)
{
parent::__construct();
}
/**
* Returns the base directory to the Kimai installation.
@@ -37,30 +37,18 @@ final class ReloadCommand extends Command
*/
protected function getRootDirectory(): string
{
/** @var Application $application */
$application = $this->getApplication();
return $application->getKernel()->getProjectDir();
return $this->projectDirectory;
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:reload')
->setDescription('Reload Kimai caches')
->setHelp('This command will validate the configurations and translations and then clear and rebuild the application cache.')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
@@ -82,7 +70,7 @@ final class ReloadCommand extends Command
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return self::ERROR_LINT_CONFIG;
return Command::FAILURE;
}
try {
@@ -97,14 +85,10 @@ final class ReloadCommand extends Command
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return self::ERROR_LINT_TRANSLATIONS;
return Command::FAILURE;
}
/** @var Application $application */
$application = $this->getApplication();
/** @var KernelInterface $kernel */
$kernel = $application->getKernel();
$environment = $kernel->getEnvironment();
$environment = $this->kernelEnvironment;
// flush the cache, in case values from the database are cached
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
@@ -122,17 +106,17 @@ final class ReloadCommand extends Command
]
);
return $cacheResult;
return (int) $cacheResult;
}
$io->success(
sprintf('Kimai config was reloaded')
);
return 0;
return Command::SUCCESS;
}
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
{
$io->text('Rebuilding your cache, please be patient ...');
@@ -144,7 +128,7 @@ final class ReloadCommand extends Command
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return self::ERROR_CACHE_CLEAN;
return Command::FAILURE;
}
$command = $this->getApplication()->find('cache:warmup');
@@ -155,9 +139,9 @@ final class ReloadCommand extends Command
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return self::ERROR_CACHE_WARMUP;
return Command::FAILURE;
}
return 0;
return Command::SUCCESS;
}
}

View File

@@ -9,7 +9,7 @@
namespace App\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -21,11 +21,12 @@ use Symfony\Component\Console\Output\OutputInterface;
* This is one of the cases where I don't feel like it is necessary to add tests, so lets "cheat" with:
* @codeCoverageIgnore
*/
class ResetDevelopmentCommand extends AbstractResetCommand
#[AsCommand(name: 'kimai:reset:dev', description: 'Resets the "development" environment')]
final class ResetDevelopmentCommand extends AbstractResetCommand
{
protected function getEnvName(): string
public function __construct(string $kernelEnvironment)
{
return 'dev';
parent::__construct($kernelEnvironment);
}
protected function loadData(InputInterface $input, OutputInterface $output): void

View File

@@ -17,6 +17,8 @@ use App\Entity\User;
use App\Entity\UserPreference;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -29,19 +31,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
* This is one of the cases where I don't feel like it is necessary to add tests, so lets "cheat" with:
* @codeCoverageIgnore
*/
class ResetTestCommand extends AbstractResetCommand
#[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')]
final class ResetTestCommand extends AbstractResetCommand
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, string $kernelEnvironment)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function getEnvName(): string
{
return 'test';
parent::__construct($kernelEnvironment);
}
protected function loadData(InputInterface $input, OutputInterface $output): void
@@ -54,13 +49,12 @@ class ResetTestCommand extends AbstractResetCommand
$activity->setBudget(1000);
$this->entityManager->persist($activity);
$customer = new Customer();
$customer = new Customer('Test');
$customer->setNumber('1');
$customer->setComment('Test comment');
$customer->setContact('Test');
$customer->setAddress('Test');
$customer->setCompany('Test');
$customer->setName('Test');
$customer->setCountry('DE');
$customer->setCurrency('EUR');
$customer->setPhone('111');
@@ -83,19 +77,174 @@ class ResetTestCommand extends AbstractResetCommand
$users = [
// 0=id, 1=hourly rate, 2=Alias, 3=registration date, 4=title, 5=avatar, 6=enabled, 7=password, 8=roles, 9=username, 10=username canonical, 11=email, 12=email canonical, 13=salt, 14=last login, 15=confirmation token, 16=password requested at, 17=api_token
[1, 53, 'Clara Haynes', '2018-02-06 23:28:57', 'CFO', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y', 1, '$2y$04$kKBYJ8sKCOhhakCjm9sCp.TQdwLTS1FPkPiWn2KBmaCA7xFL0NA42', ['ROLE_CUSTOMER'], 'clara_customer', 'clara_customer', 'clara_customer@example.com', 'clara_customer@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
[2, 82, 'John Doe', '2018-02-06 23:28:57', 'Developer', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', 1, '$2y$04$36P/xyhP6FbnfFYbXy7V0.ioSe8HjMlJQFYnlIzz2T6Agfi8ob6jK', [], 'john_user', 'john_user', 'john_user@example.com', 'john_user@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
[3, 35, 'Chris Deactive', '2018-02-06 23:28:57', 'Developer (left company)', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', 0, '$2y$04$MLtQBZ9JLzWu1Y01QnNjsuoLm8qC9XRkpUywf6DIbpd9OAL1mEcCi', [], 'chris_user', 'chris_user', 'chris_user@example.com', 'chris_user@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
[4, 35, 'Tony Maier', '2018-02-06 23:28:57', 'Head of Development', 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', 1, '$2y$04$rqxiiExfUVzIYRVL2x4JJumQWNPIG6PazXwrSJm/VQFEesR08Uj5i', ['ROLE_TEAMLEAD'], 'tony_teamlead', 'tony_teamlead', 'tony_teamlead@example.com', 'tony_teamlead@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
[5, 81, 'Anna Smith', '2018-02-06 23:28:57', 'Administrator', null, 1, '$2y$04$ct/rVb.naDzYZECnvfTJ2uns/zPHv8.8KcunhTjYFwWQeg1dywI8G', ['ROLE_ADMIN'], 'anna_admin', 'anna_admin', 'anna_admin@example.com', 'anna_admin@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
[6, 46, null, '2018-02-06 23:28:57', 'Super Administrator', '/bundles/avanzuadmintheme/img/avatar.png', 1, '$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2', ['ROLE_SUPER_ADMIN'], 'susan_super', 'susan_super', 'susan_super@example.com', 'susan_super@example.com', null, '2020-04-14 09:50:38', null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
[7, null, 'Test User 1', null, 'Quality Tester 1', null, 1, '$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2', [], 'test_user_1', 'test_user_1', 'test_user_1@example.com', 'test_user_1@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
[8, null, 'Test User 2', null, 'Quality Tester 2', null, 1, '$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2', [], 'test_user_2', 'test_user_2', 'test_user_2@example.com', 'test_user_2@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
[
1,
53,
'Clara Haynes',
'2018-02-06 23:28:57',
'CFO',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y',
1,
'$2y$04$kKBYJ8sKCOhhakCjm9sCp.TQdwLTS1FPkPiWn2KBmaCA7xFL0NA42',
['ROLE_CUSTOMER'],
'clara_customer',
'clara_customer',
'clara_customer@example.com',
'clara_customer@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
],
[
2,
82,
'John Doe',
'2018-02-06 23:28:57',
'Developer',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
1,
'$2y$04$36P/xyhP6FbnfFYbXy7V0.ioSe8HjMlJQFYnlIzz2T6Agfi8ob6jK',
[],
'john_user',
'john_user',
'john_user@example.com',
'john_user@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
],
[
3,
35,
'Chris Deactive',
'2018-02-06 23:28:57',
'Developer (left company)',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
0,
'$2y$04$MLtQBZ9JLzWu1Y01QnNjsuoLm8qC9XRkpUywf6DIbpd9OAL1mEcCi',
[],
'chris_user',
'chris_user',
'chris_user@example.com',
'chris_user@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
],
[
4,
35,
'Tony Maier',
'2018-02-06 23:28:57',
'Head of Development',
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
1,
'$2y$04$rqxiiExfUVzIYRVL2x4JJumQWNPIG6PazXwrSJm/VQFEesR08Uj5i',
['ROLE_TEAMLEAD'],
'tony_teamlead',
'tony_teamlead',
'tony_teamlead@example.com',
'tony_teamlead@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
],
[
5,
81,
'Anna Smith',
'2018-02-06 23:28:57',
'Administrator',
null,
1,
'$2y$04$ct/rVb.naDzYZECnvfTJ2uns/zPHv8.8KcunhTjYFwWQeg1dywI8G',
['ROLE_ADMIN'],
'anna_admin',
'anna_admin',
'anna_admin@example.com',
'anna_admin@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
],
[
6,
46,
null,
'2018-02-06 23:28:57',
'Super Administrator',
'/bundles/avanzuadmintheme/img/avatar.png',
1,
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
['ROLE_SUPER_ADMIN'],
'susan_super',
'susan_super',
'susan_super@example.com',
'susan_super@example.com',
null,
'2020-04-14 09:50:38',
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
],
[
7,
null,
'Test User 1',
null,
'Quality Tester 1',
null,
1,
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
[],
'test_user_1',
'test_user_1',
'test_user_1@example.com',
'test_user_1@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
],
[
8,
null,
'Test User 2',
null,
'Quality Tester 2',
null,
1,
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
[],
'test_user_2',
'test_user_2',
'test_user_2@example.com',
'test_user_2@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
],
];
$userEntities = [];
foreach ($users as $userConf) {
$user = new User();
foreach (User::WIZARDS as $wizard) {
$user->setWizardAsSeen($wizard);
}
if ($userConf[1] !== null) {
$user->setPreferenceValue(UserPreference::HOURLY_RATE, $userConf[1]);
}
@@ -120,7 +269,7 @@ class ResetTestCommand extends AbstractResetCommand
} else {
$user->setRoles(['ROLE_USER']);
}
$user->setUsername($userConf[9]);
$user->setUserIdentifier($userConf[9]);
if ($userConf[10] !== null) {
// removed field: UsernameCanonical
}
@@ -138,8 +287,7 @@ class ResetTestCommand extends AbstractResetCommand
$userEntities[] = $user;
}
$team = new Team();
$team->setName('Test team');
$team = new Team('Test team');
$team->addTeamlead($userEntities[6]);
$team->addUser($userEntities[7]);
$this->entityManager->persist($team);
@@ -155,9 +303,9 @@ class ResetTestCommand extends AbstractResetCommand
} catch (Exception $ex) {
$io->error('Failed to drop database schema: ' . $ex->getMessage());
return 2;
return Command::FAILURE;
}
return 0;
return Command::SUCCESS;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Command;
use App\Timesheet\TimesheetService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -18,23 +19,20 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @codeCoverageIgnore
*/
class TimesheetStopAllCommand extends Command
#[AsCommand(name: 'kimai:timesheet:stop-all')]
final class TimesheetStopAllCommand extends Command
{
private $timesheetService;
public function __construct(TimesheetService $timesheetService)
public function __construct(private TimesheetService $timesheetService)
{
parent::__construct();
$this->timesheetService = $timesheetService;
}
protected function configure(): void
{
$this->setName('kimai:timesheet:stop-all');
$this->setDescription('Stop all running timesheets immediately');
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
protected function execute(InputInterface $input, OutputInterface $output): int
{
$amount = $this->timesheetService->stopAll();
@@ -43,6 +41,6 @@ class TimesheetStopAllCommand extends Command
$io->success(sprintf('Stopped %s timesheet records.', $amount));
}
return 0;
return Command::SUCCESS;
}
}

View File

@@ -9,8 +9,9 @@
namespace App\Command;
use App\Configuration\LocaleService;
use App\Kernel;
use App\Utils\LanguageService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
@@ -24,34 +25,24 @@ use Symfony\Component\HttpClient\HttpClient;
*
* @codeCoverageIgnore
*/
class TranslationCommand extends Command
#[AsCommand(name: 'kimai:translations')]
final class TranslationCommand extends Command
{
private $projectDirectory;
private $environment;
private $languageService;
public function __construct(string $projectDirectory, string $kernelEnvironment, LanguageService $languageService)
public function __construct(private string $projectDirectory, private string $kernelEnvironment, private LocaleService $localeService)
{
parent::__construct();
$this->projectDirectory = $projectDirectory;
$this->environment = $kernelEnvironment;
$this->languageService = $languageService;
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:translations')
->setDescription('Translation adjustments')
->addOption('resname', null, InputOption::VALUE_NONE, 'Fix the resname vs. id attribute')
->addOption('duplicates', null, InputOption::VALUE_NONE, 'Find duplicate translation keys')
->addOption('delete-resname', null, InputOption::VALUE_REQUIRED, 'Deletes the translation by resname')
->addOption('extension', null, InputOption::VALUE_NONE, 'Find translation files with wrong extensions')
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty kyes and files which have no translated key at all')
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
// DEEPL TRANSLATION FEATURE - UNTESTED
->addOption('translate-locale', null, InputOption::VALUE_REQUIRED, 'Translate into the given locale with Deepl')
// @see https://www.deepl.com/de/pro#developer
@@ -61,18 +52,16 @@ class TranslationCommand extends Command
public function isEnabled(): bool
{
return $this->environment !== 'prod';
return $this->kernelEnvironment !== 'prod';
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$bases = [
'core' => $this->projectDirectory . '/translations/*.xlf',
'core_xliff' => $this->projectDirectory . '/translations/*.xliff',
'plugins' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf',
'plugins_xliff' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xliff',
];
if ($input->getOption('delete-resname')) {
@@ -123,7 +112,7 @@ class TranslationCommand extends Command
if (!file_exists($fromLocaleName)) {
$io->error('Could not find translation file: ' . $fromLocaleName);
return 1;
return Command::FAILURE;
}
$translations[$fromLocale][$name] = $this->getTranslations($fromLocaleName);
}
@@ -200,13 +189,13 @@ class TranslationCommand extends Command
if ($locale !== null && $deepl === null) {
$io->error('Missing "DeepL API Free" auth-key');
return 1;
return Command::FAILURE;
}
if ($locale === null && $deepl !== null) {
$io->error('Missing translation locale');
return 1;
return Command::FAILURE;
}
if ($locale !== null && $deepl !== null) {
@@ -227,16 +216,16 @@ class TranslationCommand extends Command
];
$locale = strtolower($locale);
if (!$this->languageService->isKnownLanguage($locale)) {
if (!$this->localeService->isKnownLocale($locale)) {
$io->error('Unknown locale given: ' . $locale);
return 1;
return Command::FAILURE;
}
if (!\array_key_exists($locale, $deeplySupportedLanguages)) {
$io->error('Locale not supported by Deeply: ' . $locale);
return 1;
return Command::FAILURE;
}
$allKeys = 0;
@@ -307,7 +296,7 @@ class TranslationCommand extends Command
} catch (\Exception $exception) {
$io->error($exception->getMessage());
return 1;
return Command::FAILURE;
}
$json = json_decode($rawResponseData->getContent(), true);
@@ -323,7 +312,7 @@ class TranslationCommand extends Command
}
}
return 0;
return Command::SUCCESS;
}
private function getTranslations(string $file): array
@@ -364,7 +353,7 @@ class TranslationCommand extends Command
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($xml->asXML());
$xpath = new \DOMXpath($xmlDocument);
$xpath = new \DOMXPath($xmlDocument);
$xpath->registerNamespace('ns', $xmlDocument->documentElement->namespaceURI);
$xmlContent = '';
@@ -379,7 +368,7 @@ class TranslationCommand extends Command
}
$fragment = $xmlDocument->createDocumentFragment();
$fragment->appendXml('<body>' . $xmlContent . '</body>');
$fragment->appendXML('<body>' . $xmlContent . '</body>');
/** @var \DOMElement $element */
$element = $xpath->evaluate('/ns:xliff/ns:file')->item(0);

View File

@@ -11,63 +11,39 @@ namespace App\Command;
use App\Constants;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\KernelInterface;
/**
* Command used to update a Kimai installation.
*/
#[AsCommand(name: 'kimai:update')]
final class UpdateCommand extends Command
{
public const ERROR_CACHE_CLEAN = 2;
public const ERROR_CACHE_WARMUP = 4;
public const ERROR_DATABASE = 8;
public const ERROR_MIGRATIONS = 32;
/**
* @var Connection
*/
private $connection;
public function __construct(Connection $connection)
public function __construct(private Connection $connection, private string $kernelEnvironment)
{
parent::__construct();
$this->connection = $connection;
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:update')
->setDescription('Update your Kimai installation')
->setHelp('This command will execute all required steps to update your Kimai installation.')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai updates running ...');
/** @var Application $application */
$application = $this->getApplication();
/** @var KernelInterface $kernel */
$kernel = $application->getKernel();
$environment = $kernel->getEnvironment();
$environment = $this->kernelEnvironment;
// make sure database is available, Kimai running and installed
try {
@@ -77,21 +53,21 @@ final class UpdateCommand extends Command
);
}
if (!$this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
if (!$this->connection->createSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
$io->error('Tables missing. Did you run the installer already?');
return self::ERROR_DATABASE;
return Command::FAILURE;
}
if (!$this->connection->getSchemaManager()->tablesExist(['migration_versions'])) {
if (!$this->connection->createSchemaManager()->tablesExist(['migration_versions'])) {
$io->error('Unknown migration status, aborting database update');
return self::ERROR_DATABASE;
return Command::FAILURE;
}
} catch (\Exception $ex) {
$io->error('Failed to validate database: ' . $ex->getMessage());
return self::ERROR_DATABASE;
return Command::FAILURE;
}
// execute latest doctrine migrations
@@ -107,13 +83,13 @@ final class UpdateCommand extends Command
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return self::ERROR_MIGRATIONS;
return Command::FAILURE;
}
// flush the cache, in case values from the database are cached
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
if ($cacheResult !== 0) {
if ($cacheResult !== Command::SUCCESS) {
$io->warning(
[
sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION),
@@ -128,10 +104,10 @@ final class UpdateCommand extends Command
);
}
return 0;
return Command::SUCCESS;
}
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
{
$io->text('Rebuilding your cache, please be patient ...');
@@ -143,7 +119,7 @@ final class UpdateCommand extends Command
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return self::ERROR_CACHE_CLEAN;
return Command::FAILURE;
}
$command = $this->getApplication()->find('cache:warmup');
@@ -154,9 +130,9 @@ final class UpdateCommand extends Command
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return self::ERROR_CACHE_WARMUP;
return Command::FAILURE;
}
return 0;
return Command::SUCCESS;
}
}

View File

@@ -10,77 +10,44 @@
namespace App\Command;
use App\Constants;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to fetch Kimai version information.
*/
class VersionCommand extends Command
#[AsCommand(name: 'kimai:version')]
final class VersionCommand extends Command
{
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('kimai:version')
->setDescription('Receive version information')
->setHelp('This command allows you to fetch various version information about Kimai.')
->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only')
->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only')
// @deprecated since 1.14.1
->addOption('name', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the major release name')
->addOption('candidate', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the current version candidate (e.g. "stable" or "dev")')
->addOption('semver', null, InputOption::VALUE_NONE, 'DEPRECATED: Semantical versioning (SEMVER) compatible version string')
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ($input->getOption('semver')) {
@trigger_error('bin/console kimai:version --semver is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$io->writeln(Constants::VERSION . '-' . Constants::STATUS);
return 0;
}
if ($input->getOption('short')) {
$io->writeln(Constants::VERSION);
return 0;
}
if ($input->getOption('name')) {
@trigger_error('bin/console kimai:version --name is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$io->writeln(Constants::NAME);
return 0;
}
if ($input->getOption('candidate')) {
@trigger_error('bin/console kimai:version --candidate is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$io->writeln(Constants::STATUS);
return 0;
return Command::SUCCESS;
}
if ($input->getOption('number')) {
$io->writeln((string) Constants::VERSION_ID);
return 0;
return Command::SUCCESS;
}
$io->writeln(sprintf('%s <info>%s</info> by Kevin Papst and contributors.', Constants::SOFTWARE, Constants::VERSION));
$io->writeln(sprintf('%s <info>%s</info> by Kevin Papst.', Constants::SOFTWARE, Constants::VERSION));
return 0;
return Command::SUCCESS;
}
}