Release 2.33.0 (#5438)

This commit is contained in:
Kevin Papst
2025-04-22 20:43:22 +02:00
committed by GitHub
parent a70c803158
commit e663cc2b02
58 changed files with 1404 additions and 899 deletions

View File

@@ -120,12 +120,23 @@ class ActivityService
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countActivity();
$i = 0;
$createDate = new \DateTimeImmutable();
do {
$start++;
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start, $createDate): string|int {
return match ($format) {
'Y' => $createDate->format('Y'),
'y' => $createDate->format('y'),
'M' => $createDate->format('m'),
'm' => $createDate->format('n'),
'D' => $createDate->format('d'),
'd' => $createDate->format('j'),
'YY' => (int) $createDate->format('Y') + $increaseBy,
'yy' => (int) $createDate->format('y') + $increaseBy,
'MM' => (int) $createDate->format('m') + $increaseBy,
'DD' => (int) $createDate->format('d') + $increaseBy,
'ac' => $start + $increaseBy,
default => $originalFormat,
};

View File

@@ -39,7 +39,7 @@ final class RegenerateLocalesCommand extends Command
*
* @var string[]
*/
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca'];
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca', 'ta'];
/**
* A list of locales that will be activated, no matter if translation files exist for them.
*
@@ -47,11 +47,11 @@ final class RegenerateLocalesCommand extends Command
*/
private array $addLocaleToList = ['zh_Hant_TW'];
/**
* A list of locales that will NOT be activated, as no translations exist by now.
* A list of locales that will NOT be activated, as not enough translations exist by now.
*
* @var string[]
*/
private array $skipLocale = ['ca'];
private array $skipLocale = ['ca', 'et'];
public function __construct(
private readonly string $projectDirectory,
@@ -86,6 +86,7 @@ final class RegenerateLocalesCommand extends Command
$firstLevelLocales[] = $l;
}
$firstLevelLocales = array_unique(array_merge($firstLevelLocales, $this->addLocaleToList));
$io->title('First level locales found');
$io->writeln(implode('|', $firstLevelLocales));
@@ -198,8 +199,23 @@ final class RegenerateLocalesCommand extends Command
// in the future this list should be reduced to the list of available translations, but for a long time users
// could choose from the entire list of all locales, so we likely have to keep that forever ...
$io->title('List of "kimai_locales" for services.yaml');
$io->writeln("['" . implode("', '", $locales) . "']");
$listOfLocales = array_map(fn ($locale) => "'$locale'", $locales);
$filename = 'config/services.yaml';
$targetFile = $this->projectDirectory . DIRECTORY_SEPARATOR . $filename;
$content = file_get_contents($targetFile);
if ($content === false) {
$io->error('Failed reading configuration file at ' . $filename);
} else {
$content = preg_replace(
'/^(\s*kimai_locales:\s*\[).*?(\])$/m',
'${1}' . implode(', ', $listOfLocales) . '${2}',
$content
);
file_put_contents($targetFile, $content);
$io->success('Replaced locale definitions in: ' . $filename);
}
ksort($appLocales);

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.32.0';
public const VERSION = '2.33.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 23200;
public const VERSION_ID = 23300;
/**
* The software name
*/

View File

@@ -63,6 +63,8 @@ final class ContractController extends AbstractController
/** @var \DateTime $yearDate */
$yearDate = $values->getDate();
// make sure we use the correct datetime for the selected user
$yearDate = $this->getDateTimeFactory($profile)->createStartOfYear($yearDate);
$year = $workingTimeService->getYear($profile, $yearDate, $now);
$page = new PageSetup('work_times');

View File

@@ -137,12 +137,23 @@ final class CustomerService
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countCustomer();
$i = 0;
$createDate = new \DateTimeImmutable();
do {
$start++;
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start, $createDate): string|int {
return match ($format) {
'Y' => $createDate->format('Y'),
'y' => $createDate->format('y'),
'M' => $createDate->format('m'),
'm' => $createDate->format('n'),
'D' => $createDate->format('d'),
'd' => $createDate->format('j'),
'YY' => (int) $createDate->format('Y') + $increaseBy,
'yy' => (int) $createDate->format('y') + $increaseBy,
'MM' => (int) $createDate->format('m') + $increaseBy,
'DD' => (int) $createDate->format('d') + $increaseBy,
'cc' => $start + $increaseBy,
default => $originalFormat,
};

View File

@@ -12,6 +12,7 @@ namespace App\Export\Base;
use App\Entity\ExportableItem;
use App\Export\ExportFilename;
use App\Export\Package\CellFormatter\DateStringFormatter;
use App\Export\Package\CellFormatter\DurationPlainFormatter;
use App\Export\Package\SpoutSpreadsheet;
use App\Export\RendererInterface;
use App\Export\TimesheetExportInterface;
@@ -71,6 +72,7 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
$spreadsheet->open($filename);
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter());
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
return new \SplFileInfo($filename);

View File

@@ -105,7 +105,7 @@ final class SpreadsheetRenderer
$formula = null;
if (\in_array($column->getName(), $totalColumns)) {
$columnName = $columnNames[$totalColumn - 1];
$formula = \sprintf('=SUM(%s2:%s%s)', $columnName, $columnName, $currentRow);
$formula = \sprintf('=SUBTOTAL(9,%s2:%s%s)', $columnName, $columnName, $currentRow);
}
$totalRow[] = $formula;
$totalColumn++;

View File

@@ -0,0 +1,15 @@
<?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\Export\Package\CellFormatter;
interface CellWithFormatInterface
{
public function getFormat(): string;
}

View File

@@ -9,7 +9,7 @@
namespace App\Export\Package\CellFormatter;
final class DateFormatter implements CellFormatterInterface
final class DateFormatter implements CellFormatterInterface, CellWithFormatInterface
{
public function formatValue(mixed $value): mixed
{
@@ -23,4 +23,9 @@ final class DateFormatter implements CellFormatterInterface
throw new \InvalidArgumentException('Only DateTimeInterface can be formatted');
}
public function getFormat(): string
{
return 'yyyy-mm-dd';
}
}

View File

@@ -9,23 +9,34 @@
namespace App\Export\Package\CellFormatter;
use App\Utils\Duration;
final class DurationFormatter implements CellFormatterInterface
final class DurationFormatter implements CellFormatterInterface, CellWithFormatInterface
{
private Duration $duration;
public function __construct()
{
$this->duration = new Duration();
}
public function formatValue(mixed $value): mixed
{
if (is_numeric($value)) {
return $this->duration->format((int) $value);
if (!is_numeric($value) || (int) $value === 0) {
return new \DateInterval('PT0S');
}
return $this->duration->format(0);
$value = (int) $value;
$seconds = abs($value);
$hours = intdiv($seconds, 3600);
$seconds %= 3600;
$minutes = intdiv($seconds, 60);
$seconds %= 60;
$intervalSpec = \sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds);
$interval = new \DateInterval($intervalSpec);
if ($value < 0) {
$interval->invert = 1;
}
return $interval;
}
public function getFormat(): string
{
return '[hh]:mm:ss';
}
}

View File

@@ -0,0 +1,37 @@
<?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\Export\Package\CellFormatter;
final class DurationPlainFormatter implements CellFormatterInterface
{
public function formatValue(mixed $value): mixed
{
if (!is_numeric($value) || (int) $value === 0) {
return '0:00:00';
}
$value = (int) $value;
$seconds = abs($value);
$hours = intdiv($seconds, 3600);
$seconds %= 3600;
$minutes = intdiv($seconds, 60);
$seconds %= 60;
$intervalSpec = \sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds);
$interval = new \DateInterval($intervalSpec);
if ($value < 0) {
$interval->invert = 1;
}
return $interval->format('%r%h:%I:%S');
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Export\Package;
use App\Entity\ExportableItem;
use App\Export\Package\CellFormatter\CellFormatterInterface;
use App\Export\Package\CellFormatter\CellWithFormatInterface;
class Column
{
@@ -71,4 +72,13 @@ class Column
{
return $this->header ?? $this->name;
}
public function getFormat(): ?string
{
if ($this->formatter instanceof CellWithFormatInterface) {
return $this->formatter->getFormat();
}
return null;
}
}

View File

@@ -26,7 +26,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class SpoutSpreadsheet implements SpreadsheetPackage
{
private Style $dateStyle;
/** @var array<int, Style|null> */
private array $styles = [];
public function __construct(
private readonly WriterInterface $writer,
@@ -34,7 +35,6 @@ class SpoutSpreadsheet implements SpreadsheetPackage
)
{
$this->writer->setCreator(Constants::SOFTWARE);
$this->dateStyle = (new Style())->setFormat('yyyy-mm-dd');
}
/**
@@ -48,9 +48,15 @@ class SpoutSpreadsheet implements SpreadsheetPackage
}
$tmp = [];
$i = 0;
foreach ($columns as $column) {
$title = $this->translator->trans($column->getHeader());
$tmp[] = Cell::fromValue($title);
$style = null;
if ($column->getFormat() !== null) {
$style = (new Style())->setFormat($column->getFormat());
}
$this->styles[$i++] = $style;
}
$style = new Style();
@@ -100,12 +106,9 @@ class SpoutSpreadsheet implements SpreadsheetPackage
}
$tmp = [];
$i = 0;
foreach ($columns as $column) {
if ($column instanceof \DateTimeInterface) {
$tmp[] = Cell::fromValue($column, $this->dateStyle);
} else {
$tmp[] = Cell::fromValue($column); // @phpstan-ignore argument.type
}
$tmp[] = Cell::fromValue($column, $this->styles[$i++]); // @phpstan-ignore argument.type
}
$this->writer->addRow(new Row($tmp, $style));

View File

@@ -207,15 +207,22 @@ class TimesheetEditForm extends AbstractType
/** @var \DateTime|null $date */
$date = $event->getForm()->get('begin_date')->getData();
/** @var \DateTime|null $time */
$time = $event->getForm()->get('begin_time')->getData();
if ($date === null || $time === null) {
return;
}
// if the user did not change the time, make sure to keep the seconds
$seconds = 0;
if ($data->getBegin()?->format('H:i') === $time->format('H:i')) {
$seconds = $data->getBegin()->format('s') ?? 0;
}
// mutable datetime are a problem for doctrine
$newDate = clone $date;
$newDate->setTime($time->format('H'), $time->format('i'));
$newDate->setTime((int) $time->format('H'), (int) $time->format('i'), (int) $seconds);
if ($data->getBegin() === null || $data->getBegin()->getTimestamp() !== $newDate->getTimestamp()) {
$data->setBegin($newDate);
@@ -251,6 +258,7 @@ class TimesheetEditForm extends AbstractType
$timesheet = $event->getData();
$oldEnd = $timesheet->getEnd();
/** @var \DateTime|null $end */
$end = $event->getForm()->get('end_time')->getData();
if ($end === null || $end === false) {
$timesheet->setEnd(null);
@@ -267,8 +275,15 @@ class TimesheetEditForm extends AbstractType
if ($time === null) {
throw new \Exception('Cannot work with timesheets without start time');
}
// if the user did not change the time, make sure to keep the seconds
$seconds = 0;
if ($oldEnd !== null && $oldEnd->format('H:i') === $end->format('H:i')) {
$seconds = $oldEnd->format('s') ?? 0;
}
$newEnd = clone $time;
$newEnd->setTime($end->format('H'), $end->format('i'));
$newEnd->setTime((int) $end->format('H'), (int) $end->format('i'), (int) $seconds);
if ($newEnd < $time) {
$newEnd->modify('+ 1 day');

View File

@@ -18,11 +18,17 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
public function hydrate(InvoiceModel $model): array
{
$template = $model->getTemplate();
if ($template === null) {
throw new \InvalidArgumentException('InvoiceModel needs a template');
}
$currency = $model->getCurrency();
$tax = $model->getCalculator()->getTax();
$total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal();
$formatter = $model->getFormatter();
$language = $template->getLanguage();
$values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
@@ -31,7 +37,7 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.date_process' => $model->getInvoiceDate()->format(self::DATE_PROCESS_FORMAT), // since 2.14
'invoice.number' => $model->getInvoiceNumber(),
'invoice.currency' => $currency,
'invoice.language' => $model->getTemplate()->getLanguage(), // since 1.9
'invoice.language' => $language, // since 1.9
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00,
@@ -47,15 +53,17 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.subtotal_nc' => $formatter->getFormattedMoney($subtotal, $currency, false),
'invoice.subtotal_plain' => $subtotal,
'template.name' => $model->getTemplate()->getName() ?? '',
'template.company' => $model->getTemplate()->getCompany() ?? '',
'template.address' => $model->getTemplate()->getAddress() ?? '',
'template.title' => $model->getTemplate()->getTitle() ?? '',
'template.payment_terms' => $model->getTemplate()->getPaymentTerms() ?? '',
'template.due_days' => $model->getTemplate()->getDueDays(),
'template.vat_id' => $model->getTemplate()->getVatId() ?? '',
'template.contact' => $model->getTemplate()->getContact() ?? '',
'template.payment_details' => $model->getTemplate()->getPaymentDetails() ?? '',
'template.name' => $template->getName() ?? '',
'template.company' => $template->getCompany() ?? '',
'template.address' => $template->getAddress() ?? '',
'template.title' => $template->getTitle() ?? '',
'template.payment_terms' => $template->getPaymentTerms() ?? '',
'template.due_days' => $template->getDueDays(),
'template.vat_id' => $template->getVatId() ?? '',
'template.contact' => $template->getContact() ?? '',
'template.country' => null,
'template.country_name' => null,
'template.payment_details' => $template->getPaymentDetails() ?? '',
'query.begin' => '',
'query.begin_day' => '',

View File

@@ -128,12 +128,23 @@ final class ProjectService
// we cannot use max(number) because a varchar column returns unexpected results
$start = $this->repository->countProject();
$i = 0;
$createDate = new \DateTimeImmutable();
do {
$start++;
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start): string|int {
$numberGenerator = new NumberGenerator($format, function (string $originalFormat, string $format, int $increaseBy) use ($start, $createDate): string|int {
return match ($format) {
'Y' => $createDate->format('Y'),
'y' => $createDate->format('y'),
'M' => $createDate->format('m'),
'm' => $createDate->format('n'),
'D' => $createDate->format('d'),
'd' => $createDate->format('j'),
'YY' => (int) $createDate->format('Y') + $increaseBy,
'yy' => (int) $createDate->format('y') + $increaseBy,
'MM' => (int) $createDate->format('m') + $increaseBy,
'DD' => (int) $createDate->format('d') + $increaseBy,
'pc' => $start + $increaseBy,
default => $originalFormat,
};

View File

@@ -48,8 +48,14 @@ final class DateTimeFactory
return $date;
}
/**
* @deprecated
* @codeCoverageIgnore
*/
public function getStartOfLastMonth(): DateTimeInterface
{
@trigger_error('The method "DateTimeFactory::getStartOfLastMonth()" will be removed soon', E_USER_DEPRECATED);
$date = $this->createDateTime('first day of -1 month');
$date = $date->setTime(0, 0, 0);
@@ -66,9 +72,13 @@ final class DateTimeFactory
return $this->createDateTime($date);
}
return DateTime::createFromInterface($date);
return $this->createDateTime($date->format('Y-m-d H:i:s'));
}
/**
* @deprecated
* @codeCoverageIgnore
*/
private function createDate(DateTimeInterface|string|null $date = null): \DateTimeImmutable
{
if ($date === null) {
@@ -79,7 +89,7 @@ final class DateTimeFactory
return $this->create($date);
}
return \DateTimeImmutable::createFromInterface($date);
return $this->create($date->format('Y-m-d H:i:s'));
}
public function getStartOfWeek(DateTimeInterface|string|null $date = null): DateTime
@@ -125,8 +135,14 @@ final class DateTimeFactory
return $date;
}
/**
* @deprecated
* @codeCoverageIgnore
*/
public function getEndOfLastMonth(): DateTimeInterface
{
@trigger_error('The method "DateTimeFactory::getEndOfLastMonth()" will be removed soon', E_USER_DEPRECATED);
$date = $this->createDateTime('last day of -1 month');
$date = $date->setTime(23, 59, 59);
@@ -152,15 +168,27 @@ final class DateTimeFactory
return new \DateTimeImmutable($datetime, $this->getTimezone());
}
/**
* @deprecated
* @codeCoverageIgnore
*/
public function createStartOfDay(DateTimeInterface|string|null $date = null): \DateTimeImmutable
{
@trigger_error('The method "DateTimeFactory::createStartOfDay()" will be removed soon', E_USER_DEPRECATED);
$date = $this->createDate($date);
return $date->modify('00:00:00');
}
/**
* @deprecated
* @codeCoverageIgnore
*/
public function createEndOfDay(DateTimeInterface|string|null $date = null): \DateTimeImmutable
{
@trigger_error('The method "DateTimeFactory::createEndOfDay()" will be removed soon', E_USER_DEPRECATED);
$date = $this->createDate($date);
return $date->modify('23:59:59');

View File

@@ -97,9 +97,9 @@ final class ThemeExtension implements RuntimeExtensionInterface
return $class;
}
public function generateTitle(?string $prefix = null, string $delimiter = ' '): string
public function generateTitle(): string
{
return ($prefix ?? '') . Constants::SOFTWARE;
return Constants::SOFTWARE;
}
public function colorize(?string $color, ?string $identifier = null): string