Release 2.38.0 (#5563)

This commit is contained in:
Kevin Papst
2025-08-08 23:25:42 +02:00
committed by GitHub
parent f3890691ce
commit 04331420ae
88 changed files with 1319 additions and 697 deletions

View File

@@ -20,7 +20,7 @@ use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
/**
* @final

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.37.0';
public const VERSION = '2.38.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 23700;
public const VERSION_ID = 23800;
/**
* The software name
*/

View File

@@ -106,15 +106,15 @@ final class PermissionController extends AbstractController
$other = [];
foreach ($event->getSections() as $section) {
$permissionSorted[$section->getTitle()] = [];
$permissionSorted[$section->getTitle()] = []; // @phpstan-ignore method.deprecatedInterface
}
foreach ($this->manager->getPermissions() as $permission) {
$found = false;
foreach (array_reverse($event->getSections()) as $section) {
if ($section->filter($permission)) {
$permissionSorted[$section->getTitle()][] = $permission;
if ($section->filter($permission)) { // @phpstan-ignore method.deprecatedInterface
$permissionSorted[$section->getTitle()][] = $permission; // @phpstan-ignore method.deprecatedInterface
$found = true;
break;
}

View File

@@ -12,8 +12,8 @@ namespace App\Controller\Security;
use App\Configuration\SystemConfiguration;
use App\Controller\AbstractController;
use App\Entity\User;
use App\Event\EmailEvent;
use App\Event\EmailPasswordResetEvent;
use App\Event\UserEmailEvent;
use App\User\UserService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
@@ -112,7 +112,7 @@ final class PasswordResetController extends AbstractController
$this->eventDispatcher->dispatch($event);
// this will send the email
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
$this->eventDispatcher->dispatch(new UserEmailEvent($user, $event->getEmail()));
$user->markPasswordRequested();
$user->setRequiresPasswordReset(true);

View File

@@ -348,6 +348,10 @@ final class SystemConfigurationController extends AbstractController
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration('timesheet.rules.break_time_active'))
->setLabel('break')
->setType(YesNoType::class)
->setOptions(['help' => 'Beta']),
]),
(new SystemConfigurationModel('quick_entry'))
->setTranslation('quick_entry.title')

View File

@@ -21,7 +21,7 @@ use DateTimeInterface;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
/**
* @final

View File

@@ -11,6 +11,10 @@ namespace App\Entity;
use Doctrine\Common\Collections\Collection;
/**
* @method getTags() array
* @method getBreak() int
*/
interface ExportableItem
{
public function getId(): ?int;
@@ -85,4 +89,18 @@ interface ExportableItem
* Example: "work"
*/
public function getCategory(): string;
/*
* Returns all assigned tags.
* @TODO activate for 3.0
* @return Collection<Tag>
*/
//public function getTags(): array;
/*
* Returns the break duration.
* @TODO activate for 3.0
* @return Collection<Tag>
*/
//public function getBreak(): int;
}

View File

@@ -75,7 +75,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'order_number', type: Types::TEXT, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Project_Entity'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'orderNumber')]
private ?string $orderNumber = null;
/**

View File

@@ -0,0 +1,25 @@
<?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\Event;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
class AbstractInvoiceEvent extends Event
{
public function __construct(private readonly Invoice $invoice)
{
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered for activity instances, which were just saved.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'activity.created', description: 'Triggered after a new activity was created', payload: 'object.getActivity()')]
final class ActivityCreatePostEvent extends AbstractActivityEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered right before a activity will be deleted.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'activity.deleted', description: 'Triggered right before an activity will be deleted', payload: 'object.getActivity()')]
final class ActivityDeleteEvent extends AbstractActivityEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered for activity instances, which were updated.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'activity.updated', description: 'Triggered after an activity was updated', payload: 'object.getActivity()')]
final class ActivityUpdatePostEvent extends AbstractActivityEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered for customer instances, which were just saved.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'customer.created', description: 'Triggered after a customer was created', payload: 'object.getCustomer()')]
final class CustomerCreatePostEvent extends AbstractCustomerEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered right before a customer will be deleted.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'customer.deleted', description: 'Triggered right before a customer will be deleted', payload: 'object.getCustomer()')]
final class CustomerDeleteEvent extends AbstractCustomerEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered for customer instances, which were just updated.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'customer.updated', description: 'Triggered after a customer was updated', payload: 'object.getCustomer()')]
final class CustomerUpdatePostEvent extends AbstractCustomerEvent
{
}

View File

@@ -11,17 +11,14 @@ namespace App\Event;
use App\Entity\Invoice;
use App\Invoice\InvoiceModel;
use Symfony\Contracts\EventDispatcher\Event;
use App\Webhook\Attribute\AsWebhook;
final class InvoiceCreatedEvent extends Event
#[AsWebhook(name: 'invoice.created', description: 'Triggered after an invoice was created', payload: 'object.getInvoice()')]
final class InvoiceCreatedEvent extends AbstractInvoiceEvent
{
public function __construct(private readonly Invoice $invoice, private readonly InvoiceModel $model)
public function __construct(Invoice $invoice, private readonly InvoiceModel $model)
{
}
public function getInvoice(): Invoice
{
return $this->invoice;
parent::__construct($invoice);
}
public function getInvoiceModel(): InvoiceModel

View File

@@ -9,17 +9,9 @@
namespace App\Event;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
use App\Webhook\Attribute\AsWebhook;
final class InvoiceDeleteEvent extends Event
#[AsWebhook(name: 'invoice.deleted', description: 'Triggered after an invoice was deleted', payload: 'object.getInvoice()')]
final class InvoiceDeleteEvent extends AbstractInvoiceEvent
{
public function __construct(private Invoice $invoice)
{
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View File

@@ -20,9 +20,9 @@ final class PermissionSectionsEvent extends Event
/**
* @var array<PermissionSectionInterface>
*/
private array $sections = [];
private array $sections = []; // @phpstan-ignore property.deprecatedInterface
public function addSection(PermissionSectionInterface $section): PermissionSectionsEvent
public function addSection(PermissionSectionInterface $section): PermissionSectionsEvent // @phpstan-ignore parameter.deprecatedInterface
{
$this->sections[] = $section;
@@ -32,7 +32,7 @@ final class PermissionSectionsEvent extends Event
/**
* @return PermissionSectionInterface[]
*/
public function getSections(): array
public function getSections(): array // @phpstan-ignore return.deprecatedInterface
{
return $this->sections;
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered for project instances, which were just saved.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'project.created', description: 'Triggered after a project was created', payload: 'object.getProject()')]
final class ProjectCreatePostEvent extends AbstractProjectEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered right before a project will be deleted.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'project.deleted', description: 'Triggered right before a project will be deleted', payload: 'object.getProject()')]
final class ProjectDeleteEvent extends AbstractProjectEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered for project instances, which were just updated.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'project.updated', description: 'Triggered after a project was updated', payload: 'object.getProject()')]
final class ProjectUpdatePostEvent extends AbstractProjectEvent
{
}

