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

@@ -18,12 +18,14 @@ use App\Entity\Timesheet;
final class Color
{
// @see https://clrs.cc
public const PALETTE_1 = ['#0074D9', '#7FDBFF', '#39CCCC', '#B10DC9', '#F012BE', '#85144b', '#FF4136', '#FF851B', '#FFDC00', '#3D9970', '#2ECC40', '#01FF70', '#AAAAAA', '#DDDDDD'];
// old avatar color set
public const PALETTE_2 = ['#a972c9', '#9C27B0', '#673AB7', '#5319e7', '#041fd1', '#3F51B5', '#2196F3', '#03A9F4', '#00BCD4', '#006b75', '#009688', '#00bb32', '#4CAF50', '#8BC34A', '#CDDC39', '#FFC107', '#FF9800', '#FF5722', '#f41a00', '#E91E63', '#b60205', '#cc317c', '#d82d80', '#e135f4', '#2d3748', '#4a5568', '#718096'];
// all mixed together
public const PALETTE_3 = ['#AAAAAA', '#DDDDDD', '#a972c9', '#9C27B0', '#673AB7', '#041fd1', '#5319e7', '#3F51B5', '#0074D9', '#2196F3', '#03A9F4', '#7FDBFF', '#39CCCC', '#00BCD4', '#006b75', '#009688', '#00bb32', '#4CAF50', '#3D9970', '#2ECC40', '#01FF70', '#8BC34A', '#CDDC39', '#FFDC00', '#FFC107', '#FF851B', '#FF9800', '#FF5722', '#f41a00', '#E91E63', '#85144b', '#b60205', '#FF4136', '#cc317c', '#F012BE', '#d82d80', '#B10DC9', '#e135f4', '#2d3748', '#4a5568', '#718096'];
private const PALETTE = [
'#AAAAAA', '#DDDDDD', '#a972c9', '#9C27B0', '#673AB7', '#041fd1', '#5319e7',
'#3F51B5', '#0074D9', '#2196F3', '#03A9F4', '#7FDBFF', '#39CCCC', '#00BCD4',
'#006b75', '#009688', '#00bb32', '#4CAF50', '#3D9970', '#2ECC40', '#01FF70',
'#8BC34A', '#CDDC39', '#FFDC00', '#FFC107', '#FF851B', '#FF9800', '#FF5722',
'#f41a00', '#E91E63', '#85144b', '#b60205', '#FF4136', '#cc317c', '#F012BE',
'#d82d80', '#B10DC9', '#e135f4', '#2d3748', '#4a5568', '#718096'
];
public function getTimesheetColor(Timesheet $timesheet): string
{
@@ -104,10 +106,9 @@ final class Color
$id += mb_ord($input[$pos], 'UTF-8');
}
$colors = self::PALETTE_3;
$key = $id % \count($colors);
$key = $id % \count(self::PALETTE);
return $colors[$key];
return self::PALETTE[$key];
}
public function getFontContrastColor(string $color): string

View File

@@ -1,70 +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\Utils;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
final class CommandStyle
{
private $input;
private $output;
private $style;
public function __construct(InputInterface $input, OutputInterface $output)
{
$this->input = $input;
$this->output = $output;
}
private function getStyle(): SymfonyStyle
{
if ($this->style === null) {
$this->style = new SymfonyStyle($this->input, $this->output);
}
return $this->style;
}
public function success($message): void
{
$this->getStyle()->success($message);
}
public function error($message): void
{
$this->getStyle()->error($message);
}
public function warning($message): void
{
$this->getStyle()->warning($message);
}
public function validationError(ValidationFailedException $exception): void
{
$errors = $exception->getViolations();
if ($errors->count() > 0) {
$style = $this->getStyle();
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach ($errors as $error) {
$value = $error->getInvalidValue();
$style->error(
$error->getPropertyPath()
. ' (' . (\is_array($value) ? implode(',', $value) : $value) . ')'
. "\n "
. $error->getMessage()
);
}
}
}
}

View File

