Release 2.25 (#5109)

This commit is contained in:
Kevin Papst
2024-11-21 22:44:49 +01:00
committed by GitHub
parent 49eb7068c9
commit 0c26a2678e
261 changed files with 6431 additions and 7426 deletions

View File

@@ -17,7 +17,7 @@ use App\Entity\Timesheet;
use App\Event\PageActionsEvent;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

View File

@@ -13,7 +13,7 @@ use App\API\Model\TimesheetConfig;
use App\Configuration\SystemConfiguration;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

View File

@@ -14,7 +14,7 @@ use App\API\Model\Version;
use App\Plugin\PluginManager;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

View File

@@ -15,11 +15,11 @@ use App\Plugin\Plugin;
use App\Plugin\PluginManager;
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\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Process\PhpSubprocess;
#[AsCommand(name: 'kimai:plugins', description: 'Manage Kimai plugins')]
final class PluginCommand extends Command
@@ -69,14 +69,28 @@ final class PluginCommand extends Command
}
}
$command = $this->getApplication()?->find('doctrine:migrations:migrate');
if ($command === null) {
throw new \RuntimeException('Failed finding doctrine migrations command');
}
$cmdInput = new ArrayInput(['--allow-no-migration' => true, '--configuration' => $config]);
$cmdInput->setInteractive(false);
if (0 !== $command->run($cmdInput, $output)) {
$io->error('Failed to install bundle database: ' . $config);
// using getApplication()->find('doctrine:migrations:migrate') does NOT work here
// because the Doctrine command can only be executed once
// if run more than once it fails with a "Container is frozen" exception
$process = new PhpSubprocess([
'bin/console',
'doctrine:migrations:migrate',
'--allow-no-migration',
'--no-interaction',
'--configuration=' . $config
]);
$process->run();
if (!$process->isSuccessful()) {
$io->error('Failed to install bundle database: ' . PHP_EOL . $config);
$io->error($process->getErrorOutput());
} else {
if ($io->isVerbose()) {
$io->write($process->getOutput());
} else {
$io->success('Successfully installed: ' . $plugin->getName());
}
}
}

View File

@@ -136,7 +136,15 @@ final class RegenerateLocalesCommand extends Command
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT);
$settings['date'] = $shortDate->getPattern();
if ($settings['date'] === false) {
$io->error('Invalid date pattern for locale: ' . $locale);
continue;
}
$settings['time'] = $shortTime->getPattern();
if ($settings['time'] === false) {
$io->error('Invalid time pattern for locale: ' . $locale);
continue;
}
// see https://github.com/kimai/kimai/issues/4402 - Korean time format failed parsing
// special case when time pattern starts with A / a => this will lead to an error

View File

