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

@@ -21,13 +21,6 @@ authentication via SAML/LDAP/Database, two-factor authentication (2FA) with TOTP
user/customer/project specific rates, advanced search & filtering, money and time budgets, advanced reporting, support for [plugins](https://www.kimai.org/store/)
and so much more.
### Versions
There are two [versions](https://www.kimai.org/documentation/versions.html) of Kimai existing:
- [Version 2](https://github.com/kimai/kimai) — the current stable release (PHP 8.1+)
- [Version 1](https://github.com/kimai/kimai/tree/1.x) — do **NOT** use, EOL since mid of 2023 (PHP 7.4)
### Links
- [Home](https://www.kimai.org) — Kimai project homepage
@@ -36,7 +29,7 @@ There are two [versions](https://www.kimai.org/documentation/versions.html) of K
### Requirements
- PHP 8.1.3 minimum (support for PHP 8.2 and 8.3)
- PHP 8.1.3 minimum (support for PHP 8.2, 8.3, 8.4)
- MariaDB or MySQL
- A webserver and subdomain (subdirectory is not supported)
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`

5
assets/highlight.js Normal file
View File

@@ -0,0 +1,5 @@
require('highlight.js/styles/github-dark.css');
const hljs = require('highlight.js/lib/common');
global.hljs = hljs;

View File

@@ -202,8 +202,8 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
{
let formatted = time.trim();
// replace dot with colon
formatted = formatted.replace(/\./g, ':');
// replace invalid separators with colon
formatted = formatted.replace(/\.|;|,/g, ':');
// uppercase 12-hour format
formatted = formatted.replace(/am/i, 'AM');
formatted = formatted.replace(/pm/i, 'PM');

View File

@@ -49,6 +49,7 @@ import enGbLocale from '@fullcalendar/core/locales/en-gb';
import enUsLocale from '@fullcalendar/core/locales/en-gb';
import KimaiColor from './KimaiColor';
import KimaiContextMenu from "./KimaiContextMenu";
import { DateTime } from 'luxon';
export default class KimaiCalendar {
@@ -266,6 +267,12 @@ export default class KimaiCalendar {
}
});
},
// called after all events of one source were set, so this can
// and will be called multiple times before the calendar is initialized
eventsSet: (events) => {
this._renderDayAndWeekSum(this.getCalendar().getCurrentData().viewSpec.type, events);
}
};
// ============= DRAG & DROP =============
@@ -687,4 +694,73 @@ export default class KimaiCalendar {
});
}
/**
* @param {string} view
* @param {EventApi[]} events
* @private
*/
_renderDayAndWeekSum(view, events) {
if (view === 'dayGridMonth') {
// currently we do not display totals in month view
return;
}
/** @type {KimaiDateUtils} DATES */
const DATES = this.kimai.getPlugin('date');
const durations = {};
if (view === 'timeGridWeek') {
// make sure we have an entry for every day of the week, even days without timesheets
document.querySelectorAll(`th.fc-col-header-cell[data-date]`).forEach(cell => {
durations[cell.dataset.date] = 0;
});
}
events.forEach(item => {
const start = DateTime.fromJSDate(item.start);
const dateStr = start.toISODate();
if (!durations[dateStr]) {
durations[dateStr] = 0;
}
// absences or public holidays are all day
if (item.end !== null) {
const end = DateTime.fromJSDate(item.end);
const duration = end.diff(start, 'hours').as('seconds');
durations[dateStr] += duration;
}
});
const dailyTotals = document.querySelectorAll('.fc-dailytotal');
dailyTotals.forEach(element => element.remove());
for (const dateValue in durations) {
const durationValue = durations[dateValue];
if (view === 'timeGridWeek') { // this is the week view
const headerCells = document.querySelectorAll(`th.fc-col-header-cell[data-date="${dateValue}"]`);
headerCells.forEach(cell => {
const newElement = document.createElement('div');
newElement.classList.add('fc-dailytotal');
newElement.textContent = DATES.formatSeconds(durationValue);
cell.appendChild(newElement);
});
}
}
// this is the day view
if (view === 'timeGridDay') {
const dayEl = document.querySelector('th.fc-day');
const dayDate = dayEl.dataset.date;
const dayTotal = document.querySelectorAll('.fc-dailytotal');
dayTotal.forEach(element => element.remove());
const newElement = document.createElement('div');
newElement.classList.add('fc-dailytotal');
newElement.textContent = DATES.formatSeconds(durations[dayDate]);
dayEl.appendChild(newElement);
}
}
}

View File

@@ -93,3 +93,19 @@ table.dataTable thead > tr > th.hw-min {
width: 30%;
}
}
.markdown {
blockquote {
p {
/* Parsedown wraps content in a <p> like <blockquote><p> */
margin: 0;
}
/* Tabler doesn't style blockquotes */
border-left: 15px var(--tblr-border-style) var(--tblr-border-color);
background-color: var(--tblr-bg-surface-secondary);
}
pre code.hljs {
padding: 0;
background-color: var(--tblr-bg-surface-dark);
}
}

1139
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@ tabler:
barcode: fas fa-barcode
bookmark: far fa-star
bookmarked: fas fa-star
break: fas fa-utensils
calendar: far fa-calendar-alt
cancel: fas fa-times
clock: far fa-clock
@@ -68,9 +69,9 @@ tabler:
documentation: fas fa-book
dot: fas fa-circle
download: fas fa-download
duration: far fa-hourglass
duration: fas fa-stopwatch
edit: far fa-edit
end: fas fa-stopwatch
end: fas fa-hourglass-end
export: fas fa-download
failure: fas fa-times
fax: fas fa-fax

View File

@@ -45,6 +45,7 @@
"eslint": "^9",
"globals": "^15",
"gridstack": "^7",
"highlight.js": "^11.11.1",
"litepicker": "^2",
"luxon": "^3",
"sass": "^1",

View File

@@ -4164,29 +4164,14 @@ parameters:
path: src/Utils/PaginationTemplate.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockHeader\\(\\) has no return type specified\\.$#"
message: "#^Method App\\\\Utils\\\\Parsedown\\:\\:blockHeader\\(\\) has no return type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
path: src/Utils/Parsedown.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockHeader\\(\\) has parameter \\$Line with no type specified\\.$#"
message: "#^Method App\\\\Utils\\\\Parsedown\\:\\:blockHeader\\(\\) has parameter \\$Line with no type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockTable\\(\\) has no return type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockTable\\(\\) has parameter \\$Block with no value type specified in iterable type array\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockTable\\(\\) has parameter \\$Line with no type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
path: src/Utils/Parsedown.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:inlineUrl\\(\\) has parameter \\$Excerpt with no type specified\\.$#"
@@ -4201,7 +4186,7 @@ parameters:
-
message: "#^Parameter \\#1 \\$str of function strtr expects string, string\\|null given\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
path: src/Utils/Parsedown.php
-
message: "#^Property App\\\\Utils\\\\ParsedownExtension\\:\\:\\$BlockTypes has no type specified\\.$#"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.3d366594.js"
"/build/app.dd0a8e58.js"
],
"css": [
"/build/app.7ffe3b24.css"
"/build/app.8b6629a6.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.7a875ca7.js"
],
"css": [
"/build/app-rtl.4f157a57.css"
"/build/app-rtl.bf451276.css"
]
},
"export-pdf": {
@@ -54,7 +54,7 @@
"calendar": {
"js": [
"/build/runtime.6c399d29.js",
"/build/calendar.7415ab21.js"
"/build/calendar.f4379767.js"
],
"css": [
"/build/calendar.d757753e.css"
@@ -68,14 +68,23 @@
"css": [
"/build/dashboard.b7129fa1.css"
]
},
"highlight": {
"js": [
"/build/runtime.6c399d29.js",
"/build/highlight.766d3929.js"
],
"css": [
"/build/highlight.98bf3927.css"
]
}
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.3d366594.js": "sha384-Wa5ZJDt7hVmlqWA8ixxZ/w/9vh/NJQ74KK3FqAsT/PPWNayIcg0N5XOv/9GfspBk",
"/build/app.7ffe3b24.css": "sha384-G4p41N3MjNkWRsaJkUKgGj1Ukd18qMGXHPOaDH6DqE8AzHAJPa1sGskswGP5zg+N",
"/build/app.dd0a8e58.js": "sha384-k0DlCny0XdsJHHuyL8B5MNlZTl+s8ZvBFvjwD9xrPQhbZ7ryiTx72pHOi+vaEzB8",
"/build/app.8b6629a6.css": "sha384-mgEEi5bDaaMfrI+/iEBBD2S2sQvjdRJhsYN89QlhqPeUhM39pcxiGx9WVVP31jls",
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
"/build/app-rtl.4f157a57.css": "sha384-kV+s8JUXnXpA6O/j8m7QCvqA5Qb7BK4kk3l1Ojs84OIrg4ztcvf3h6cBb+g/dMfw",
"/build/app-rtl.bf451276.css": "sha384-hWaFBRhgNGAmCoe30xfzWFG1qkmKSAXBjZj/GRXY61Gymsq3p2d9sbyC7M6EwumB",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",
@@ -83,9 +92,11 @@
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.62631acc.js": "sha384-L4evSO0OZiQt+jTqfMR70M2Vid7Hl5YdPocC6syhSoBRavlf7Az9/Ldh4YJUuwAU",
"/build/calendar.7415ab21.js": "sha384-+iYr7VnMIxSQMERlaruX2Ktp6gWmGSyCxfUAaeSDkoninXdUdiKBjlcZZwhHhZWU",
"/build/calendar.f4379767.js": "sha384-fNnf3iqMaALA89ncvi3nHAlJ2vlI1gjuL1fqnktU+RKVKTrVFCGuDrlIurNHrggx",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.632f98fb.js": "sha384-PlHarP53f8b+47VZvbQw3LURA2vODFf7UMwpnktvukrhswaHtx93cx2M1BtO56JH",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS"
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
"/build/highlight.766d3929.js": "sha384-/4zEkS1Zk8Y4U14MbMEJPH8rOkSfJbSg3+ZQ+xdVW18TCHzqv0DQC5FXIPOW4aB3",
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#0d1117;color:#c9d1d9}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{background-color:#033a16;color:#aff5b4}.hljs-deletion{background-color:#67060c;color:#ffdcd7}

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.7ffe3b24.css",
"build/app.js": "/build/app.3d366594.js",
"build/app-rtl.css": "/build/app-rtl.4f157a57.css",
"build/app.css": "/build/app.8b6629a6.css",
"build/app.js": "/build/app.dd0a8e58.js",
"build/app-rtl.css": "/build/app-rtl.bf451276.css",
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
@@ -11,9 +11,11 @@
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
"build/chart.js": "/build/chart.62631acc.js",
"build/calendar.css": "/build/calendar.d757753e.css",
"build/calendar.js": "/build/calendar.7415ab21.js",
"build/calendar.js": "/build/calendar.f4379767.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.632f98fb.js",
"build/highlight.css": "/build/highlight.98bf3927.css",
"build/highlight.js": "/build/highlight.766d3929.js",
"build/runtime.js": "/build/runtime.6c399d29.js",
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.ad1782c7.ttf",
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.26b80c88.ttf",

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
{
public function __construct(private Invoice $invoice)
#[AsWebhook(name: 'invoice.deleted', description: 'Triggered after an invoice was deleted', payload: 'object.getInvoice()')]
final class InvoiceDeleteEvent extends AbstractInvoiceEvent
{
}
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);
}
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());
}
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;

View File

@@ -19,6 +19,8 @@
{% set columns = {
'avatar': {'class': 'text-nowrap w-avatar d-none d-md-table-cell', 'title': false, 'orderBy': false},
'date': {'class': 'alwaysVisible text-nowrap', 'orderBy': false},
'begin': {'class': 'd-none', 'orderBy': false},
'end': {'class': 'd-none', 'orderBy': false},
'user': {'class': 'd-none', 'orderBy': false},
'project': {'class': 'd-none d-sm-table-cell', 'orderBy': false},
'activity': {'class': 'd-none', 'orderBy': false},
@@ -234,6 +236,16 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">
{{ entry.begin|date_short }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'begin') }}">
{{ entry.begin|time }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'begin') }}">
{% if entry.end %}
{{ entry.end|time }}
{% else %}
&dash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">
{{ widgets.label_user(entry.user) }}
</td>

View File

@@ -154,7 +154,7 @@
{%- set attr = attr|merge({'pattern': time_format|pattern, 'autocomplete': 'off', 'data-timepicker': 'on', 'data-format': js_format, 'placeholder': time_format}) -%}
<div class="input-group">
<div class="input-group-text">
<a href="#" data-form-widget="date-now" data-format="{{ js_format }}" data-target="{{ id }}">{{ icon('calendar') }}</a>
<a href="#" data-form-widget="date-now" data-format="{{ js_format }}" data-target="{{ id }}">{{ icon('clock') }}</a>
</div>
{{ block('form_widget_simple') }}
{% set time_presets = form_time_presets(app.user.timezone) %}

View File

@@ -59,7 +59,6 @@
{% endfor %}
</div>
{% set tableName = 'user_admin_permissions' %}
{{ tables.datatable_header(tableName, columns, null, {'reload': 'kimai.userRoleUpdate'}) }}

View File

@@ -487,6 +487,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'color' => '@string',
'customer' => 'int',
'number' => '@int',
'orderNumber' => '@string',
'globalActivities' => 'bool',
'comment' => '@string',
];
@@ -501,6 +502,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'color' => '@string',
'customer' => ['result' => 'object', 'type' => 'Customer'],
'number' => '@int',
'orderNumber' => '@string',
'globalActivities' => 'bool',
'comment' => '@string',
];
@@ -514,6 +516,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'customer' => 'int',
'number' => '@int',
'orderNumber' => '@string',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => 'string',

View File

@@ -100,7 +100,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
$titles[] = trim($th->textContent);
}
self::assertEquals([
'', 'Date', 'User', 'Project', 'Activity', 'Description', 'Tags', 'Duration', 'Unit price', 'Internal price', 'Total price', '',
'', 'Date', 'From', 'To', 'User', 'Project', 'Activity', 'Description', 'Tags', 'Duration', 'Unit price', 'Internal price', 'Total price', '',
], $titles);
// assert export type buttons are available

View File

@@ -49,7 +49,10 @@ class UserTest extends TestCase
self::assertFalse($user->canSeeAllData());
self::assertFalse($user->isExportDecimal());
self::assertFalse($user->isSystemAccount());
self::assertFalse($user->isPasswordRequestNonExpired(-1));
self::assertFalse($user->isPasswordRequestNonExpired(0));
self::assertFalse($user->isPasswordRequestNonExpired(3599));
self::assertFalse($user->isPasswordRequestNonExpired(PHP_INT_MAX));
$user->setUserIdentifier('foo');
self::assertEquals('foo', $user->getUserIdentifier());
@@ -675,4 +678,21 @@ class UserTest extends TestCase
self::assertInstanceOf(\DateTime::class, $lastLogin);
self::assertEquals('Europe/Berlin', $lastLogin->getTimezone()->getName());
}
public function testIsPasswordRequestNonExpiredIsTimezoneIndependent(): void
{
$user = new User();
$user->setTimezone('Europe/Vienna');
$user->markPasswordRequested();
self::assertTrue($user->isPasswordRequestNonExpired(3600));
self::assertTrue($user->isPasswordRequestNonExpired(7200));
$before = date_default_timezone_get();
date_default_timezone_set('America/Los_Angeles');
date_default_timezone_set($before);
self::assertTrue($user->isPasswordRequestNonExpired(3600));
self::assertTrue($user->isPasswordRequestNonExpired(7200));
}
}

View File

@@ -20,6 +20,7 @@ use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\InvoiceCreatedEvent
* @covers \App\Event\AbstractInvoiceEvent
*/
class InvoiceCreatedEventTest extends TestCase
{

View File

@@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\InvoiceDeleteEvent
* @covers \App\Event\AbstractInvoiceEvent
*/
class InvoiceDeleteEventTest extends TestCase
{

View File

@@ -14,6 +14,7 @@ use App\Entity\ExportTemplate;
use App\Form\ExportTemplateSpreadsheetForm;
use App\Form\Type\ExportColumnsType;
use App\Form\Type\LanguageType;
use App\Tests\Mocks\SystemConfigurationFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\Form\Test\TypeTestCase;
@@ -31,9 +32,10 @@ class ExportTemplateSpreadsheetFormTest extends TypeTestCase
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$translator = $this->createMock(TranslatorInterface::class);
$config = SystemConfigurationFactory::createStub();
return [
new ExportColumnsType($dispatcher, $translator),
new ExportColumnsType($dispatcher, $translator, $config),
new LanguageType(new LocaleService([]))
];
}

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Form\Type;
use App\Form\Type\ExportColumnsType;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use App\Tests\Mocks\SystemConfigurationFactory;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Test\TypeTestCase;
@@ -40,9 +41,10 @@ class ExportColumnsTypeTest extends TypeTestCase
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$translator = $this->createMock(TranslatorInterface::class);
$config = SystemConfigurationFactory::createStub();
return [
new ExportColumnsType($dispatcher, $translator)
new ExportColumnsType($dispatcher, $translator, $config)
];
}

View File

@@ -26,7 +26,7 @@ class MarkdownExtensionTest extends TestCase
$config = SystemConfigurationFactory::create($loader, ['timesheet' => ['markdown_content' => true]]);
$sut = new MarkdownExtension(new Markdown(), $config);
self::assertEquals('<p><em>test</em></p>', $sut->markdownToHtml('*test*'));
self::assertEquals('<h1>foobar</h1>', $sut->markdownToHtml('# foobar'));
self::assertEquals('<h1 id="foobar">foobar</h1>', $sut->markdownToHtml('# foobar'));
self::assertEquals(
'<p><a href="javascript%3Aalert(`XSS`)">XSS</a></p>',
$sut->markdownToHtml('[XSS](javascript:alert(`XSS`))')

View File

@@ -53,7 +53,6 @@ class ThemeEventExtensionTest extends TestCase
protected function getSut(bool $hasListener = true): ThemeExtension
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->method('hasListeners')->willReturn($hasListener);
$dispatcher->expects($hasListener ? $this->once() : $this->never())->method('dispatch');
$translator = $this->getMockBuilder(TranslatorInterface::class)->getMock();
@@ -95,13 +94,6 @@ class ThemeEventExtensionTest extends TestCase
self::assertInstanceOf(ThemeEvent::class, $event);
}
public function testTriggerWithoutListener(): void
{
$sut = $this->getSut(false);
$event = $sut->trigger($this->getEnvironment(), 'foo', []);
self::assertInstanceOf(ThemeEvent::class, $event);
}
public function testJavascriptTranslations(): void
{
$sut = $this->getSut();

View File

@@ -79,7 +79,6 @@ class RuntimeExtensionsTest extends TestCase
foreach ($filters as $filter) {
switch ($filter->getName()) {
case 'md2html':
self::assertEquals('html', $filters[0]->getPreEscape());
self::assertEquals(['html'], $filters[0]->getSafe(new TextNode('', 10)));
$found_md2html = true;
break;

View File

@@ -0,0 +1,42 @@
<?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\Tests\Utils;
use App\Utils\ParsedownExtension;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Utils\Parsedown
* @covers \App\Utils\ParsedownExtension
*/
class ParsedownExtensionTest extends TestCase
{
public function testTableContainsCssClasses(): void
{
$sut = new ParsedownExtension();
$html = $sut->parse('
| Item | Price |
|---|---|
| Something | $ 472,78 |
| Another entry | € 111 |
| | |
| Total | A lot |');
self::assertStringStartsWith('<table class="table">', $html);
}
public function testHeaderIsNotConverted(): void
{
$sut = new ParsedownExtension();
$html = $sut->parse('
# Foo
');
self::assertEquals('<p># Foo</p>', $html);
}
}

View File

@@ -0,0 +1,54 @@
<?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\Tests\Utils;
use App\Utils\Parsedown;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Utils\Parsedown
*/
class ParsedownTest extends TestCase
{
public function testTableContainsCssClasses(): void
{
$sut = new Parsedown();
$html = $sut->parse('
| Item | Price |
|---|---|
| Something | $ 472,78 |
| Another entry | € 111 |
| | |
| Total | A lot |');
self::assertStringStartsWith('<table class="table table-striped table-vcenter">', $html);
}
public function testHeaderContainsId(): void
{
$sut = new Parsedown();
$html = $sut->parse('
# Foo
');
self::assertEquals('<h1 id="foo">Foo</h1>', $html);
}
public function testHeaderContainsIdDoesNotDuplicate(): void
{
$sut = new Parsedown();
$html = $sut->parse('
# Foo
# Foo
# Foo
');
self::assertEquals('<h1 id="foo">Foo</h1>
<h1 id="foo-1">Foo</h1>
<h1 id="foo-2">Foo</h1>', $html);
}
}

View File

@@ -0,0 +1,47 @@
<?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\Tests\Webhook\Attribute;
use App\Entity\Invoice;
use App\Event\InvoiceDeleteEvent;
use App\Webhook\Attribute\AsWebhook;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Webhook\Attribute\AsWebhook
*/
class AsWebhookTestCase extends TestCase
{
public function testConstruct(): void
{
$attribute = new AsWebhook('name', 'description', 'some payload');
self::assertEquals('name', $attribute->name);
self::assertEquals('description', $attribute->description);
self::assertEquals('some payload', $attribute->payload);
}
public function testUsage(): void
{
$invoice = new Invoice();
$invoice->setComment('foo bar');
$usage = new InvoiceDeleteEvent($invoice);
$ref = new \ReflectionClass($usage);
$attr = $ref->getAttributes(AsWebhook::class);
self::assertCount(1, $attr);
$arguments = $attr[0]->getArguments();
self::assertEquals('invoice.deleted', $arguments['name']);
self::assertEquals('Triggered after an invoice was deleted', $arguments['description']);
self::assertEquals('object.getInvoice()', $arguments['payload']);
}
}

View File

@@ -1954,6 +1954,18 @@
<source>sending_company</source>
<target>Rechnungsabsender</target>
</trans-unit>
<trans-unit id="Uzll0ps" resname="Customer portal">
<source>Customer portal</source>
<target>Kundenportal</target>
</trans-unit>
<trans-unit id="cDOQMYv" resname="Beta">
<source>Beta</source>
<target>Aktiviert eine Vorschau-Funktion, die sich noch in der Entwicklung befindet.</target>
</trans-unit>
<trans-unit id="NA9GMDP" resname="Code">
<source>Code</source>
<target>Code</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1954,6 +1954,18 @@
<source>sending_company</source>
<target>Invoice Sender</target>
</trans-unit>
<trans-unit id="Uzll0ps" resname="Customer portal">
<source>Customer portal</source>
<target>Customer portal</target>
</trans-unit>
<trans-unit id="cDOQMYv" resname="Beta">
<source>Beta</source>
<target>Enables a preview feature that is still in development.</target>
</trans-unit>
<trans-unit id="NA9GMDP" resname="Code">
<source>Code</source>
<target>Code</target>
</trans-unit>
</body>
</file>
</xliff>

View File

View File

@@ -16,6 +16,7 @@ Encore
.addEntry('chart', './assets/chart.js')
.addEntry('calendar', './assets/calendar.js')
.addEntry('dashboard', './assets/dashboard.js')
.addEntry('highlight', './assets/highlight.js')
.splitEntryChunks()
.configureSplitChunks((splitChunks) => {

View File

@@ -3842,6 +3842,13 @@ __metadata:
languageName: node
linkType: hard
"highlight.js@npm:^11.11.1":
version: 11.11.1
resolution: "highlight.js@npm:11.11.1"
checksum: 10/205272f12f2c8ab1760452a75c58b043b11129cf3a5d2a9c0c90d43993580d0f5c385a73a4b8aba197eef20c0ec37d64000e6b35c4ed5991324f4c2dc78f4e43
languageName: node
linkType: hard
"htmlparser2@npm:^6.1.0":
version: 6.1.0
resolution: "htmlparser2@npm:6.1.0"
@@ -4243,6 +4250,7 @@ __metadata:
eslint: "npm:^9"
globals: "npm:^15"
gridstack: "npm:^7"
highlight.js: "npm:^11.11.1"
litepicker: "npm:^2"
luxon: "npm:^3"
sass: "npm:^1"