View File

@@ -13,7 +13,7 @@ use App\Form\Model\SystemConfiguration;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event should be used, if system configurations should be changed/added dynamically.
* Adjust system configurations dynamically.
*/
final class SystemConfigurationEvent extends Event
{
@@ -32,10 +32,6 @@ final class SystemConfigurationEvent extends Event
return $this->configurations;
}
/**
* @param SystemConfiguration $configuration
* @return SystemConfigurationEvent
*/
public function addConfiguration(SystemConfiguration $configuration): SystemConfigurationEvent
{
$this->configurations[] = $configuration;

View File

@@ -9,6 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'timesheet.created', description: 'Triggered after a timesheet was created', payload: 'object.getTimesheet()')]
final class TimesheetCreatePostEvent extends AbstractTimesheetEvent
{
}

View File

@@ -9,6 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'timesheet.stopped', description: 'Triggered after a timesheet was stopped', payload: 'object.getTimesheet()')]
final class TimesheetStopPostEvent extends AbstractTimesheetEvent
{
}

View File

@@ -9,6 +9,9 @@
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'timesheet.updated', description: 'Triggered after a timesheet was updated', payload: 'object.getTimesheet()')]
final class TimesheetUpdatePostEvent extends AbstractTimesheetEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered for new user instances, which were just saved.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'user.created', description: 'Triggered after a user was created', payload: 'object.getUser()')]
final class UserCreatePostEvent extends AbstractUserEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered for user instances which were just deleted.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'user.deleted', description: 'Triggered after a user was deleted', payload: 'object.getUser()')]
final class UserDeletePostEvent extends UserDeleteEvent
{
}

View File

@@ -9,9 +9,9 @@
namespace App\Event;
/**
* Triggered for user instances, which were just updated.
*/
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'user.updated', description: 'Triggered after a user was updated', payload: 'object.getUser()')]
final class UserUpdatePostEvent extends AbstractUserEvent
{
}

View File