@@ -48,7 +48,9 @@ final class TranslationCommand extends Command
->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 keys and files which have no translated key at all')
->addOption('move-resname', null, InputOption::VALUE_REQUIRED, 'Move a resname from one file to another (needs "source" and "target" options)')
->addOption('move-all', null, InputOption::VALUE_NONE, 'Move all keys from one file to another (needs "source" and "target" options)')
->addOption('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use')
->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories')
->addOption('target', null, InputOption::VALUE_REQUIRED, 'Single target file to use')
// DEEPL TRANSLATION FEATURE - UNTESTED
->addOption('translate-locale', null, InputOption::VALUE_REQUIRED, 'Translate into the given locale with Deepl')
@@ -68,10 +70,13 @@ final class TranslationCommand extends Command
$bases = [
'core' => $this->projectDirectory . '/translations/*.xlf',
'plugins' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf',
'theme' => $this->projectDirectory . '/vendor/kevinpapst/tabler-bundle/translations/*.xlf',
];
if (!$input->getOption('only-core')) {
$bases['plugins'] = $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf';
$bases['theme'] = $this->projectDirectory . '/vendor/kevinpapst/tabler-bundle/translations/*.xlf';
}
$sources = [];
if ($input->getOption('source') !== null) {
/** @var string $tmp */
@@ -146,6 +151,19 @@ final class TranslationCommand extends Command
return $this->moveResname($io, $moveResname, $sources, $targets);
}
// ==========================================================================
// Move all keys from source to target
// ==========================================================================
if ($input->getOption('move-all')) {
if (\count($sources) === 0 || \count($targets) === 0) {
$io->error('Moving all keys only works with one source and one target file');
return Command::FAILURE;
}
return $this->moveAll($io, $sources, $targets);
}
// ==========================================================================
// Fill empty translations with english version
// ==========================================================================
@@ -634,4 +652,66 @@ final class TranslationCommand extends Command
return Command::SUCCESS;
}
/**
* @param array<string> $sources
* @param array<string> $targets
*/
private function moveAll(SymfonyStyle $io, array $sources, array $targets): int
{
foreach ($sources as $source) {
$tmp = basename($source);
$pos = strpos($tmp, '.');
if ($pos === false) {
$io->error('Unexpected filename: ' . $source);
return Command::FAILURE;
}
$suffix = substr($tmp, $pos);
$target = null;
foreach ($targets as $t) {
if (str_ends_with($t, $suffix)) {
$target = $t;
}
}
if ($target === null) {
$io->error('Cannot find translation target file for source: ' . $source);
return Command::FAILURE;
}
$sourceDocument = new \DOMDocument('1.0');
$sourceDocument->load($source);
$targetDocument = new \DOMDocument('1.0');
$targetDocument->load($target);
$removeNodes = [];
/** @var \DOMElement $element */
foreach ($sourceDocument->getElementsByTagName('trans-unit') as $element) {
$newNode = $targetDocument->importNode($element, true);
$targetDocument->documentElement->firstElementChild->firstElementChild->appendChild($newNode); // @phpstan-ignore-line
$removeNodes[] = $element;
}
foreach ($removeNodes as $node) {
$sourceDocument->documentElement->firstElementChild->firstElementChild->removeChild($node); // @phpstan-ignore-line
}
$xmlDocument = new \DOMDocument('1.0');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($sourceDocument->saveXML()); // @phpstan-ignore-line
file_put_contents($source, $xmlDocument->saveXML());
$xmlDocument = new \DOMDocument('1.0');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($targetDocument->saveXML()); // @phpstan-ignore-line
file_put_contents($target, $xmlDocument->saveXML());
}
return Command::SUCCESS;
}
}

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.24.0';
public const VERSION = '2.25.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22400;
public const VERSION_ID = 22500;
/**
* The software name
*/

View File

@@ -111,6 +111,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
$tags[] = $tag;
}
$newTimesheets = [];
foreach ($allUsers as $user) {
$newTimesheet = $entry->createCopy();
$newTimesheet->setUser($user);
@@ -118,6 +119,11 @@ final class TimesheetTeamController extends TimesheetAbstractController
$newTimesheet->addTag($tag);
}
$this->service->prepareNewTimesheet($newTimesheet, $request);
$this->service->validateTimesheet($newTimesheet);
$newTimesheets[] = $newTimesheet;
}
foreach ($newTimesheets as $newTimesheet) {
$this->service->saveNewTimesheet($newTimesheet);
}

View File

