Release 2.38.0 (#5563)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
25
src/Event/AbstractInvoiceEvent.php
Normal file
25
src/Event/AbstractInvoiceEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.38 - use PermissionSection directly
|
||||
*/
|
||||
interface PermissionSectionInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']]),
|
||||
|
||||
@@ -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
82
src/Utils/Parsedown.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
18
src/Webhook/Attribute/AsWebhook.php
Normal file
18
src/Webhook/Attribute/AsWebhook.php
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user