@@ -22,7 +22,10 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*/
final class RedirectToLocaleSubscriber implements EventSubscriberInterface
{
public function __construct(private UrlGeneratorInterface $urlGenerator, private LocaleService $localeService)
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LocaleService $localeService
)
{
}

View File

@@ -87,12 +87,12 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
$options = new Options();
$options->SHOULD_ADD_BOM = false;
$opts = $this->spreadsheetRenderer->getTemplate()->getOptions();
$opts = $this->spreadsheetRenderer->getTemplate($query)->getOptions();
if (\array_key_exists('separator', $opts) && $opts['separator'] === ';') {
$options->FIELD_DELIMITER = ';';
}
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate()->getLocale());
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate($query)->getLocale());
$spreadsheet->open($filename);
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());

View File

@@ -65,11 +65,11 @@ final class SpreadsheetRenderer
$this->template = $template;
}
public function getTemplate(): TemplateInterface
public function getTemplate(?TimesheetQuery $query = null): TemplateInterface
{
if ($this->template === null) {
$template = new Template('default', 'default');
$template->setColumns($this->getDefaultColumns());
$template->setColumns($this->getDefaultColumns($query));
$template->setLocale('en');
$this->template = $template;
@@ -231,7 +231,7 @@ final class SpreadsheetRenderer
}
}
$template = $this->getTemplate();
$template = $this->getTemplate($query);
$columns = [];
@@ -250,6 +250,15 @@ final class SpreadsheetRenderer
$columns[] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'duration_seconds') {
$columns[] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'break') {
// TODO remove method_exists with 3.0
$columns[] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
} elseif ($column === 'break_decimal') {
// TODO remove method_exists with 3.0
$columns[] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
} elseif ($column === 'break_seconds') {
// TODO remove method_exists with 3.0
$columns[] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
} elseif ($column === 'currency' && $showRates) {
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'rate' && $showRates) {
@@ -319,9 +328,9 @@ final class SpreadsheetRenderer
/**
* @return array<int, string>
*/
private function getDefaultColumns(): array
private function getDefaultColumns(?TimesheetQuery $query = null): array
{
// @deprecated since 2.36 - will be removed with 3.0
// @deprecated from 2.36 - will be removed with 3.0
$durationFormatter = 'duration';
if (($user = $this->voter->getUser()) instanceof User) {
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
@@ -355,7 +364,7 @@ final class SpreadsheetRenderer
'project.order_number',
];
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query ?? new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'timesheet.meta.' . $metaField->getName();
}

View File

@@ -81,7 +81,7 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
throw new \Exception('Could not open temporary file');
}
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate()->getLocale());
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate($query)->getLocale());
$spreadsheet->open($filename);
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);

View File

@@ -19,7 +19,7 @@ final class ProjectHelper
public const PATTERN_NAME = '{name}';
public const PATTERN_NUMBER = '{number}';
public const PATTERN_COMMENT = '{comment}';
public const PATTERN_ORDERNUMBER = '{ordernumber}';
public const PATTERN_ORDERNUMBER = '{orderNumber}';
public const PATTERN_DATERANGE = '{daterange}';
public const PATTERN_START = '{start}';
public const PATTERN_END = '{end}';
@@ -74,6 +74,7 @@ final class ProjectHelper
$name = str_replace(self::PATTERN_NUMBER, $project->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $project->getComment() ?? '', $name);
$name = str_replace(self::PATTERN_CUSTOMER, $project->getCustomer()?->getName() ?? '', $name);
$name = str_replace('{ordernumber}', self::PATTERN_ORDERNUMBER, $name); // this was a typo before Kimai 2.38, can be removed in the future
$name = str_replace(self::PATTERN_ORDERNUMBER, $project->getOrderNumber() ?? '', $name);
if ($this->dateFormatter === null) {

View File

@@ -329,7 +329,7 @@ class TimesheetEditForm extends AbstractType
$builder->add('duration', DurationType::class, $durationOptions);
if ($this->systemConfiguration->isBreakTimeEnabled()) {
$builder->add('break', DurationType::class, ['label' => 'break', 'required' => false]);
$builder->add('break', DurationType::class, ['label' => 'break', 'required' => false, 'icon' => 'break']);
}
$builder->addEventListener(
@@ -392,7 +392,14 @@ class TimesheetEditForm extends AbstractType
return;
}
$builder->add('user', UserType::class);
$users = [];
if (isset($options['data']) && $options['data'] instanceof Timesheet) {
$users = [$options['data']->getUser()];
}
$builder->add('user', UserType::class, [
'include_users' => $users,
]);
}
protected function addExported(FormBuilderInterface $builder, array $options): void