@@ -24,7 +24,7 @@ final class UTCDateTimeImmutableType extends DateTimeImmutableType
/**
* @param T $value
* @return (T is null ? null : string)
* @template T<\DateTimeImmutable>
* @template T
* @throws ConversionException
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string

View File

@@ -25,7 +25,7 @@ final class UTCDateTimeType extends DateTimeType
* @param T $value
* @param AbstractPlatform $platform
* @return (T is null ? null : string)
* @template T<\DateTime>
* @template T
* @throws ConversionException
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string

View File

@@ -109,16 +109,14 @@ trait MetaTableTypeTrait
// unchecked checkboxes / false bool would save an empty string in the database
// those cannot be searched in the database
if (null !== $value) {
switch ($this->type) {
case YesNoType::class:
case CheckboxType::class:
if (!\is_int($value) && !\is_bool($value) && !\is_string($value)) {
throw new \InvalidArgumentException('Failed converting meta-field bool value');
} else {
$value = (string) $value;
}
}
switch ($this->type) {
case YesNoType::class:
case CheckboxType::class:
if ($value === false || $value === '' || !\is_scalar($value)) {
$value = 0;
} else {
$value = 1;
}
}
if ($value === null) {

View File

@@ -178,13 +178,13 @@ class Team
}
/**
* @return User[]
* @return list<User>
*/
public function getTeamleads(): array
{
$leads = [];
foreach ($this->members as $member) {
if ($member->isTeamlead()) {
if ($member->isTeamlead() && $member->getUser() !== null) {
$leads[] = $member->getUser();
}
}
@@ -273,13 +273,15 @@ class Team
/**
* Returns all users in the team, both teamlead and normal member.
*
* @return User[]
* @return list<User>
*/
public function getUsers(): array
{
$users = [];
foreach ($this->members as $member) {
$users[] = $member->getUser();
if ($member->getUser() !== null) {
$users[] = $member->getUser();
}
}
return $users;

View File

@@ -624,11 +624,9 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
return $this;
}
public function createCopy(?Timesheet $timesheet = null): Timesheet
public function createCopy(): Timesheet
{
if (null === $timesheet) {
$timesheet = new Timesheet();
}
$timesheet = new Timesheet();
$values = get_object_vars($this);
foreach ($values as $k => $v) {
@@ -639,7 +637,9 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
/** @var TimesheetMeta $meta */
foreach ($this->meta as $meta) {
$timesheet->setMetaField(clone $meta);
$tmp = clone $meta;
$tmp->setEntity($timesheet);
$timesheet->setMetaField($tmp);
}
$timesheet->tags = new ArrayCollection();

View File

@@ -752,9 +752,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
* This method should not be called by plugins and returns true on success or false on a failure.
*
* @internal immutable property that cannot be set by plugins
* @param bool $canSeeAllData
* @return bool
* @throws Exception
*/
public function initCanSeeAllData(bool $canSeeAllData): bool
{

View File

@@ -150,11 +150,26 @@ class UserPreference
* integer, float, string, boolean or null
*
* @param mixed $value
* @return UserPreference
*/
public function setValue($value): UserPreference
{
$this->value = $value;
// unchecked checkboxes / false bool would save an empty string in the database
// those cannot be searched in the database
switch ($this->type) {
case YesNoType::class:
case CheckboxType::class:
if ($value === false || $value === '' || !\is_scalar($value)) {
$value = 0;
} else {
$value = 1;
}
}
if ($value === null) {
$this->value = $value;
} elseif (\is_scalar($value)) {
$this->value = (string) $value;
}
return $this;
}

View File

@@ -157,7 +157,7 @@ class PageActionsEvent extends ThemeEvent
public function addQuickImport(string $url): void
{
$this->addAction('import', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'import', 'icon' => 'upload', 'translation_domain' => 'actions']);
$this->addAction('import', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'import', 'icon' => 'upload']);
}
public function addQuickExport(string $url): void
@@ -172,7 +172,7 @@ class PageActionsEvent extends ThemeEvent
public function addEdit(string $url, bool $modal = true, string $class = ''): void
{
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'translation_domain' => 'actions', 'title' => 'edit']);
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'title' => 'edit']);
}
/**
@@ -180,20 +180,20 @@ class PageActionsEvent extends ThemeEvent
*/
public function addSettings(string $url): void
{
$this->addAction('settings', ['url' => $url, 'class' => 'modal-ajax-form', 'title' => 'settings', 'translation_domain' => 'actions', 'accesskey' => 'h']);
$this->addAction('settings', ['url' => $url, 'class' => 'modal-ajax-form', 'title' => 'settings', 'accesskey' => 'h']);
}
public function addConfig(string $url): void
{
$this->addAction('settings', ['url' => $url, 'title' => 'settings', 'translation_domain' => 'actions']);
$this->addAction('settings', ['url' => $url, 'title' => 'settings']);
}
public function addDelete(string $url, bool $remoteConfirm = true): void
{
if ($remoteConfirm) {
$this->addAction('trash', ['url' => $url, 'class' => 'modal-ajax-form text-red', 'translation_domain' => 'actions', 'title' => 'trash']);
$this->addAction('trash', ['url' => $url, 'class' => 'modal-ajax-form text-red', 'title' => 'trash']);
} else {
$this->addAction('trash', ['url' => $url, 'class' => 'confirmation-link text-red', 'attr' => ['data-question' => 'confirm.delete'], 'translation_domain' => 'actions', 'title' => 'trash']);
$this->addAction('trash', ['url' => $url, 'class' => 'confirmation-link text-red', 'attr' => ['data-question' => 'confirm.delete'], 'title' => 'trash']);
}
}

View File