@@ -13,11 +13,8 @@ use App\Entity\User;
final class Context
{
private $user;
public function __construct(User $user)
public function __construct(private User $user)
{
$this->user = $user;
}
public function getUser(): User

191
src/Utils/DataTable.php Normal file
View File

@@ -0,0 +1,191 @@
<?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\Utils;
use App\Repository\Query\BaseQuery;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Traversable;
final class DataTable implements \Countable, \IteratorAggregate
{
private ?Pagination $pagination = null;
private ?FormInterface $searchForm = null;
private ?FormInterface $batchForm = null;
private array $columns = [];
private array $reloadEvents = [];
private bool $configuration = true;
private bool $sticky = true;
private ?string $paginationRoute = null;
public function __construct(private string $tableName, private BaseQuery $query)
{
}
public function hasResults(): bool
{
return $this->pagination !== null && $this->pagination->count() > 0;
}
public function getResults(): ?iterable
{
return $this->pagination;
}
public function getPagination(): ?Pagination
{
return $this->pagination;
}
public function setPagination(?Pagination $pagination): void
{
$this->pagination = $pagination;
}
public function getTableName(): string
{
return $this->tableName;
}
public function getQuery(): BaseQuery
{
return $this->query;
}
public function getSearchForm(): ?FormView
{
return $this->searchForm?->createView();
}
public function setSearchForm(?FormInterface $searchForm): void
{
$this->searchForm = $searchForm;
}
public function hasBatchForm(): bool
{
return $this->batchForm !== null;
}
public function getBatchForm(): ?FormView
{
return $this->batchForm?->createView();
}
public function setBatchForm(?FormInterface $batchForm): void
{
$this->batchForm = $batchForm;
if (!\array_key_exists('id', $this->columns)) {
$this->addColumn('id', [
'class' => 'alwaysVisible multiCheckbox',
'orderBy' => false,
'title' => false,
'batchUpdate' => true
]);
}
}
public function getSortedColumnNames(): array
{
$columns = [];
foreach ($this->columns as $key => $options) {
$columns[$key] = \array_key_exists('data', $options) ? $options['data'] : [];
}
return $columns;
}
public function getColumns(): array
{
return $this->columns;
}
public function setColumns(array $columns): void
{
$this->columns = $columns;
}
public function addColumn(string $name, array $column): void
{
$this->columns[$name] = $column;
}
public function deactivateConfiguration(): void
{
$this->configuration = false;
}
public function hasConfiguration(): bool
{
return $this->configuration;
}
public function getPaginationRoute(): ?string
{
return $this->paginationRoute;
}
public function setPaginationRoute(?string $paginationRoute): void
{
$this->paginationRoute = $paginationRoute;
}
public function getOptions(): array
{
$options = [
'columnConfig' => false,
'sticky' => $this->sticky,
];
if (\count($this->reloadEvents) > 0) {
$options['reload'] = $this->getReloadEvents();
}
return $options;
}
public function getReloadEvents(): string
{
return implode(' ', $this->reloadEvents);
}
public function setReloadEvents(string|array $reloadEvents): void
{
if (\is_string($reloadEvents)) {
$reloadEvents = explode(' ', $reloadEvents);
}
$this->reloadEvents = $reloadEvents;
}
public function addReloadEvent(string $reloadEvent): void
{
$this->reloadEvents[] = $reloadEvent;
}
public function setSticky(bool $sticky = true): void
{
$this->sticky = $sticky;
}
public function getIterator(): Traversable
{
return $this->pagination?->getIterator();
}
public function count(): int
{
if ($this->pagination === null) {
return 0;
}
return $this->pagination->count();
}
}

View File

@@ -1,33 +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\Utils;
class DateFormatConverter
{
/**
* This defines the mapping between PHP date format (key) and ICU date format (value).
* https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
*
* @var array
*/
private static $formatConvertRules = [
// hours
'h' => 'hh', 'H' => 'HH',
// minutes
'i' => 'mm',
// am/pm to AM/PM
'A' => 'a'
];
public function convert(string $format): string
{
return strtr($format, self::$formatConvertRules);
}
}

View File

@@ -12,21 +12,12 @@ namespace App\Utils;
/**
* Convert duration strings into seconds.
*/
class Duration
final class Duration
{
public const FORMAT_COLON = 'colon';
public const FORMAT_NATURAL = 'natural';
public const FORMAT_DECIMAL = 'decimal';
/**
* @deprecated since 1.13
*/
public const FORMAT_SECONDS = 'seconds';
/**
* @deprecated since 1.21
*/
public const FORMAT_WITH_SECONDS = '%h:%m';
public const FORMAT_NO_SECONDS = '%h:%m';
public const FORMAT_DEFAULT = '%h:%m';
/**
* Transforms seconds into a duration string.
@@ -35,7 +26,7 @@ class Duration
* @param string $format
* @return string|null
*/
public function format(?int $seconds, string $format = self::FORMAT_NO_SECONDS)
public function format(?int $seconds, string $format = self::FORMAT_DEFAULT): ?string
{
if (null === $seconds) {
return null;
@@ -50,12 +41,11 @@ class Duration
$hour = (int) floor($seconds / 3600);
$minute = (int) floor((int) ($seconds / 60) % 60);
$hour = $hour > 9 ? $hour : '0' . $hour;
$minute = $minute > 9 ? $minute : '0' . $minute;
$formatted = str_replace('%h', $hour, $format);
return str_replace('%m', $minute, $formatted);
$formatted = str_replace('%h', (string) $hour, $format);
return str_replace('%m', (string) $minute, $formatted);
}
/**
@@ -70,11 +60,7 @@ class Duration
return $this->parseDuration($duration, self::FORMAT_COLON);
}
if (strpos($duration, '.') !== false || strpos($duration, ',') !== false) {
return $this->parseDuration($duration, self::FORMAT_DECIMAL);
}
if (is_numeric($duration) && $duration == (int) $duration) {
if (str_contains($duration, '.') || str_contains($duration, ',') || is_numeric($duration)) {
return $this->parseDuration($duration, self::FORMAT_DECIMAL);
}
@@ -95,32 +81,15 @@ class Duration
return 0;
}
switch ($mode) {
case self::FORMAT_COLON:
$seconds = $this->parseColonFormat($duration);
break;
case self::FORMAT_NATURAL:
$seconds = $this->parseNaturalFormat($duration);
break;
case self::FORMAT_DECIMAL:
$seconds = $this->parseDecimalFormat($duration);
break;
case self::FORMAT_SECONDS:
@trigger_error('Duration format FORMAT_SECONDS is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$seconds = (int) $duration;
break;
default:
throw new \InvalidArgumentException(sprintf('Unsupported duration format "%s"', $mode));
}
return $seconds;
return match ($mode) {
self::FORMAT_COLON => $this->parseColonFormat($duration),
self::FORMAT_NATURAL => $this->parseNaturalFormat($duration),
self::FORMAT_DECIMAL => $this->parseDecimalFormat($duration),
default => throw new \InvalidArgumentException(sprintf('Unsupported duration format "%s"', $mode)),
};
}
protected function parseNaturalFormat(string $duration): int
private function parseNaturalFormat(string $duration): int
{
try {
$interval = new \DateInterval('PT' . strtoupper($duration));
@@ -133,7 +102,7 @@ class Duration
}
}
protected function parseDecimalFormat(string $duration): int
private function parseDecimalFormat(string $duration): int
{
$duration = str_replace(',', '.', $duration);
$duration = (float) $duration;
@@ -142,7 +111,7 @@ class Duration
return (int) $duration;
}
protected function parseColonFormat(string $duration): int
private function parseColonFormat(string $duration): int
{
$parts = explode(':', $duration);
if (\count($parts) < 2 || \count($parts) > 3) {
@@ -168,7 +137,7 @@ class Duration
$seconds = 0;
if (3 == \count($parts)) {
if (3 === \count($parts)) {
$seconds += (int) array_pop($parts);
}

View File

@@ -14,25 +14,17 @@ use Symfony\Component\String\UnicodeString;
final class FileHelper
{
/**
* @var string
*/
private $dataDir;
/**
* @var Filesystem
*/
private $filesystem;
private Filesystem $filesystem;
public function __construct(string $dataDir)
public function __construct(private string $dataDir)
{
$this->dataDir = $dataDir;
$this->filesystem = new Filesystem();
}
/**
* @CloudRequired
*/
public function setDataDirectory(string $directory)
public function setDataDirectory(string $directory): void
{
$this->dataDir = $directory;
}

View File

@@ -0,0 +1,96 @@
<?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\Utils;
final class FormFormatConverter
{
public const PATTERN_DAY_SINGLE = '(([1-9])|([1-2][0-9])|(3[01]))';
public const PATTERN_DAY_DOUBLE = '((0[1-9])|([1-2][0-9])|(3[01]))';
public const PATTERN_MONTH_SINGLE = '(([1-9])|([1][0-2]))';
public const PATTERN_MONTH_DOUBLE = '((0[1-9])|([1][0-2]))';
public const PATTERN_YEAR = '(19|20)\d{2}';
public const PATTERN_HOUR_SINGLE = '([0-9]|[1][0-9]|2[0-3])';
public const PATTERN_HOUR_DOUBLE = '([0-9]|[01][0-9]|2[0-3])';
public const PATTERN_MINUTES = '([0-5][0-9])';
/**
* This defines the mapping between ICU date format and PHP Date format.
*
* @see https://www.php.net/manual/en/datetime.format.php
* @var array
*/
private static array $formatConvertRules = [
// Litepicker interprets a year like 22 as 1922 instead of 2022
// so we have to make sure that it is always a4-digit year
"'h'" => "\h", // special format for fr_CA which includes 'h' as character
'yy' => 'yyyy',
'y' => 'yyyy',
'mm' => 'i', // ICU 2 letter minutes
'a' => 'A', // uppercase AM/PM, Luxon only supports uppercase
'HH' => 'H', // H = 24-hour format of an hour with leading zeros
'h' => 'g', // g = 12-hour format of an hour without leading zeros 1 through 12
'H' => 'G', // G = 24-hour format of an hour without leading zeros 0 through 23
// h = 12-hour format of an hour with leading zeros 01 through 12
];
public function convert(string $format): string
{
return strtr($format, self::$formatConvertRules);
}
/**
* This works with ICU and DateTime format.
*
* @param string $format
* @param bool $html
* @return string
*/
public function convertToPattern(string $format, bool $html = true): string
{
if (!$html) {
$format = preg_quote($format, '/');
}
$pattern = $format;
// special case fr_CA
$pattern = str_replace('\\\\h', '*****', $pattern);
$pattern = str_replace('\\h', '*****', $pattern);
$pattern = str_replace("'h'", '*****', $pattern);
// days
$pattern = str_replace('dd', self::PATTERN_DAY_DOUBLE, $pattern);
$pattern = str_replace('d', self::PATTERN_DAY_SINGLE, $pattern);
// months
$pattern = str_replace('MM', self::PATTERN_MONTH_DOUBLE, $pattern);
$pattern = str_replace('M', self::PATTERN_MONTH_SINGLE, $pattern);
// years
$pattern = str_replace('yyyy', self::PATTERN_YEAR, $pattern);
$pattern = str_replace('yy', self::PATTERN_YEAR, $pattern);
$pattern = str_replace('y', self::PATTERN_YEAR, $pattern);
// time
$pattern = str_replace('HH', self::PATTERN_HOUR_DOUBLE, $pattern);
$pattern = str_replace('H', self::PATTERN_HOUR_DOUBLE, $pattern);
$pattern = str_replace('G', self::PATTERN_HOUR_SINGLE, $pattern);
$pattern = str_replace('h', self::PATTERN_HOUR_SINGLE, $pattern);
$pattern = str_replace('g', self::PATTERN_HOUR_SINGLE, $pattern);
$pattern = str_replace('i', self::PATTERN_MINUTES, $pattern);
$pattern = str_replace('mm', self::PATTERN_MINUTES, $pattern);
$pattern = str_replace('A', '(AM|PM){1}', $pattern);
$pattern = str_replace('a', '(AM|PM){1}', $pattern);
$pattern = str_replace('*****', 'h', $pattern);
if (!$html) {
$pattern = '/^' . $pattern . '$/';
}
return $pattern;
}
}

View File

@@ -1,24 +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\Utils;
interface HtmlToPdfConverter
{
/**
* Returns the binary content of the PDF, which can be saved as file.
* Throws an exception if conversion fails.
*
* @param string $html
* @param array $options
* @return string
* @throws \Exception
*/
public function convertToPdf(string $html, array $options = []): string;
}

View File

@@ -0,0 +1,44 @@
<?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\Utils;
final class JavascriptFormatConverter
{
/**
* Convert PHP date format to Luxon compatible format.
*
* @see https://moment.github.io/luxon/#/formatting?id=table-of-tokens
* @see https://www.php.net/manual/en/datetime.format.php
* @var array
*/
private static array $formatConvertRules = [
// year: Litepicker interprets 2-digit year as 1900, so we have to convert 20 to 2022.
'yyyy' => 'YYYY', 'yy' => 'YYYY', 'y' => 'YYYY',
// day
'dd' => 'DD', 'd' => 'D',
// day of week
'EE' => 'ddd', 'EEEEEE' => 'dd',
// timezone
'ZZZZZ' => 'Z', 'ZZZ' => 'ZZ',
// letter 'T'
'\'T\'' => 'T',
// am/pm (a) to AM/PM (A) - Luxon always produces uppercase AM/PM
'a' => 'A',
];
/**
* The output of this format is used only to convert the Litepicker date object
* to the input field (expected by Symfony form).
*/
public function convert(string $format): string
{
return strtr($format, self::$formatConvertRules);
}
}

View File

@@ -1,49 +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\Utils;
use App\Constants;
final class LanguageService
{
/**
* @var string[]|string
*/
private $locales;
public function __construct(string $locales)
{
$this->locales = $locales;
}
/**
* @return string[]
*/
public function getAllLanguages(): array
{
if (!\is_array($this->locales)) {
// no further checks, because the list of languages is hard coded and we can be sure that
// it is well formatted and contains the default langauge english
$this->locales = array_unique(explode('|', trim($this->locales)));
}
return $this->locales;
}
public function isKnownLanguage(string $language): bool
{
return \in_array($language, $this->getAllLanguages());
}
public function getDefaultLanguage(): string
{
return Constants::DEFAULT_LOCALE;
}
}

View File

@@ -1,125 +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\Utils;
use App\Configuration\LanguageFormattings;
use App\Constants;
/**
* Use this class, when you want information about formats for a locale.
*/
class LocaleFormats
{
/**
* @var LanguageFormattings
*/
private $formats;
/**
* @var string
*/
private $locale = Constants::DEFAULT_LOCALE;
public function __construct(LanguageFormattings $formats, string $locale)
{
$this->formats = $formats;
$this->locale = $locale;
}
/**
* Returns an array with all available locale/language codes.
*
* @return string[]
*/
public function getAvailableLanguages(): array
{
return $this->formats->getAvailableLanguages();
}
/**
* Returns the current locale used by the user in this request.
*
* @return string
*/
public function getLocale(): string
{
return $this->locale;
}
/**
* Returns the format which is used by the form component to handle date values.
*
* @return string
*/
public function getDateTypeFormat(): string
{
return $this->formats->getDateTypeFormat($this->getLocale());
}
/**
* Returns the format which is used by the Javascript component to handle date values.
*
* @return string
*/
public function getDatePickerFormat(): string
{
return $this->formats->getDatePickerFormat($this->getLocale());
}
/**
* Returns the format which is used by the form component to handle datetime values.
*
* @deprecated since 1.16
* @return string
*/
public function getDateTimeTypeFormat(): string
{
return $this->formats->getDateTypeFormat($this->getLocale()) . ' HH:mm';
}
/**
* Returns the locale specific date format, which should be used in combination with the twig filter "|date".
*
* @return string
*/
public function getDateFormat(): string
{
return $this->formats->getDateFormat($this->getLocale());
}
/**
* Returns the locale specific time format, which should be used in combination with the twig filter "|time".
*
* @return string
*/
public function getTimeFormat(): string
{
return $this->formats->getTimeFormat($this->getLocale());
}
/**
* Returns the locale specific datetime format, which should be used in combination with the twig filter "|date".
*
* @return string
*/
public function getDateTimeFormat(): string
{
return $this->formats->getDateTimeFormat($this->getLocale());
}
/**
* Returns the format used in the "|duration" twig filter to display a Timesheet duration.
*
* @return string
*/
public function getDurationFormat(): string
{
return $this->formats->getDurationFormat($this->getLocale());
}
}

View File

@@ -9,103 +9,67 @@
namespace App\Utils;
use App\Configuration\LanguageFormattings;
use App\Configuration\LocaleService;
use App\Entity\Timesheet;
use DateTime;
use Exception;
use IntlDateFormatter;
use Symfony\Component\Intl\Locales;
use NumberFormatter;
use Symfony\Component\Intl\Currencies;
/**
* Use this class to format values into locale specific representations.
*/
final class LocaleFormatter
{
/**
* @var LocaleFormats
*/
private $localeFormats;
/**
* @var Duration
*/
private $durationFormatter;
/**
* @var LocaleHelper
*/
private $helper;
/**
* @var string
*/
private $locale;
// ---------------- private cache below ----------------
/**
* @var string
*/
private $dateFormat = null;
/**
* @var string
*/
private $dateTimeFormat = null;
/**
* @var string
*/
private $dateTypeFormat = null;
/**
* @var string
*/
private $dateTimeTypeFormat = null;
/**
* @var string
*/
private $timeFormat = null;
private ?Duration $durationFormatter = null;
private ?IntlDateFormatter $dateFormatter = null;
private ?IntlDateFormatter $dateTimeFormatter = null;
private ?IntlDateFormatter $timeFormatter = null;
private ?NumberFormatter $numberFormatter = null;
private ?NumberFormatter $decimalFormatter = null;
private ?NumberFormatter $moneyFormatter = null;
private ?NumberFormatter $moneyFormatterNoCurrency = null;
public function __construct(LanguageFormattings $formats, string $locale)
public function __construct(private LocaleService $localeService, private string $locale)
{
$this->locale = $locale;
$this->durationFormatter = new Duration();
$this->helper = new LocaleHelper($locale);
$this->localeFormats = new LocaleFormats($formats, $locale);
}
/**
* Transforms seconds into a duration string.
*
* @param int|Timesheet|null $duration
* @param bool $decimal
* @return string
*/
public function duration($duration, $decimal = false)
public function duration(int|Timesheet|string|null $duration, bool $decimal = false): string
{
if ($decimal) {
return $this->durationDecimal($duration);
}
$seconds = $this->getSecondsForDuration($duration);
$format = $this->localeFormats->getDurationFormat();
return $this->formatDuration($seconds, $format);
return $this->formatDuration(
$this->getSecondsForDuration($duration),
$this->localeService->getDurationFormat($this->locale)
);
}
/**
* Transforms seconds into a decimal formatted duration string.
*
* @param int|Timesheet|null $duration
* @return string
*/
public function durationDecimal($duration)
public function durationDecimal(Timesheet|int|string|null $duration): string
{
if (null === $this->numberFormatter) {
$this->decimalFormatter = new NumberFormatter($this->locale, NumberFormatter::DECIMAL);
$this->decimalFormatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2);
}
$seconds = $this->getSecondsForDuration($duration);
return $this->helper->durationDecimal($seconds);
$value = round($seconds / 3600, 2);
return $this->decimalFormatter->format($value);
}
/**
* @param int|Timesheet|null $duration
* @return int
*/
private function getSecondsForDuration($duration): int
private function getSecondsForDuration(string|int|Timesheet|null $duration): int
{
if (null === $duration) {
if ($duration === null || $duration === '') {
return 0;
}
@@ -113,7 +77,7 @@ final class LocaleFormatter
if (null === $duration->getEnd()) {
$duration = time() - $duration->getBegin()->getTimestamp();
} else {
$duration = $duration->getDuration();
$duration = $duration->getDuration() ?? 0;
}
}
@@ -122,209 +86,202 @@ final class LocaleFormatter
private function formatDuration(int $seconds, string $format): string
{
if ($this->durationFormatter === null) {
$this->durationFormatter = new Duration();
}
return $this->durationFormatter->format($seconds, $format);
}
/**
* @param string|float $amount
* @return bool|false|string
* Used in twig filter |amount and invoice templates.
*/
public function amount($amount)
public function amount(null|int|float|string $amount): string
{
return $this->helper->amount($amount);
if ($amount === null || $amount === '') {
return '0';
}
if (null === $this->numberFormatter) {
$this->numberFormatter = new NumberFormatter($this->locale, NumberFormatter::DECIMAL);
}
$formatted = $this->numberFormatter->format($amount);
if (!\is_string($formatted)) {
throw new \Exception('Could not convert into monetary string: ' . $amount);
}
return $formatted;
}
/**
* Returns the currency symbol.
*
* @param string $currency
* @return string
*/
public function currency($currency)
public function currency(?string $currency): string
{
return $this->helper->currency($currency);
}
/**
* @param string $language
* @return string
*/
public function language($language)
{
return $this->helper->language($language);
}
/**
* @param string $country
* @return string
*/
public function country($country)
{
return $this->helper->country($country);
}
/**
* @param float $amount
* @param string|null $currency
* @param bool $withCurrency
* @return string
*/
public function money($amount, ?string $currency = null, bool $withCurrency = true)
{
return $this->helper->money($amount, $currency, $withCurrency);
}
/**
* Takes the list of codes of the locales (languages) enabled in the
* application and returns an array with the name of each locale written
* in its own language (e.g. English, Français, Español, etc.)
*
* @return array
*/
public function getLocales()
{
$locales = [];
foreach ($this->localeFormats->getAvailableLanguages() as $locale) {
$locales[] = ['code' => $locale, 'name' => Locales::getName($locale, $locale)];
if ($currency === null) {
return '';
}
return $locales;
}
/**
* @param DateTime|string $date
* @return string
*/
public function dateShort($date)
{
if (null === $this->dateFormat) {
$this->dateFormat = $this->localeFormats->getDateFormat();
try {
return Currencies::getSymbol(strtoupper($currency), $this->locale);
} catch (\Exception $ex) {
}
if (!$date instanceof DateTime) {
return $currency;
}
public function money(null|int|float $amount, ?string $currency = null, bool $withCurrency = true): string
{
if ($currency === null) {
$withCurrency = false;
}
if ($amount === null) {
$amount = 0;
}
if (false === $withCurrency) {
if (null === $this->moneyFormatterNoCurrency) {
$this->moneyFormatterNoCurrency = new NumberFormatter($this->locale, NumberFormatter::CURRENCY);
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::POSITIVE_PREFIX, '');
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::POSITIVE_SUFFIX, '');
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::NEGATIVE_PREFIX, '-');
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::NEGATIVE_SUFFIX, '');
}
return $this->moneyFormatterNoCurrency->format($amount, NumberFormatter::TYPE_DEFAULT);
}
if (null === $this->moneyFormatter) {
$this->moneyFormatter = new NumberFormatter($this->locale, NumberFormatter::CURRENCY);
}
return $this->moneyFormatter->formatCurrency($amount, $currency);
}
public function dateShort(\DateTimeInterface|string|null $date): ?string
{
if ($date === null || $date === '') {
return null;
}
if (null === $this->dateFormatter) {
$this->dateFormatter = new IntlDateFormatter(
$this->locale,
IntlDateFormatter::MEDIUM,
IntlDateFormatter::MEDIUM,
date_default_timezone_get(),
IntlDateFormatter::GREGORIAN,
$this->localeService->getDateFormat($this->locale)
);
}
if (!$date instanceof \DateTimeInterface) {
try {
$date = new DateTime($date);
$date = new \DateTimeImmutable($date);
} catch (Exception $ex) {
return $date;
return null;
}
}
return $date->format($this->dateFormat);
}
$formatted = $this->dateFormatter->format($date);
private function getDateTypeFormat(): string
{
if (null === $this->dateTypeFormat) {
$this->dateTypeFormat = $this->localeFormats->getDateTypeFormat();
if ($formatted === false) {
return null;
}
return $this->dateTypeFormat;
return (string) $formatted;
}
/**
* @param DateTime|string $date
* @return string
*/
public function dateTime($date)
public function dateTime(DateTime|string|null $date): ?string
{
if (null === $this->dateTimeFormat) {
$this->dateTimeFormat = $this->localeFormats->getDateTimeFormat();
if ($date === null || $date === '') {
return null;
}
if (!$date instanceof DateTime) {
if (null === $this->dateTimeFormatter) {
$this->dateTimeFormatter = new IntlDateFormatter(
$this->locale,
IntlDateFormatter::MEDIUM,
IntlDateFormatter::MEDIUM,
date_default_timezone_get(),
IntlDateFormatter::GREGORIAN,
$this->localeService->getDateTimeFormat($this->locale)
);
}
if (!$date instanceof \DateTimeInterface) {
try {
$date = new DateTime($date);
$date = new \DateTimeImmutable($date);
} catch (Exception $ex) {
return $date;
return null;
}
}
return $date->format($this->dateTimeFormat);
$formatted = $this->dateTimeFormatter->format($date);
if ($formatted === false) {
return null;
}
return (string) $formatted;
}
/**
* @param DateTime|string $date
* @param string $timeFormat
* @param bool $stripMidnight
* @return bool|false|string
*/
public function dateTimeFull($date, string $timeFormat, bool $stripMidnight = false)
public function dateFormat(\DateTimeInterface|string|null $date, string $format): ?string
{
if (null === $this->dateTimeTypeFormat) {
$converter = new DateFormatConverter();
$this->dateTimeTypeFormat = $this->getDateTypeFormat() . ' ' . $converter->convert($timeFormat);
if ($date === null || $date === '') {
return null;
}
if (!$date instanceof DateTime) {
if (!$date instanceof \DateTimeInterface) {
try {
$date = new DateTime($date);
$date = new \DateTimeImmutable($date);
} catch (Exception $ex) {
return $date;
}
}
$format = $this->dateTimeTypeFormat;
if ($stripMidnight && $date->format('H') == '00' && $date->format('i') == '00') {
$format = $this->localeFormats->getDateTypeFormat();
}
$formatter = new IntlDateFormatter(
$this->locale,
IntlDateFormatter::MEDIUM,
IntlDateFormatter::MEDIUM,
date_default_timezone_get(),
IntlDateFormatter::GREGORIAN,
$format
);
return $formatter->format($date);
}
/**
* @param DateTime|string $date
* @param string $format
* @return false|string
* @throws Exception
*/
public function dateFormat($date, string $format)
{
if (!$date instanceof DateTime) {
try {
$date = new DateTime($date);
} catch (Exception $ex) {
return $date;
return null;
}
}
return $date->format($format);
}
/**
* @param DateTime|string $date
* @return string
* @throws Exception
*/
public function time($date, string $format = null)
public function time(\DateTimeInterface|string|null $date): ?string
{
if (null === $this->timeFormat) {
$this->timeFormat = $this->localeFormats->getTimeFormat();
if ($date === null || $date === '') {
return null;
}
if (!$date instanceof DateTime) {
$date = new DateTime($date);
if (null === $this->timeFormatter) {
$this->timeFormatter = new IntlDateFormatter(
$this->locale,
IntlDateFormatter::MEDIUM,
IntlDateFormatter::MEDIUM,
date_default_timezone_get(),
IntlDateFormatter::GREGORIAN,
$this->localeService->getTimeFormat($this->locale)
);
}
return $date->format($format ?? $this->timeFormat);
if (!$date instanceof \DateTimeInterface) {
try {
$date = new \DateTimeImmutable($date);
} catch (Exception $ex) {
return $date;
}
}
$formatted = $this->timeFormatter->format($date);
if ($formatted === false) {
return null;
}
return (string) $formatted;
}
/**
* @see https://framework.zend.com/manual/1.12/en/zend.date.constants.html#zend.date.constants.selfdefinedformats
* @see http://userguide.icu-project.org/formatparse/datetime
*
* @param DateTime $dateTime
* @param string $format
* @return string
* @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/
*/
private function formatIntl(\DateTime $dateTime, string $format): string
{
@@ -337,7 +294,13 @@ final class LocaleFormatter
$format
);
return $formatter->format($dateTime);
$formatted = $formatter->format($dateTime);
if ($formatted === false) {
throw new \Exception('Invalid dateformat given for formatIntl()');
}
return (string) $formatted;
}
public function monthName(\DateTime $dateTime, bool $withYear = false): string

View File

@@ -1,185 +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\Utils;
use NumberFormatter;
use Symfony\Component\Intl\Countries;
use Symfony\Component\Intl\Currencies;
use Symfony\Component\Intl\Languages;
final class LocaleHelper
{
/**
* @var string
*/
private $locale;
/**
* @var NumberFormatter
*/
private $numberFormatter;
/**
* @var NumberFormatter
*/
private $durationFormatter;
/**
* @var NumberFormatter
*/
private $moneyFormatter;
/**
* @var NumberFormatter
*/
private $moneyFormatterNoCurrency;
public function __construct(string $locale)
{
$this->locale = $locale;
}
/**
* Transforms seconds into a decimal formatted duration string.
*
* @param int|null $seconds
* @return string
*/
public function durationDecimal(?int $seconds): string
{
if ($seconds === null) {
$seconds = 0;
}
$value = round($seconds / 3600, 2);
return $this->getDurationFormatter()->format($value);
}
/**
* Only used in twig filter |amount and invoice templates
*
* @param string|float|null $amount
* @return bool|false|string
*/
public function amount($amount)
{
if ($amount === null) {
$amount = 0.00;
}
return $this->getNumberFormatter()->format($amount);
}
/**
* @param string|null $currency
* @return string
*/
public function currency(?string $currency)
{
if ($currency === null) {
return '';
}
try {
return Currencies::getSymbol(strtoupper($currency), $this->locale);
} catch (\Exception $ex) {
}
return $currency;
}
/**
* @param string $language
* @return string
*/
public function language(string $language)
{
try {
return Languages::getName(strtolower($language), $this->locale);
} catch (\Exception $ex) {
}
return $language;
}
/**
* @param string $country
* @return string
*/
public function country(string $country)
{
try {
return Countries::getName(strtoupper($country), $this->locale);
} catch (\Exception $ex) {
}
return $country;
}
/**
* @param int|float|null $amount
* @param string|null $currency
* @param bool $withCurrency
* @return string
*/
public function money($amount, ?string $currency = null, bool $withCurrency = true)
{
if (null === $currency) {
$withCurrency = false;
}
if ($amount === null) {
$amount = 0;
}
if (false === $withCurrency) {
return $this->getMoneyFormatter($withCurrency)->format($amount, NumberFormatter::TYPE_DEFAULT);
}
return $this->getMoneyFormatter($withCurrency)->formatCurrency($amount, $currency);
}
private function getNumberFormatter(): NumberFormatter
{
if (null === $this->numberFormatter) {
$this->numberFormatter = new NumberFormatter($this->locale, NumberFormatter::DECIMAL);
}
return $this->numberFormatter;
}
private function getDurationFormatter(): NumberFormatter
{
if (null === $this->numberFormatter) {
$this->durationFormatter = new NumberFormatter($this->locale, NumberFormatter::DECIMAL);
$this->durationFormatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2);
}
return $this->durationFormatter;
}
private function getMoneyFormatter(bool $withCurrency = true): NumberFormatter
{
if ($withCurrency) {
if (null === $this->moneyFormatter) {
$this->moneyFormatter = new NumberFormatter($this->locale, NumberFormatter::CURRENCY);
}
return $this->moneyFormatter;
}
if (null === $this->moneyFormatterNoCurrency) {
$this->moneyFormatterNoCurrency = new NumberFormatter($this->locale, NumberFormatter::CURRENCY);
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::POSITIVE_PREFIX, '');
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::POSITIVE_SUFFIX, '');
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::NEGATIVE_PREFIX, '-');
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::NEGATIVE_SUFFIX, '');
}
return $this->moneyFormatterNoCurrency;
}
}

View File

@@ -1,44 +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\Utils;
use App\Configuration\LanguageFormattings;
use App\Constants;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Use this class, when you want information about formats for the "current request locale".
*/
final class LocaleSettings extends LocaleFormats
{
private $requestStack;
private $locale;
public function __construct(RequestStack $requestStack, LanguageFormattings $formats)
{
parent::__construct($formats, Constants::DEFAULT_LOCALE);
$this->requestStack = $requestStack;
}
public function getLocale(): string
{
if ($this->locale === null) {
$locale = \Locale::getDefault();
// request is null in a console command
if (null !== $this->requestStack->getMasterRequest()) {
$locale = $this->requestStack->getMasterRequest()->getLocale();
}
$this->locale = $locale;
}
return $this->locale;
}
}

View File

@@ -1,151 +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\Utils;
use App\Constants;
use Mpdf\Config\ConfigVariables;
use Mpdf\Config\FontVariables;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
class MPdfConverter implements HtmlToPdfConverter
{
/**
* @var FileHelper
*/
private $fileHelper;
/**
* @var string
*/
private $cacheDirectory;
public function __construct(FileHelper $fileHelper, string $cacheDirectory)
{
$this->fileHelper = $fileHelper;
$this->cacheDirectory = $cacheDirectory;
}
protected function sanitizeOptions(array $options): array
{
$configs = new ConfigVariables();
$fonts = new FontVariables();
$allowed = [
'mode', 'format', 'default_font_size', 'default_font', 'margin_left', 'margin_right', 'margin_top',
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts',
];
$filtered = array_filter($options, function ($key) use ($allowed, $configs, $fonts) {
if (!\in_array($key, $allowed)) {
if (!\array_key_exists($key, $configs->getDefaults())) {
return \array_key_exists($key, $fonts->getDefaults());
}
}
return true;
}, ARRAY_FILTER_USE_KEY);
if (\array_key_exists('tempDir', $filtered)) {
unset($filtered['tempDir']);
}
return $filtered;
}
/**
* @param string $html
* @param array $options
* @return string
* @throws \Mpdf\MpdfException
*/
public function convertToPdf(string $html, array $options = []): string
{
$options = array_merge(
$this->sanitizeOptions($options),
['tempDir' => $this->cacheDirectory, 'exposeVersion' => false]
);
$mpdf = $this->initMpdf($options);
// some OS do not follow the PHP default settings
if ((int) ini_get('pcre.backtrack_limit') < 1000000) {
@ini_set('pcre.backtrack_limit', '1000000');
}
// large amount of data take time
@ini_set('max_execution_time', '120');
// reduce the size of content parts that are passed to MPDF, to prevent
// https://mpdf.github.io/troubleshooting/known-issues.html#blank-pages-or-some-sections-missing
$parts = explode('<pagebreak>', $html);
for ($i = 0; $i < \count($parts); $i++) {
if (stripos($parts[$i], '<!-- CONTENT_PART -->') !== false) {
$subParts = explode('<!-- CONTENT_PART -->', $parts[$i]);
foreach ($subParts as $subPart) {
$mpdf->WriteHTML($subPart);
}
} else {
$mpdf->WriteHTML($parts[$i]);
}
if ($i < \count($parts) - 1) {
$mpdf->WriteHTML('<pagebreak>');
}
}
return $mpdf->Output('', Destination::STRING_RETURN);
}
/**
* @param array $options
* @return Mpdf
* @throws \Mpdf\MpdfException
* @throws \Exception
*/
private function initMpdf(array $options): Mpdf
{
$options['fontDir'] = $this->getFontDirectories();
$options['fontdata'] = $this->mergeFontData($options);
$mpdf = new Mpdf($options);
$mpdf->creator = Constants::SOFTWARE;
return $mpdf;
}
/**
* @return array
* @throws \Exception
*/
private function getFontDirectories(): array
{
$defaultConfig = (new ConfigVariables())->getDefaults();
$fontDirectories = $defaultConfig['fontDir'];
$fontDirectories[] = $this->fileHelper->getDataDirectory('fonts');
return $fontDirectories;
}
/**
* @param array $options
* @return array
*/
private function mergeFontData(array $options): array
{
$defaultFontConfig = (new FontVariables())->getDefaults();
$fontData = $defaultFontConfig['fontdata'];
if (\array_key_exists('fonts', $options)) {
$fontData = array_merge($fontData, $options['fonts']);
}
return $fontData;
}
}

View File

@@ -10,41 +10,36 @@
namespace App\Utils;
/**
* Parse markdown syntax and return HTML.
* Parse Markdown syntax and return HTML.
*/
final class Markdown
{
/**
* @var ParsedownExtension
*/
private $parser;
private ?ParsedownExtension $parser = null;
private ?\Parsedown $parserFull = null;
public function toHtml(string $text, bool $safe = true): string
public function toHtml(string $text): string
{
if ($this->parser === null) {
$this->parser = new ParsedownExtension();
$this->parser->setUrlsLinked(true);
$this->parser->setBreaksEnabled(true);
$this->parser->setSafeMode(true);
$this->parser->setMarkupEscaped(true);
}
if ($safe !== true) {
@trigger_error('Only safe mode is supported in Markdown since 1.16.3 to prevent XSS attacks. Parameter $safe will be removed with 2.0', E_USER_DEPRECATED);
}
$this->parser->setSafeMode(true);
$this->parser->setMarkupEscaped(true);
return $this->parser->text($text);
}
public function withFullMarkdownSupport(string $text): string
{
$parser = new \Parsedown();
$parser->setUrlsLinked(true);
$parser->setBreaksEnabled(true);
$parser->setSafeMode(true);
$parser->setMarkupEscaped(true);
if ($this->parserFull === null) {
$this->parserFull = new \Parsedown();
$this->parserFull->setUrlsLinked(true);
$this->parserFull->setBreaksEnabled(true);
$this->parserFull->setSafeMode(true);
$this->parserFull->setMarkupEscaped(true);
}
return $parser->text($text);
return $this->parserFull->text($text);
}
}

View File

@@ -9,11 +9,247 @@
namespace App\Utils;
use KevinPapst\AdminLTEBundle\Model\MenuItemModel as BaseMenuItemModel;
use KevinPapst\TablerBundle\Model\MenuItemInterface;
class MenuItemModel extends BaseMenuItemModel
final class MenuItemModel implements MenuItemInterface
{
private $childRoutes = [];
private string $identifier;
private string $label;
private ?string $route;
private array $routeArgs;
private bool $isActive = false;
/** @var array<MenuItemModel> */
private array $children = [];
private ?string $icon;
private ?MenuItemModel $parent = null;
private ?string $badge = null;
private ?string $badgeColor = null;
private static int $dividerId = 0;
private bool $divider = false;
private bool $lastWasDivider = false;
private bool $expanded = false;
public function __construct(
string $id,
string $label,
?string $route = null,
array $routeArgs = [],
?string $icon = null
) {
$this->identifier = $id;
$this->label = $label;
$this->route = $route;
$this->routeArgs = $routeArgs;
$this->icon = $icon;
}
/**
* @return MenuItemModel[]
*/
public function getChildren(): array
{
return $this->children;
}
public function getChild(string $id): ?MenuItemModel
{
foreach ($this->children as $child) {
if ($child->getIdentifier() === $id) {
return $child;
}
}
return null;
}
/**
* @param array<MenuItemModel> $children
*/
public function setChildren(array $children): void
{
$this->children = $children;
}
public function getIcon(): ?string
{
return $this->icon;
}
public function setIcon(string $icon): void
{
$this->icon = $icon;
}
public function getIdentifier(): string
{
return $this->identifier;
}
public function getIsActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): void
{
$this->getParent()?->setIsActive($isActive);
$this->isActive = $isActive;
}
public function hasParent(): bool
{
return $this->parent !== null;
}
public function getParent(): ?MenuItemModel
{
return $this->parent;
}
public function setParent(MenuItemInterface $parent): void
{
if (!($parent instanceof MenuItemModel)) {
throw new \Exception('MenuItemModel::setParent() expects a MenuItemModel');
}
$this->parent = $parent;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(string $label): void
{
$this->label = $label;
}
public function getRoute(): ?string
{
return $this->route;
}
public function setRoute(?string $route): void
{
$this->route = $route;
}
public function getRouteArgs(): array
{
return $this->routeArgs;
}
public function setRouteArgs(array $routeArgs): void
{
$this->routeArgs = $routeArgs;
}
public function hasChildren(): bool
{
if (\count($this->children) < 1) {
return false;
}
foreach ($this->children as $child) {
if (!$child->isDivider()) {
return true;
}
}
return false;
}
public function addChild(MenuItemInterface $child): void
{
if (!($child instanceof MenuItemModel)) {
throw new \Exception('MenuItemModel::addChild() expects a MenuItemModel');
}
// first item should not be a divider
if (!$this->hasChildren() && $child->isDivider()) {
return;
}
// two divider should not be added as direct siblings
if ($this->lastWasDivider && $child->isDivider()) {
return;
}
$this->lastWasDivider = $child->isDivider();
$child->setParent($this);
$this->children[] = $child;
}
public function removeChild(MenuItemInterface $child): void
{
if (false !== ($key = array_search($child, $this->children))) {
unset($this->children[$key]);
}
}
public function findChild(string $identifier): ?MenuItemModel
{
return $this->find($identifier, $this);
}
private function find(string $identifier, MenuItemModel $menu): ?MenuItemModel
{
if ($menu->getIdentifier() === $identifier) {
return $this;
}
foreach ($menu->getChildren() as $child) {
if ($child->getIdentifier() === $identifier) {
return $child;
}
if ($child->hasChildren()) {
if (($tmp = $this->find($identifier, $child)) !== null) {
return $tmp;
}
}
}
return null;
}
public function getActiveChild(): ?MenuItemModel
{
foreach ($this->children as $child) {
if ($child->isActive()) {
return $child;
}
}
return null;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setBadge(?string $badge): void
{
$this->badge = $badge;
}
public function setBadgeColor(?string $badgeColor): void
{
$this->badgeColor = $badgeColor;
}
public function getBadge(): ?string
{
return $this->badge;
}
public function getBadgeColor(): ?string
{
return $this->badgeColor;
}
private array $childRoutes = [];
public function setChildRoutes(array $routes): MenuItemModel
{
@@ -33,4 +269,32 @@ class MenuItemModel extends BaseMenuItemModel
{
return \in_array($route, $this->childRoutes);
}
public static function createDivider(): MenuItemModel
{
$model = new MenuItemModel('divider_' . self::$dividerId++, '');
$model->setDivider(true);
return $model;
}
public function isDivider(): bool
{
return $this->divider;
}
public function setDivider(bool $divider): void
{
$this->divider = $divider;
}
public function isExpanded(): bool
{
return $this->expanded;
}
public function setExpanded(bool $expanded): void
{
$this->expanded = $expanded;
}
}

View File

@@ -1,51 +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\Utils;
/**
* This class is used to convert PHP date format to moment.js format.
*
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
class MomentFormatConverter
{
/**
* This defines the mapping between PHP ICU date format (key) and moment.js date format (value)
* For ICU formats see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
* For Moment formats see http://momentjs.com/docs/#/displaying/format/
*
* @var array
*/
private static $formatConvertRules = [
// year
'yyyy' => 'YYYY', 'yy' => 'YY', 'y' => 'YYYY',
// day
'dd' => 'DD', 'd' => 'D',
// day of week
'EE' => 'ddd', 'EEEEEE' => 'dd',
// timezone
'ZZZZZ' => 'Z', 'ZZZ' => 'ZZ',
// letter 'T'
'\'T\'' => 'T',
// am/pm to AM/PM
'a' => 'A',
];
/**
* Returns associated moment.js format.
*
* @param string $format
* @return string
*/
public function convert(string $format): string
{
return strtr($format, self::$formatConvertRules);
}
}

View File

@@ -0,0 +1,103 @@
<?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\Utils;
final class NumberGenerator
{
/**
* @param string $format
* @param callable $patternReplacer (receives the parameters: string $originalFormat, string $format, int $increaseBy)
*/
public function __construct(private string $format, private $patternReplacer)
{
}
public function getNumber(int $startWith = 0): string
{
$result = $this->format;
preg_match_all('/{[^}]*?}/', $result, $matches);
foreach ($matches[0] as $part) {
$partialResult = $this->parseReplacer($part, $startWith);
$result = str_replace($part, $partialResult, $result);
}
return $result;
}
private function parseReplacer(string $originalFormat, int $increaseBy): string
{
$formatterLength = null;
$formatPattern = str_replace(['{', '}'], '', $originalFormat);
$parts = preg_split('/([+\-,])+/', $formatPattern, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($parts === false) {
throw new \InvalidArgumentException('Invalid number format received');
}
$format = array_shift($parts);
if (\count($parts) % 2 !== 0) {
throw new \InvalidArgumentException('Invalid number format configuration found');
}
while (null !== ($tmp = array_shift($parts))) {
switch ($tmp) {
case '+':
$local = array_shift($parts);
if (!is_numeric($local)) {
throw new \InvalidArgumentException('Unknown increment found');
}
$increaseBy = $increaseBy + \intval($local);
break;
case '-':
$local = array_shift($parts);
if (!is_numeric($local)) {
throw new \InvalidArgumentException('Unknown decrement found');
}
$increaseBy = $increaseBy - \intval($local);
break;
case ',':
$local = array_shift($parts);
if (!is_numeric($local)) {
throw new \InvalidArgumentException('Unknown format length found');
}
$formatterLength = \intval($local);
if ((string) $formatterLength !== $local) {
throw new \InvalidArgumentException('Unknown format length found');
}
break;
default:
throw new \InvalidArgumentException('Unknown pattern found');
}
}
if ($increaseBy === 0) {
$increaseBy = 1;
}
$partialResult = \call_user_func($this->patternReplacer, $originalFormat, $format, $increaseBy);
if (!\is_string($partialResult) && !\is_int($partialResult) && !\is_float($partialResult)) {
throw new \Exception('Number generator callback must return string or integer');
}
$partialResult = (string) $partialResult;
if (null !== $formatterLength) {
$partialResult = str_pad($partialResult, $formatterLength, '0', STR_PAD_LEFT);
}
return $partialResult;
}
}

93
src/Utils/PageSetup.php Normal file
View File

@@ -0,0 +1,93 @@
<?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\Utils;
final class PageSetup
{
private ?string $help = null;
private ?string $actionName = null;
private string $actionView = 'index';
private array $actionPayload = [];
private ?DataTable $dataTable = null;
public function __construct(private string $title)
{
}
public function hasDataTable(): bool
{
return $this->dataTable !== null;
}
public function hasSearchForm(): bool
{
return $this->dataTable !== null && $this->dataTable->getSearchForm() !== null;
}
public function getDataTable(): ?DataTable
{
return $this->dataTable;
}
public function setDataTable(?DataTable $dataTable): void
{
$this->dataTable = $dataTable;
}
public function getHelp(): ?string
{
return $this->help;
}
public function setHelp(?string $help): void
{
$this->help = $help;
}
public function getTitle(): string
{
return $this->title;
}
public function getActionName(): ?string
{
return $this->actionName;
}
public function setActionName(?string $actionName): void
{
$this->actionName = $actionName;
}
public function getActionView(): string
{
return $this->actionView;
}
public function setActionView(string $actionView): void
{
$this->actionView = $actionView;
}
public function getActionPayload(): array
{
return $this->actionPayload;
}
public function setActionPayload(array $actionPayload): void
{
$this->actionPayload = $actionPayload;
}
public function isTableAction(): bool
{
return \in_array($this->actionView, ['detail', 'custom', 'table']);
}
}

21
src/Utils/Pagination.php Normal file
View File

@@ -0,0 +1,21 @@
<?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\Utils;
use Pagerfanta\Adapter\AdapterInterface;
use Pagerfanta\Pagerfanta;
final class Pagination extends Pagerfanta
{
public function __construct(AdapterInterface $adapter)
{
parent::__construct($adapter);
}
}

View File

@@ -0,0 +1,60 @@
<?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\Utils;
use Pagerfanta\View\Template\TwitterBootstrap5Template;
final class PaginationTemplate extends TwitterBootstrap5Template
{
protected function getDefaultOptions(): array
{
return array_merge(
parent::getDefaultOptions(),
[
//'prev_message' = '←',
//'next_message' = '→',
'prev_message' => '<i class="fas fa-chevron-left"></i>',
'next_message' => '<i class="fas fa-chevron-right"></i>',
]
);
}
/**
* @param string $class
* @param string $href
* @param int|string $text
* @param string|null $rel
* @return string
*/
protected function linkLi(string $class, string $href, $text, ?string $rel = null): string
{
$liClass = implode(' ', array_filter(['page-item', $class]));
$rel = $rel ? sprintf(' rel="%s"', $rel) : '';
return sprintf('<li class="%s"><a class="page-link pagination-link" href="%s"%s>%s</a></li>', $liClass, $href, $rel, $text);
}
/**
* @param string $class
* @param string $text
* @return string
*/
protected function spanLi(string $class, $text): string
{
$liClass = implode(' ', array_filter(['page-item', $class]));
return sprintf('<li class="%s"><span class="page-link pagination-link">%s</span></li>', $liClass, $text);
}
public function current(int $page): string
{
return $this->linkLi($this->option('css_active_class'), $this->generateRoute($page), $page);
}
}

View File

@@ -0,0 +1,26 @@
<?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\Utils;
use Pagerfanta\View\Template\TemplateInterface;
use Pagerfanta\View\TwitterBootstrap5View;
final class PaginationView extends TwitterBootstrap5View
{
protected function getDefaultProximity(): int
{
return 2;
}
protected function createDefaultTemplate(): TemplateInterface
{
return new PaginationTemplate();
}
}

View File

@@ -12,9 +12,10 @@ namespace App\Utils;
/**
* This Class extends the default Parsedown Class for custom methods.
*/
class ParsedownExtension extends \Parsedown
final class ParsedownExtension extends \Parsedown
{
private $ids = [];
/** @var array<string> */
private array $ids = [];
/**
* Overwritten to prevent # to show up as headings for two reasons:
@@ -71,16 +72,16 @@ class ParsedownExtension extends \Parsedown
* - added support for file:///
* - open links in new windows
*/
protected function inlineUrl($Excerpt)
protected function inlineUrl($Excerpt): ?array
{
if ($this->urlsLinked !== true or !isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') {
return;
return null;
}
if (preg_match('/\b(https?:[\/]{2}|file:[\/]{3})[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) {
$url = $matches[0][0];
$Inline = [
return [
'extent' => \strlen($matches[0][0]),
'position' => $matches[0][1],
'element' => [
@@ -92,14 +93,14 @@ class ParsedownExtension extends \Parsedown
],
],
];
return $Inline;
}
return null;
}
protected function blockHeader($line)
protected function blockHeader($Line)
{
$block = parent::blockHeader($line);
$block = parent::blockHeader($Line);
$text = $block['element']['text'];
$id = $this->getIDfromText($text);
@@ -151,8 +152,8 @@ class ParsedownExtension extends \Parsedown
{
$Block = parent::blockTable($Line, $Block);
if (\is_null($Block)) {
return;
if ($Block === null) {
return null;
}
$Block['element']['attributes']['class'] = 'table';

View File

@@ -0,0 +1,89 @@
<?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\Utils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
final class ProfileManager
{
public const SESSION_PROFILE = 'datatable_profile';
public const PROFILE_DESKTOP = 'desktop';
public const PROFILE_MOBILE = 'mobile';
public const COOKIE_PROFILE = 'K2P';
public function __construct()
{
}
public function isValidProfile(string $profile): bool
{
return \in_array($profile, [self::PROFILE_DESKTOP, self::PROFILE_MOBILE]);
}
public function getDatatableName(string $dataTable, ?string $profile = null): string
{
if (empty($profile) || $profile === self::PROFILE_DESKTOP) {
return $dataTable;
}
return trim($dataTable . '_' . $profile);
}
/**
* Always returns a valid profile name (default: desktop).
*
* @param string $profile
* @return string
*/
public function getProfile(string $profile): string
{
if (!\in_array($profile, [self::PROFILE_DESKTOP, self::PROFILE_MOBILE])) {
return self::PROFILE_DESKTOP;
}
return $profile;
}
public function setProfile(Session $session, string $profile): void
{
if ($profile === self::PROFILE_MOBILE) {
$session->set(self::SESSION_PROFILE, $profile);
} else {
$session->remove(self::SESSION_PROFILE);
}
}
/**
* Always returns a valid profile name (default: desktop).
*
* @param Request $request
* @return string
*/
public function getProfileFromCookie(Request $request): string
{
$profile = $request->cookies->get(self::COOKIE_PROFILE, self::PROFILE_DESKTOP);
return $this->getProfile($profile);
}
/**
* Always returns a valid profile name (default: desktop).
*
* @param Session $session
* @return string
*/
public function getProfileFromSession(Session $session): string
{
$profile = $session->get(self::SESSION_PROFILE, self::PROFILE_DESKTOP);
return $this->getProfile($profile);
}
}

View File

@@ -0,0 +1,121 @@
<?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\Utils;
use App\Constants;
use Composer\Semver\Semver;
use Composer\Semver\VersionParser;
/**
* Code inspired by https://github.com/consolidation/self-update (MIT license - 03 Sept. 2022)
*/
final class ReleaseVersion
{
/**
* Get all releases from GitHub.
*
* @throws \Exception
* @return array
*/
private function getReleasesFromGithub(): array
{
$versionParser = new VersionParser();
$opts = [
'http' => [
'method' => 'GET',
'header' => [
'User-Agent: ' . Constants::SOFTWARE . ' ' . Constants::VERSION . ' Update-Check (PHP)',
],
],
];
$context = stream_context_create($opts);
$releases = file_get_contents('https://api.github.com/repos/' . Constants::GITHUB_REPO . '/releases', false, $context);
$releases = json_decode($releases);
if (!isset($releases[0])) {
throw new \Exception('API error - no release found at GitHub repository: ' . Constants::GITHUB_REPO);
}
$parsed = [];
foreach ($releases as $release) {
if ($release->draft || $release->prerelease) {
continue;
}
try {
$normalized = $versionParser->normalize($release->tag_name);
} catch (\UnexpectedValueException $e) {
continue;
}
if (VersionParser::parseStability($normalized) !== 'stable') {
continue;
}
$date = $release->published_at;
try {
$date = new \DateTimeImmutable($date);
} catch (\Exception $ex) {
// can be ignored, we return a string
}
$parsed[$normalized] = [
'version' => $release->tag_name,
'date' => $date,
'url' => $release->html_url,
'download' => $release->zipball_url,
'content' => $release->body,
];
}
$versions = Semver::rsort(array_keys($parsed));
$releases = [];
foreach ($versions as $version) {
$releases[$version] = $parsed[$version];
}
return $releases;
}
/**
* Returns an array with the keys:
* - version (string, tag name)
* - date (string, release date)
* - url (string, web address)
* - download (string, ZIP URL)
* - content (string, release notes)
*
* @param bool $compatible
* @return array|null
* @throws \Exception
*/
public function getLatestReleaseFromGithub(bool $compatible): ?array
{
foreach ($this->getReleasesFromGithub() as $release) {
$releaseVersion = $release['version'];
if ($compatible && !$this->satisfiesMajorVersionConstraint($releaseVersion)) {
continue;
}
return $release;
}
return null;
}
private function satisfiesMajorVersionConstraint(string $releaseVersion): bool
{
if (preg_match('/^v?(\d+)/', Constants::VERSION, $matches)) {
return Semver::satisfies($releaseVersion, '^' . $matches[1]);
}
return false;
}
}

View File

@@ -11,27 +11,16 @@ namespace App\Utils;
final class SearchTerm
{
/**
* @var string
*/
private $originalTerm;
/**
* @var string
*/
private $term;
private string $originalTerm;
private string $term;
/**
* @var string[]
*/
private $fields = [];
private array $fields;
public function __construct(string $searchTerm)
{
$this->originalTerm = $searchTerm;
$this->parse($searchTerm);
}
private function parse(string $searchTerm)
{
$terms = explode(' ', $searchTerm);
$fields = [];
$finalTerm = [];

View File

@@ -1,119 +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\Utils;
use Symfony\Bundle\FrameworkBundle\Translation\Translator as BaseTranslator;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Component\Translation\MessageCatalogueInterface;
use Symfony\Component\Translation\TranslatorBagInterface;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class Translator implements TranslatorInterface, TranslatorBagInterface, LocaleAwareInterface
{
/**
* @var BaseTranslator
*/
private $translator;
/**
* @var array
*/
private $localDomains;
public function __construct(BaseTranslator $translator, array $localDomains = [])
{
$this->translator = $translator;
$this->localDomains = $localDomains;
}
public function trans($id, array $parameters = [], $domain = 'messages', $locale = null)
{
if (null === $domain) {
$domain = 'messages';
}
foreach ($this->localDomains as $localDomain) {
if (false !== $this->hasLocalOverwrite($id, $localDomain, $locale)) {
$domain = $localDomain;
break;
}
}
return $this->translator->trans($id, $parameters, $domain, $locale);
}
protected function hasLocalOverwrite($id, $domain, $locale = null): bool
{
$catalogue = $this->getCatalogue($locale);
while (false === ($found = $catalogue->defines($id, $domain))) {
if ($cat = $catalogue->getFallbackCatalogue()) {
$catalogue = $cat;
} else {
break;
}
}
return $found;
}
/**
* Gets the catalogue by locale.
*
* @param string|null $locale The locale or null to use the default
*
* @return MessageCatalogueInterface
*
* @throws InvalidArgumentException If the locale contains invalid characters
*/
public function getCatalogue($locale = null)
{
return $this->translator->getCatalogue($locale);
}
/**
* Sets the current locale.
*
* @param string $locale The locale
*
* @throws \InvalidArgumentException If the locale contains invalid characters
*/
public function setLocale($locale)
{
$this->translator->setLocale($locale);
}
/**
* Returns the current locale.
*
* @return string The locale
*/
public function getLocale()
{
return $this->translator->getLocale();
}
/**
* Translates the given choice message by choosing a translation according to a number.
*
* @param string $id The message id (may also be an object that can be cast to string)
* @param int $number The number to use to find the index of the message
* @param array $parameters An array of parameters for the message
* @param string|null $domain The domain for the message or null to use the default
* @param string|null $locale The locale or null to use the default
*
* @return string The translated string
*
* @throws InvalidArgumentException If the locale contains invalid characters
*/
public function transChoice($id, $number, array $parameters = [], $domain = null, $locale = null)
{
return $this->translator->transChoice($id, $number, $parameters, $domain, $locale);
}
}