View File

@@ -32,7 +32,7 @@ final class DurationType extends AbstractType
'preset_minutes' => null,
'toggle' => false,
'max_hours' => 24,
'icon' => 'clock',
'icon' => 'duration',
'documentation' => [
'type' => 'string',
'description' => 'Duration - supports various formats: https://www.kimai.org/documentation/duration-format.html',

View File

@@ -9,6 +9,7 @@
namespace App\Form\Type;
use App\Configuration\SystemConfiguration;
use App\Entity\MetaTableTypeInterface;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
@@ -39,6 +40,7 @@ final class ExportColumnsType extends AbstractType
public function __construct(
private readonly EventDispatcherInterface $dispatcher,
private readonly TranslatorInterface $translator,
private readonly SystemConfiguration $configuration,
)
{
}
@@ -86,6 +88,17 @@ final class ExportColumnsType extends AbstractType
],
];
if ($this->configuration->isBreakTimeEnabled()) {
$tmp = [
$this->translator->trans('break') . ' (0:30)' => 'break',
$this->translator->trans('break') . ' (0:30:00)' => 'break_seconds',
$this->translator->trans('break') . ' (0.5)' => 'break_decimal',
];
foreach ($tmp as $k => $v) {
$columns['timesheet'][$k] = $v;
}
}
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns['timesheet'][$metaField->getLabel()] = 'timesheet.meta.' . $metaField->getName();

View File