@@ -21,7 +21,7 @@ abstract class AbstractActionsSubscriber implements EventSubscriberInterface
{
private ?string $locale = null;
public function __construct(private AuthorizationCheckerInterface $auth, private UrlGeneratorInterface $urlGenerator)
public function __construct(private readonly AuthorizationCheckerInterface $auth, private readonly UrlGeneratorInterface $urlGenerator)
{
}

View File

@@ -29,7 +29,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
}
if (!$timesheet->isRunning() && $this->isGranted('start', $timesheet)) {
$event->addAction('repeat', ['title' => 'repeat', 'translation_domain' => 'actions', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
$event->addAction('repeat', ['title' => 'repeat', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
}
if ($this->isGranted('edit', $timesheet)) {
@@ -38,7 +38,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('duplicate', $timesheet)) {
$class = $event->isView('edit') ? '' : 'modal-ajax-form';
$event->addAction('copy', ['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class . ' dd-ts-duplicate']);
$event->addAction('copy', ['title' => 'copy', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class . ' dd-ts-duplicate']);
}
if ($event->countActions() > 0) {
@@ -49,7 +49,6 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
$event->addAction('trash', [
'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]),
'class' => 'api-link text-red dd-ts-trash',
'translation_domain' => 'actions',
'attr' => [
'data-event' => 'kimai.timesheetDelete',
'data-method' => 'DELETE',

View File

@@ -31,7 +31,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
}
if (!$event->isView('activity_details') && $this->isGranted('view', $activity)) {
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('activity_details', ['id' => $activity->getId()])]);
$event->addAction('details', ['title' => 'details', 'url' => $this->path('activity_details', ['id' => $activity->getId()])]);
}
if ($this->isGranted('edit', $activity)) {
@@ -40,7 +40,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
if ($this->isGranted('permissions', $activity)) {
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity_permissions', ['id' => $activity->getId()]), 'class' => $class]);
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_activity_permissions', ['id' => $activity->getId()]), 'class' => $class]);
}
if ($event->countActions() > 0) {
@@ -53,7 +53,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
$parameters['customers[]'] = $activity->getProject()->getCustomer()->getId();
$parameters['projects[]'] = $activity->getProject()->getId();
}
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', $parameters)]);
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', $parameters)]);
}
if ($event->hasSubmenu('filter')) {
@@ -65,7 +65,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
if (!$activity->isGlobal()) {
$parameters['project'] = $activity->getProject()->getId();
}
$event->addAction('create-timesheet', ['title' => 'create-timesheet', 'translation_domain' => 'actions', 'icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
$event->addAction('create-timesheet', ['title' => 'create-timesheet', 'icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
}
if (($event->isIndexView() || $event->isView('project_details')) && $this->isGranted('delete', $activity)) {

View File

@@ -34,7 +34,7 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
$isListingView = $event->isIndexView() || $event->isCustomView();
if (!$event->isView('customer_details') && $canView) {
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('customer_details', ['id' => $customer->getId()])]);
$event->addAction('details', ['title' => 'details', 'url' => $this->path('customer_details', ['id' => $customer->getId()])]);
}
if ($this->isGranted('edit', $customer)) {
@@ -43,7 +43,7 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('permissions', $customer)) {
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_customer_permissions', ['id' => $customer->getId()]), 'class' => $class]);
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_customer_permissions', ['id' => $customer->getId()]), 'class' => $class]);
}
if ($isListingView) {
@@ -61,15 +61,15 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view_project') || $this->isGranted('view_teamlead_project') || $this->isGranted('view_team_project')) {
$event->addActionToSubmenu('filter', 'project', ['title' => 'project.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
$event->addActionToSubmenu('filter', 'project', ['title' => 'project.filter', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
}
if ($this->isGranted('view_activity')) {
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
}
if ($this->isGranted('view_other_timesheet')) {
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId()])]);
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId()])]);
}
if ($event->hasSubmenu('filter')) {

View File

@@ -56,7 +56,7 @@ final class InvoiceSubscriber extends AbstractActionsSubscriber
$allowDelete = $this->isGranted('delete_invoice');
if (!$invoice->isCanceled()) {
$id = $allowDelete ? 'invoice.cancel' : 'trash';
$event->addAction($id, ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]), 'title' => 'invoice.cancel', 'translation_domain' => 'actions']);
$event->addAction($id, ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]), 'title' => 'invoice.cancel']);
}
if ($this->isGranted('delete_invoice')) {

View File

@@ -34,7 +34,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
$isListingView = $event->isIndexView() || $event->isCustomView();
if (!$event->isView('project_details') && $this->isGranted('view', $project)) {
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('project_details', ['id' => $project->getId()])]);
$event->addAction('details', ['title' => 'details', 'url' => $this->path('project_details', ['id' => $project->getId()])]);
}
if ($this->isGranted('edit', $project)) {
@@ -43,7 +43,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('permissions', $project)) {
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_project_permissions', ['id' => $project->getId()]), 'class' => $class]);
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_project_permissions', ['id' => $project->getId()]), 'class' => $class]);
}
if ($event->countActions() > 0) {
@@ -51,11 +51,11 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view_activity')) {
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
}
if ($this->isGranted('view_other_timesheet')) {
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
}
if ($this->isGranted('create_export')) {
@@ -79,7 +79,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
if (\array_key_exists('token', $payload) && $this->isGranted('edit', $project) && $this->isGranted('create_project')) {
$event->addAction(
'copy',
['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path('admin_project_duplicate', ['id' => $project->getId(), 'token' => $payload['token']])]
['title' => 'copy', 'url' => $this->path('admin_project_duplicate', ['id' => $project->getId(), 'token' => $payload['token']])]
);
}

View File

@@ -46,14 +46,13 @@ final class TagSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view_other_timesheet')) {
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['tags' => $name])]);
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['tags' => $name])]);
}
if ($event->isIndexView() && $this->isGranted('delete_tag')) {
$event->addAction('trash', [
'url' => $this->path('delete_tag', ['id' => $id]),
'class' => 'api-link text-red',
'translation_domain' => 'actions',
'attr' => [
'data-event' => 'kimai.tagDelete kimai.tagUpdate',
'data-method' => 'DELETE',

View File

@@ -36,7 +36,7 @@ final class TeamSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('create_team')) {
$event->addAction('copy', ['url' => $this->path('team_duplicate', ['id' => $team->getId()]), 'title' => 'copy', 'translation_domain' => 'actions', 'class' => 'modal-ajax-form']);
$event->addAction('copy', ['url' => $this->path('team_duplicate', ['id' => $team->getId()]), 'title' => 'copy', 'class' => 'modal-ajax-form']);
}
}
@@ -44,7 +44,6 @@ final class TeamSubscriber extends AbstractActionsSubscriber
$event->addAction('trash', [
'url' => $this->path('delete_team', ['id' => $team->getId()]),
'class' => 'api-link text-red',
'translation_domain' => 'actions',
'attr' => [
'data-event' => 'kimai.teamDelete kimai.teamUpdate',
'data-method' => 'DELETE',

View File

@@ -22,7 +22,7 @@ final class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
{
if ($this->isGranted('create_other_timesheet')) {
$event->addAction('create', ['title' => 'create', 'url' => $this->path('admin_timesheet_create'), 'class' => 'create-ts modal-ajax-form']);
$event->addAction('multi-user', ['title' => 'create-timesheet-multiuser', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet_create_multiuser'), 'class' => 'create-ts-mu modal-ajax-form', 'icon' => 'fas fa-user-plus']);
$event->addAction('multi-user', ['title' => 'create-timesheet-multiuser', 'url' => $this->path('admin_timesheet_create_multiuser'), 'class' => 'create-ts-mu modal-ajax-form', 'icon' => 'fas fa-user-plus']);
}
if ($this->isGranted('export_other_timesheet')) {

View File

@@ -31,7 +31,7 @@ final class UserFormsSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('edit', $user)) {
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats', 'translation_domain' => 'actions']);
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats']);
}
if ($this->isGranted('preferences', $user)) {
$event->addConfig($this->path('user_profile_preferences', ['username' => $user->getUserIdentifier()]));

View File

@@ -40,7 +40,7 @@ final class UserSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view', $user)) {
$event->addAction('profile-stats', ['icon' => 'avatar', 'url' => $this->path('user_profile', ['username' => $user->getUserIdentifier()]), 'translation_domain' => 'actions', 'title' => 'profile-stats']);
$event->addAction('profile-stats', ['icon' => 'avatar', 'url' => $this->path('user_profile', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats']);
$event->addDivider();
}
@@ -62,7 +62,7 @@ final class UserSubscriber extends AbstractActionsSubscriber
}
if ($user->isEnabled() && $this->isGranted('view_other_timesheet')) {
$event->addActionToSubmenu('filter', 'timesheet', ['url' => $this->path('admin_timesheet', ['users[]' => $user->getId()]), 'title' => 'timesheet.filter', 'translation_domain' => 'actions']);
$event->addActionToSubmenu('filter', 'timesheet', ['url' => $this->path('admin_timesheet', ['users[]' => $user->getId()]), 'title' => 'timesheet.filter']);
}
if ($this->isGranted('view_team')) {

View File

@@ -28,7 +28,7 @@ final class AnnotationExtractor implements ExtractorInterface
/**
* @param string $value
* @return ColumnDefinition[]
* @return list<ColumnDefinition>
* @throws ExtractorException
*/
public function extract($value): array
@@ -154,11 +154,7 @@ final class AnnotationExtractor implements ExtractorInterface
}
}
foreach ($columns as $name => $definition) {
if (null === $definition) {
unset($columns[$name]);
}
}
$columns = array_filter($columns, function ($value) { return $value !== null; });
return array_values($columns);
}

View File

@@ -29,6 +29,7 @@ final class MultiUpdateTable extends AbstractType
$builder->add('entities', HiddenType::class, [
'required' => false,
'attr' => ['class' => 'multi_update_ids']
]);
$builder->get('entities')->addModelTransformer(

View File

@@ -38,6 +38,8 @@ final class ActivityType extends AbstractType
public function groupBy(Activity $activity, $key, $index): string
{
if (null === $activity->getProject()) {
// this creates a optgroup with an empty title. previously this was null, which resulted in options without optgroup
// and those are ordered by Tomselect at the top - so globals always came first, see #4674
return '';
}

View File

@@ -13,6 +13,7 @@ use App\Configuration\MailConfiguration;
use App\Entity\User;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
@@ -31,7 +32,11 @@ final class KimaiMailer implements MailerInterface
}
if (\count($message->getFrom()) === 0) {
$message->from($this->configuration->getFromAddress());
$fallback = $this->configuration->getFromAddress();
if ($fallback === null) {
throw new \RuntimeException('Missing email "from" address');
}
$message->from(new Address($fallback, 'Kimai'));
}
$this->mailer->send($message);

View File

@@ -17,7 +17,7 @@ use DateTimeInterface;
final class MonthlyStatistic implements DateStatisticInterface
{
/**
* @var array<string|int, array<int<1, 12>, StatisticDate>>
* @var array<string, array<int<1, 12>, StatisticDate>>
*/
private array $years = [];
private DateTimeInterface $begin;
@@ -50,7 +50,7 @@ final class MonthlyStatistic implements DateStatisticInterface
$day = (int) $begin->format('d');
while ($tmp < $this->end) {
$curYear = $tmp->format('Y');
$curYear = (string) $tmp->format('Y');
if (!isset($years[$curYear])) {
$year = [];
for ($i = 1; $i < 13; $i++) {
@@ -67,7 +67,7 @@ final class MonthlyStatistic implements DateStatisticInterface
}
$tmp->modify('+1 month');
}
$this->years = $years;
$this->years = $years; // @phpstan-ignore assign.propertyType
}
/**

View File

@@ -13,6 +13,7 @@ use App\Entity\Project;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRateRepository;
use App\Repository\Query\ActivityQuery;
final class ProjectDuplicationService
{
@@ -53,7 +54,11 @@ final class ProjectDuplicationService
$this->projectRateRepository->saveRate($newRate);
}
$allActivities = $this->activityRepository->findByProject($project);
$query = new ActivityQuery();
$query->addProject($project);
$query->setExcludeGlobals(true);
$allActivities = $this->activityRepository->getActivitiesForQuery($query);
foreach ($allActivities as $activity) {
$newActivity = clone $activity;
$newActivity->setProject($newProject);

View File

@@ -37,18 +37,6 @@ class ActivityRepository extends EntityRepository
{
use RepositorySearchTrait;
/**
* @param Project $project
* @return array<Activity>
*/
public function findByProject(Project $project): array
{
$query = new ActivityQuery();
$query->addProject($project);
return $this->getActivitiesForQuery($query);
}
/**
* @param int[] $activityIds
* @return array<Activity>

View File

@@ -64,7 +64,7 @@ trait RepositorySearchTrait
$c = 0;
foreach ($searchTerm->getSearchFields() as $metaName => $metaValue) {
$and = $qb->expr()->andX();
/** @var non-falsy-string&literal-string $alias */
/** @var non-falsy-string&lowercase-string $alias */
$alias = 'meta' . $a++;
$paramName = 'metaName' . $i++;
$paramValue = 'metaValue' . $c++;

View File

@@ -108,8 +108,6 @@ final class TimesheetService
}
/**
* @param Timesheet $timesheet
* @return Timesheet
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
* @throws InvalidArgumentException for already persisted timesheets
* @throws AccessDeniedException if user is not allowed to start timesheet
@@ -233,11 +231,10 @@ final class TimesheetService
}
/**
* @param Timesheet $timesheet
* @param string[] $groups
* @throws ValidationFailedException
*/
private function validateTimesheet(Timesheet $timesheet, array $groups = []): void
public function validateTimesheet(Timesheet $timesheet, array $groups = []): void
{
$errors = $this->validator->validate($timesheet, null, $groups);

View File

@@ -19,6 +19,7 @@ use App\Utils\LocaleFormatter;
use DateTime;
use DateTimeInterface;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Twig\DeprecatedCallableInfo;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -41,7 +42,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
new TwigFilter('date_short', [$this, 'dateShort']),
new TwigFilter('date_time', [$this, 'dateTime']),
// cannot be deleted right now, needs to be kept for invoice and export templates
new TwigFilter('date_full', [$this, 'dateTime'], ['deprecated' => true, 'alternative' => 'date_time']),
new TwigFilter('date_full', [$this, 'dateTime'], ['deprecation_info' => new DeprecatedCallableInfo('Kimai', '2.0', 'date_time')]),
new TwigFilter('date_format', [$this, 'dateFormat']),
new TwigFilter('date_weekday', [$this, 'dateWeekday']),
new TwigFilter('time', [$this, 'time']),

View File

@@ -31,7 +31,7 @@ final class LocaleFormatter
private ?NumberFormatter $moneyFormatter = null;
private ?NumberFormatter $moneyFormatterNoCurrency = null;
public function __construct(private LocaleService $localeService, private string $locale)
public function __construct(private readonly LocaleService $localeService, private readonly string $locale)
{
}

View File

@@ -11,6 +11,7 @@ namespace App\Validator\Constraints;
use App\Configuration\SystemConfiguration;
use App\Entity\Timesheet as TimesheetEntity;
use App\Form\Model\MultiUserTimesheet;
use App\Repository\TimesheetRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
@@ -35,6 +36,10 @@ final class TimesheetOverlappingValidator extends ConstraintValidator
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
if ($value instanceof MultiUserTimesheet) {
return;
}
$begin = $value->getBegin();
$end = $value->getEnd();

View File

@@ -28,13 +28,13 @@ final class WorkingTimeCalculatorDay implements WorkingTimeCalculator
public function getWorkHoursForDay(\DateTimeInterface $dateTime): int
{
return (int) match ($dateTime->format('N')) {
'1' => $this->user->getPreferenceValue(self::WORK_HOURS_MONDAY, 0),
'2' => $this->user->getPreferenceValue(self::WORK_HOURS_TUESDAY, 0),
'3' => $this->user->getPreferenceValue(self::WORK_HOURS_WEDNESDAY, 0),
'4' => $this->user->getPreferenceValue(self::WORK_HOURS_THURSDAY, 0),
'5' => $this->user->getPreferenceValue(self::WORK_HOURS_FRIDAY, 0),
'6' => $this->user->getPreferenceValue(self::WORK_HOURS_SATURDAY, 0),
'7' => $this->user->getPreferenceValue(self::WORK_HOURS_SUNDAY, 0),
'1' => $this->user->getPreferenceValue(self::WORK_HOURS_MONDAY, 0, false),
'2' => $this->user->getPreferenceValue(self::WORK_HOURS_TUESDAY, 0, false),
'3' => $this->user->getPreferenceValue(self::WORK_HOURS_WEDNESDAY, 0, false),
'4' => $this->user->getPreferenceValue(self::WORK_HOURS_THURSDAY, 0, false),
'5' => $this->user->getPreferenceValue(self::WORK_HOURS_FRIDAY, 0, false),
'6' => $this->user->getPreferenceValue(self::WORK_HOURS_SATURDAY, 0, false),
'7' => $this->user->getPreferenceValue(self::WORK_HOURS_SUNDAY, 0, false),
default => throw new \Exception('Unknown day: ' . $dateTime->format('Y-m-d'))
};
}

View File

@@ -18,7 +18,7 @@ use App\Model\Month as BaseMonth;
*/
final class Month extends BaseMonth
{
public function __construct(\DateTimeImmutable $month, private User $user)
public function __construct(\DateTimeImmutable $month, private readonly User $user)
{
parent::__construct($month);
}