@@ -9,7 +9,10 @@
namespace App\Model;
class PermissionSection implements PermissionSectionInterface
/**
* @final with 3.0
*/
class PermissionSection implements PermissionSectionInterface // @phpstan-ignore class.implementsDeprecatedInterface
{
/** @var array<string> */
private array $filter;
@@ -17,7 +20,7 @@ class PermissionSection implements PermissionSectionInterface
/**
* @param string|array<string> $filter
*/
public function __construct(private string $title, string|array $filter)
public function __construct(private readonly string $title, string|array $filter)
{
if (!\is_array($filter)) {
$filter = [$filter];

View File

@@ -9,6 +9,9 @@
namespace App\Model;
/**
* @deprecated since 2.38 - use PermissionSection directly
*/
interface PermissionSectionInterface
{
/**

View File

@@ -18,7 +18,7 @@ class UserQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
public const USER_ORDER_ALLOWED = ['username', 'alias', 'title', 'email', 'systemAccount'];
public const USER_ORDER_ALLOWED = ['username', 'alias', 'title', 'email', 'system_account' => 'systemAccount'];
private ?string $role = null;
/**

View File

@@ -17,7 +17,7 @@ use App\Event\ThemeEvent;
use App\Event\ThemeJavascriptTranslationsEvent;
use App\Utils\Color;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
use Twig\Extension\RuntimeExtensionInterface;
@@ -40,12 +40,8 @@ final class ThemeExtension implements RuntimeExtensionInterface
{
/** @var User $user */
$user = $this->security->getUser();
$themeEvent = new ThemeEvent($user, $payload);
if ($this->eventDispatcher->hasListeners($eventName)) {
$this->eventDispatcher->dispatch($themeEvent, $eventName);
}
$this->eventDispatcher->dispatch($themeEvent, $eventName);
return $themeEvent;
}
@@ -53,10 +49,7 @@ final class ThemeExtension implements RuntimeExtensionInterface
public function actions(User $user, string $action, string $view, array $payload = []): ThemeEvent
{
$themeEvent = new PageActionsEvent($user, $payload, $action, $view);
if ($this->eventDispatcher->hasListeners($themeEvent->getEventName())) {
$this->eventDispatcher->dispatch($themeEvent, $themeEvent->getEventName());
}
$this->eventDispatcher->dispatch($themeEvent, $themeEvent->getEventName());
return $themeEvent;
}

View File

@@ -46,7 +46,7 @@ final class RuntimeExtensions extends AbstractExtension
public function getFilters(): array
{
return [
new TwigFilter('md2html', [MarkdownExtension::class, 'markdownToHtml'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
new TwigFilter('md2html', [MarkdownExtension::class, 'markdownToHtml'], ['is_safe' => ['html']]),
new TwigFilter('desc2html', [MarkdownExtension::class, 'timesheetContent'], ['is_safe' => ['html']]),
new TwigFilter('comment2html', [MarkdownExtension::class, 'commentContent'], ['is_safe' => ['html']]),
new TwigFilter('comment1line', [MarkdownExtension::class, 'commentOneLiner'], ['pre_escape' => 'html', 'is_safe' => ['html']]),

View File

@@ -33,7 +33,7 @@ final class Markdown
public function withFullMarkdownSupport(string $text): string
{
if ($this->parserFull === null) {
$this->parserFull = new \Parsedown();
$this->parserFull = new Parsedown();
$this->parserFull->setUrlsLinked(true);
$this->parserFull->setBreaksEnabled(true);
$this->parserFull->setSafeMode(true);

82
src/Utils/Parsedown.php Normal file
View File

@@ -0,0 +1,82 @@
<?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 extends the default Parsedown Class for custom methods.
*/
class Parsedown extends \Parsedown
{
/** @var array<string> */
private array $ids = [];
protected function blockHeader($Line)
{
$block = parent::blockHeader($Line);
$text = $block['element']['text'];
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
return $block;
}
/**
* github-action for creating ids:
*
* - It downcases the header string
* - Removes anything that is not a letter, number, space or hyphen
* - Changes any space to a hyphen
* - If that is not unique, add "-1", "-2", "-3",... to make it unique
* #
* @param non-empty-string $text
* @return string
*/
private function getIDfromText(string $text): string
{
$text = strtolower($text);
$text = preg_replace('/[^A-Za-z0-9\-\ ]/', '', $text);
$text = strtr($text, [' ' => '-']);
if (isset($this->ids[$text])) {
$i = 0;
$numberedText = $text . '-1';
while (isset($this->ids[$numberedText])) {
$i++;
$numberedText = $text . '-' . $i;
}
$text = $numberedText;
}
$this->ids[$text] = '';
return $text;
}
protected function blockTable($Line, array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
{
$Block = parent::blockTable($Line, $Block);
if ($Block === null) {
return null;
}
$Block['element']['attributes']['class'] = 'table table-striped table-vcenter';
return $Block;
}
}

View File

@@ -12,13 +12,10 @@ namespace App\Utils;
/**
* This Class extends the default Parsedown Class for custom methods.
*/
final class ParsedownExtension extends \Parsedown
final class ParsedownExtension extends Parsedown
{
/** @var array<string> */
private array $ids = [];
/**
* Overwritten to prevent # to show up as headings for two reasons:
* Overwritten to prevent # and = to show up as headings for two reasons:
* - Hashes are often used to cross-link issues in other systems
* - Headings should not occur in time record listings
*/
@@ -98,57 +95,7 @@ final class ParsedownExtension extends \Parsedown
return null;
}
protected function blockHeader($Line)
{
$block = parent::blockHeader($Line);
$text = $block['element']['text'];
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
return $block;
}
/**
* github-action for creating ids:
*
* - It downcases the header string
* - remove anything that is not a letter, number, space or hyphen
* - changes any space to a hyphen.
* - If that is not unique, add "-1", "-2", "-3",... to make it unique
*
* @param string $text
* @return string
*/
private function getIDfromText($text): string
{
$text = strtolower($text);
$text = preg_replace('/[^A-Za-z0-9\-\ ]/', '', $text);
$text = strtr($text, [' ' => '-']);
if (isset($this->ids[$text])) {
$i = 0;
$numberedText = $text . '-1';
while (isset($this->ids[$numberedText])) {
$i++;
$numberedText = $text . '-' . $i;
}
$text = $numberedText;
}
$this->ids[$text] = '';
return $text;
}
protected function blockTable($Line, array $Block = null)
protected function blockTable($Line, array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
{
$Block = parent::blockTable($Line, $Block);

View File

@@ -0,0 +1,18 @@
<?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\Webhook\Attribute;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class AsWebhook
{
public function __construct(public string $name, public string $description, public string $payload)
{
}
}

View File

@@ -59,8 +59,8 @@ final class YearPerUserSummary implements \Countable, \IteratorAggregate
public function getExpectedTime(): int
{
$all = 0;
foreach ($this->getSummaries() as $month) {
$all += $month->getExpectedTime();
foreach ($this->getSummaries() as $year) {
$all += $year->getExpectedTime();
}
return $all;
@@ -69,8 +69,8 @@ final class YearPerUserSummary implements \Countable, \IteratorAggregate
public function getActualTime(): int
{
$all = 0;
foreach ($this->getSummaries() as $month) {
$all += $month->getActualTime();
foreach ($this->getSummaries() as $year) {
$all += $year->getActualTime();
}
return $all;