Compare commits

...

6 Commits

Author SHA1 Message Date
Kevin Papst
3f184e42c8 missing translations 2026-01-31 10:28:30 +01:00
Kevin Papst
4a31411d69 Release 2.48 (#5789) 2026-01-30 16:44:26 +01:00
Weblate (bot)
3925eacf9f Translated using Weblate (#5748)
Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Amir <amearb@duck.com>
Co-authored-by: Fabio Gomes de lima <fabiogomesdelima598@gmail.com>
Co-authored-by: Heeheon Ryu <heeheon.ryu001@gmail.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Kolappan N <kolappan@users.noreply.hosted.weblate.org>
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Mauro F. T. <maurofroeltani@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Pose marto <weblate.drainage895@passmail.net>
Co-authored-by: Serhii Horichenko <serhii@horichenko.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: avv-dev <vildan.abdullin@gmail.com>
Co-authored-by: m45ked <m45ked@users.noreply.hosted.weblate.org>
2026-01-30 13:14:21 +01:00
Kevin Papst
394f377890 Update header (#5799) 2026-01-28 20:10:33 +01:00
Milo Ivir
e8b0dc4969 Add translation links to README (#5798)
Co-authored-by: Kevin Papst <kevinpapst@users.noreply.github.com>
2026-01-28 19:58:12 +01:00
Kevin Papst
d429c56687 Release 2.47 (#5784) 2026-01-25 09:51:22 +01:00
205 changed files with 4352 additions and 5039 deletions

View File

@@ -60,7 +60,6 @@ jobs:
tags: |
kimai/kimai2:latest
kimai/kimai2:fpm
kimai/kimai2:fpm-${{ env.kimai_version }}
push: true
- name: Apache image
@@ -74,6 +73,7 @@ jobs:
target: prod
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:stable
kimai/kimai2:apache
kimai/kimai2:apache-${{ env.kimai_version }}
push: true

View File

@@ -6,11 +6,9 @@
<a href="https://github.com/kimai/kimai/actions"><img alt="CI Status" src="https://github.com/kimai/kimai/actions/workflows/testing.yaml/badge.svg"></a>
<a href="https://codecov.io/gh/kimai/kimai"><img alt="Code Coverage" src="https://codecov.io/gh/kimai/kimai/branch/main/graph/badge.svg"></a>
<a href="https://packagist.org/packages/kimai/kimai"><img alt="Latest stable version" src="https://poser.pugx.org/kimai/kimai/v/stable"></a>
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html"><img alt="License" src="https://poser.pugx.org/kimai/kimai/license"></a>
<a href="https://phpc.social/@kimai" rel="me"><img alt="Mastodon" src="https://img.shields.io/badge/toot-%40kimai-8c8dff"></a>
</p>
<h1 align="center">Kimai - time-tracker</h1>
<h1 align="center">Kimai<br>#1 Open-Source Time-Tracker</h1>
Kimai is a professional grade time-tracking application, free and open-source.
It handles use-cases of freelancers as well as companies with dozens or hundreds of users.
@@ -71,6 +69,7 @@ The best way to start is to [open a new issue](https://github.com/kimai/kimai/is
In case you want to contribute, but you wouldn't know how, here are some suggestions:
- Spread the word: Please [write a testimonial for our Wall of love](https://love.kimai.org), vote for Kimai on any software platform, you can toot or tweet about it, share it on LinkedIn, Reddit and any other social media platform!
- [Translate Kimai into your language](https://hosted.weblate.org/engage/kimai/), or help to improve the existing translations, many languages look for a contributor
- Answer questions: You know the answer to another user's problem? Share your knowledge.
- Something can be done better? An essential feature is missing? Create a feature request.
- Report bugs makes Kimai better for everyone.
@@ -79,6 +78,12 @@ In case you want to contribute, but you wouldn't know how, here are some suggest
There is one simple rule in our "Code of conduct": Don't be an ass!
## Follow Kimai
- Mastodon: [@kimai](https://phpc.social/@kimai)
- Youtube: [@kimai_org](https://www.youtube.com/@kimai_org)
- LinkedIn: [@kimai-org](https://www.linkedin.com/company/kimai-org/)
### Credits
Kimai is based on modern technologies and frameworks such as [PHP](https://www.php.net/),

View File

@@ -73,6 +73,7 @@ export default class KimaiAPILink extends KimaiPlugin {
const ALERT = this.getContainer().getPlugin('alert');
const successHandle = () => {
EVENTS.trigger(eventName);
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
if (attributes['msgSuccess'] !== undefined) {
ALERT.success(attributes['msgSuccess']);
}
@@ -82,6 +83,7 @@ export default class KimaiAPILink extends KimaiPlugin {
if (attributes['msgError'] !== undefined) {
message = attributes['msgError'];
}
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
API.handleError(message, error);
};
@@ -90,6 +92,8 @@ export default class KimaiAPILink extends KimaiPlugin {
data = attributes['payload'];
}
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
if (method === 'PATCH') {
API.patch(url, data, successHandle, errorHandle);
} else if (method === 'POST') {

View File

@@ -217,8 +217,6 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
const eventName = form.dataset['formEvent'];
/** @type {KimaiEvent} alert */
const events = this.getContainer().getPlugin('event');
/** @type {KimaiAlert} alert */
const alert = this.getContainer().getPlugin('alert');
event.preventDefault();
event.stopPropagation();
@@ -259,15 +257,8 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
} else {
events.trigger(eventName);
// try to find form defined message first, but
let msg = form.dataset['msgSuccess'];
// if that is not available: use a generic fallback message
if (msg === null || msg === undefined || msg === '') {
msg = 'action.update.success';
}
this._isDirty = false;
this._getModal().hide();
alert.success(msg);
}
});
})
@@ -277,6 +268,8 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
message = 'action.update.error';
}
/** @type {KimaiAlert} alert */
const alert = this.getContainer().getPlugin('alert');
alert.error(message, error.message);
// this is useful for changing form fields and retrying to save (and in development to test form changes)

View File

@@ -42,7 +42,7 @@ export default class KimaiThemeInitializer extends KimaiPlugin {
}
// at which element we append the loading screen
let container = 'body';
let container = 'div.page-wrapper';
if (event.detail !== undefined && event.detail !== null) {
container = event.detail;
}

View File

@@ -70,8 +70,6 @@ export default class KimaiCalendar {
const DATES = this.kimai.getPlugin('date');
/** @type {KimaiAjaxModalForm} MODAL */
const MODAL = this.kimai.getPlugin('modal');
/** @type {KimaiAlert} ALERT */
const ALERT = this.kimai.getPlugin('alert');
// Instead of using "buttonIcons" the theme needs to be adjusted directly
// https://fullcalendar.io/docs/buttonIcons
@@ -282,6 +280,7 @@ export default class KimaiCalendar {
droppable: true,
// drop function handles external draggable events
drop: (dropInfo) => {
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
const entry = dropInfo.draggedEl;
const source = entry.parentElement;
let data = JSON.parse(entry.dataset.entry);
@@ -324,7 +323,7 @@ export default class KimaiCalendar {
(result) => {
const newItem = this.convertSourceForCalendar(result);
this.getCalendar().addEvent(newItem, true);
ALERT.success('action.update.success');
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
}
);
} else {
@@ -334,7 +333,7 @@ export default class KimaiCalendar {
(result) => {
const newItem = this.convertSourceForCalendar(result);
this.getCalendar().addEvent(newItem, true);
ALERT.success('action.update.success');
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
}
);
}
@@ -703,8 +702,6 @@ export default class KimaiCalendar {
/** @type {KimaiAPI} API */
const API = this.kimai.getPlugin('api');
/** @type {KimaiAlert} ALERT */
const ALERT = this.kimai.getPlugin('alert');
/** @type {KimaiDateUtils} DATE */
const DATES = this.kimai.getPlugin('date');
@@ -716,11 +713,14 @@ export default class KimaiCalendar {
payload.end = null;
}
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
const updateUrl = this.options.url.update(event.id);
API.patch(updateUrl, JSON.stringify(payload), () => {
ALERT.success('action.update.success');
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
}, (error) => {
eventArg.revert();
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
API.handleError('action.update.error', error);
});
}

View File

@@ -1,3 +1,4 @@
@import "variables";
@import "layout";
@import "error-page";
@import "print";

View File

@@ -12,6 +12,6 @@
width: 100%;
height: 100%;
z-index: 1021;
background-color: var(--tblr-backdrop-bg);
/*background-color: var(--tblr-backdrop-bg);*/
opacity: $modal-backdrop-opacity;
}

View File

@@ -48,4 +48,12 @@ fieldset.form-fieldset > legend {
.page-title {
color: var(--tblr-body-color);
}
}
}
/* Highlighted text is not visible - https://github.com/tabler/tabler/issues/2603 */
[data-bs-theme=dark] {
::selection,
.text-selected {
background-color: var(--#{$prefix}primary);
}
}

View File

@@ -0,0 +1,33 @@
:root {
--kimai-public-holiday: var(--tblr-lime);
--kimai-holiday: var(--tblr-green);
--kimai-sickness: var(--tblr-yellow);
--kimai-time-off: var(--tblr-blue);
--kimai-other: var(--tblr-purple);
--kimai-public-holiday-bg: var(--tblr-lime-lt);
--kimai-holiday-bg: var(--tblr-green-lt);
--kimai-sickness-bg: var(--tblr-yellow-lt);
--kimai-time-off-bg: var(--tblr-blue-lt);
--kimai-other-bg: var(--tblr-purple-lt);
--kimai-unexpected-bg: var(--tblr-pink-lt);
--kimai-missing-bg: var(--tblr-pink-lt);
--kimai-weekend-bg: var(--tblr-bg-surface-tertiary);
}
.public-holiday { color: var(--kimai-public-holiday); }
.holiday { color: var(--kimai-holiday); }
.sickness { color: var(--kimai-sickness); }
.time-off { color: var(--kimai-time-off); }
.other { color: var(--kimai-other); }
.bg-public-holiday{ background-color: var(--kimai-public-holiday-bg); --tblr-table-bg: var(--kimai-public-holiday-bg); i.fas{ color: var(--kimai-public-holiday); } };
.bg-holiday { background-color: var(--kimai-holiday-bg); --tblr-table-bg: var(--kimai-holiday-bg); i.fas{ color: var(--kimai-holiday); } };
.bg-sickness { background-color: var(--kimai-sickness-bg); --tblr-table-bg: var(--kimai-sickness-bg); i.fas{ color: var(--kimai-sickness); } };
.bg-time-off { background-color: var(--kimai-time-off-bg); --tblr-table-bg: var(--kimai-time-off-bg); i.fas{ color: var(--kimai-time-off); } };
.bg-other { background-color: var(--kimai-other-bg); --tblr-table-bg: var(--kimai-other-bg); i.fas{ color: var(--kimai-other); } };
.bg-unexpected { background-color: var(--kimai-unexpected-bg); --tblr-table-bg: var(--kimai-unexpected-bg); };
.bg-missing { background-color: var(--kimai-missing-bg); --tblr-table-bg: var(--kimai-missing-bg); };
.bg-weekend { background-color: var(--kimai-weekend-bg); --tblr-table-bg: var(--kimai-weekend-bg); };

844
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -75,6 +75,7 @@ tabler:
fax: fas fa-fax
filter: fas fa-filter
help: far fa-question-circle
holiday: fas fa-umbrella-beach
home: fas fa-home
info: fas fa-info-circle
import: fas fa-file-import
@@ -95,6 +96,7 @@ tabler:
ods: fas fa-table
off: fas fa-toggle-off
on: fas fa-toggle-on
other: fas fa-file-alt
password: fas fa-key
pause: fas fa-pause
pause-small: far fa-pause-circle
@@ -108,6 +110,7 @@ tabler:
profile: fas fa-user-edit
profile-stats: far fa-chart-bar
project: fas fa-briefcase
public-holiday: fas fa-calendar-day
repeat: fas fa-repeat
reporting: far fa-chart-bar
report: far fa-chart-bar
@@ -119,6 +122,7 @@ tabler:
save: far fa-save
search: fas fa-search
settings: fas fa-cog
sickness: fas fa-prescription-bottle-medical
shop: fas fa-shopping-cart
spinner: fas fa-spinner
start: fas fa-play
@@ -130,6 +134,7 @@ tabler:
team: fas fa-users
timesheet: fas fa-clock
timesheet-team: fas fa-user-clock
time-off: fas fa-couch
trash: far fa-trash-alt
unlocked: fas fa-unlock-alt
upload: fas fa-upload

View File

@@ -2644,16 +2644,6 @@ parameters:
count: 1
path: src/Form/Type/UserPreferenceType.php
-
message: "#^PHPDoc tag @var for variable \\$collection contains generic class Doctrine\\\\Common\\\\Collections\\\\ArrayCollection but does not specify its types\\: TKey, T$#"
count: 1
path: src/Form/Type/UserPreferencesCollectionType.php
-
message: "#^Parameter \\#1 \\$key of method Doctrine\\\\Common\\\\Collections\\\\ArrayCollection\\<\\(int\\|string\\),mixed\\>\\:\\:set\\(\\) expects \\(int\\|string\\), string\\|null given\\.$#"
count: 1
path: src/Form/Type/UserPreferencesCollectionType.php
-
message: "#^Cannot access offset mixed on mixed\\.$#"
count: 1
@@ -3819,61 +3809,6 @@ parameters:
count: 1
path: src/Utils/ProfileManager.php
-
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access offset 0 on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$body on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$draft on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$html_url on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$prerelease on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$published_at on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$tag_name on mixed\\.$#"
count: 2
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access property \\$zipball_url on mixed\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Method App\\\\Utils\\\\ReleaseVersion\\:\\:getReleasesFromGithub\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
count: 1
path: src/Utils/ReleaseVersion.php
-
message: "#^Cannot access offset 'pattern' on mixed\\.$#"
count: 1
@@ -4008,8 +3943,3 @@ parameters:
message: "#^Method App\\\\Form\\\\MultiUpdate\\\\MultiUpdateTableDTO\\:\\:setEntities\\(\\) has parameter \\$entities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection but does not specify its types\\: TKey, T$#"
count: 1
path: src/Form/MultiUpdate/MultiUpdateTableDTO.php
-
message: "#^Method App\\\\Entity\\\\InvoiceTemplate\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
path: src/Entity/InvoiceTemplate.php

View File

@@ -14,7 +14,7 @@
<ini name="error_reporting" value="-1"/>
<ini name="max_execution_time" value="-1"/>
<ini name="date.timezone" value="UTC"/>
<ini name="intl.default_locale" value="en_US"/>
<ini name="intl.default_locale" value="en"/>
<ini name="date.timezone" value="Europe/Vienna"/>
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{876:function(i,n,u){"use strict";u.r(n)},2395:function(i,n,u){u(876)}},function(i){var n;n=2395,i(i.s=n)}]);

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{177:function(i,n,u){u(7996)},7996:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=177,i(i.s=n)}]);

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

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
/*!
* @kurkle/color v0.3.2
* @kurkle/color v0.3.4
* https://github.com/kurkle/color#readme
* (c) 2023 Jukka Kurkela
* (c) 2024 Jukka Kurkela
* Released under the MIT License
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,26 +2,26 @@
"entrypoints": {
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.307ea672.js"
"/build/runtime.684e9f6d.js",
"/build/app.41748d55.js"
],
"css": [
"/build/app.c18ba3c6.css"
"/build/app.6bfb9c66.css"
]
},
"app-rtl": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app-rtl.fe7c5bf2.js"
"/build/runtime.684e9f6d.js",
"/build/app-rtl.15853b82.js"
],
"css": [
"/build/app-rtl.2003dce5.css"
"/build/app-rtl.16271884.css"
]
},
"export-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/export-pdf.5905454a.js"
"/build/runtime.684e9f6d.js",
"/build/export-pdf.395749ab.js"
],
"css": [
"/build/export-pdf.d8a6c23b.css"
@@ -29,8 +29,8 @@
},
"invoice": {
"js": [
"/build/runtime.6c399d29.js",
"/build/invoice.0217cc18.js"
"/build/runtime.684e9f6d.js",
"/build/invoice.42b319e4.js"
],
"css": [
"/build/invoice.36018785.css"
@@ -38,8 +38,8 @@
},
"invoice-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/invoice-pdf.1b6e735c.js"
"/build/runtime.684e9f6d.js",
"/build/invoice-pdf.26d98626.js"
],
"css": [
"/build/invoice-pdf.2b749265.css"
@@ -47,14 +47,14 @@
},
"chart": {
"js": [
"/build/runtime.6c399d29.js",
"/build/chart.2c85f027.js"
"/build/runtime.684e9f6d.js",
"/build/chart.56f16a68.js"
]
},
"calendar": {
"js": [
"/build/runtime.6c399d29.js",
"/build/calendar.cef61816.js"
"/build/runtime.684e9f6d.js",
"/build/calendar.36a92145.js"
],
"css": [
"/build/calendar.d757753e.css"
@@ -62,8 +62,8 @@
},
"dashboard": {
"js": [
"/build/runtime.6c399d29.js",
"/build/dashboard.faf42d4e.js"
"/build/runtime.684e9f6d.js",
"/build/dashboard.9708ae5e.js"
],
"css": [
"/build/dashboard.b7129fa1.css"
@@ -71,8 +71,8 @@
},
"highlight": {
"js": [
"/build/runtime.6c399d29.js",
"/build/highlight.718fe73d.js"
"/build/runtime.684e9f6d.js",
"/build/highlight.0296a734.js"
],
"css": [
"/build/highlight.98bf3927.css"
@@ -80,23 +80,23 @@
}
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.307ea672.js": "sha384-XNjtkar2JbWyJQan/C6jHZAYFI9X5rPcExLeDV86FYZMPJl0vNWMh8Pa8NgXX/EB",
"/build/app.c18ba3c6.css": "sha384-qkIgqLzngG2NchdFVImbGMU49lWlZg6Y9D0z2P0u1j2NQstDicA4KqIkkNYcpErm",
"/build/app-rtl.fe7c5bf2.js": "sha384-UyLXlR/3/H6oFjRrIMH04PA8bAjVQ8rdVWkWoiO4LLYasg4H3UNWJ+Xh+FrYekKh",
"/build/app-rtl.2003dce5.css": "sha384-pl8GyGo8sRRw1zLh9D42ZAvo450onfxeszZWocUUuzq70jAOwOlaoPYh/yyc20U7",
"/build/export-pdf.5905454a.js": "sha384-AkNj018YEQJSxC1e/3jIvlh0uR5tHnlUe9ciBbHKIoRIwj7658tCLNVaT98SNFEX",
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
"/build/app.41748d55.js": "sha384-iVPHnOXYjVDuq8567h8I3U2gl5SCV3mNMFNQCJdgMiNuFMZ4KO7PXOLanjgcGzTt",
"/build/app.6bfb9c66.css": "sha384-38+zSolYBrZdSwNcrDi5qCaQGCQlBJRKwtsJV+pnrf8rCCpf6z/M7fEpV4tS79Sz",
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
"/build/app-rtl.16271884.css": "sha384-P1x4vYfjfM4JL55NQ3sfLiy+BSy8cM4bNcshj9M62nMJ4JD2xAWUioNv6VrmaK7U",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.0217cc18.js": "sha384-ORySQp4DBSYc7so90AAb8h5abgQtz3tdoO+Zv1uHJ2XqSPMAGtFBrb+Rdzc6sZg1",
"/build/invoice.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
"/build/invoice.36018785.css": "sha384-jukM9uZ6pexDxXKgZThSxiqXimzsxzniBMHz08N9x8ryXZYkM5r/ZgaainCV0+J6",
"/build/invoice-pdf.1b6e735c.js": "sha384-8I5YMkETBNl7wD12V/hKX2YGfwH+V6PXWsu4I+1xXXx9bDzqDhtq+S7x/jey2/Wr",
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.2c85f027.js": "sha384-5Dm9aFCCSzlh5QY/vtU7/UrBkKt6aWnTccjljmpbQVFfzSEfFh0wUUV8qlXqyFJj",
"/build/calendar.cef61816.js": "sha384-qCpJrSB6fZLXT94oiWHofGbX8C0TFl86oCMTdeKWqCmNGJxABkIVo+8k19p7sGlY",
"/build/chart.56f16a68.js": "sha384-SWnYjAbZ8OWEvTP+IZfGuBMWJIcH9OZWLYQ4p38KMPmqfufsc2zhzqWkALO3mCBO",
"/build/calendar.36a92145.js": "sha384-ZPLxxbep6OnPeUiIeBXRE1iscZFgbNuTpn3rx2Jpf1KR2l6sxIuNsieDUskbmQ3m",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.faf42d4e.js": "sha384-xkM5D8wBK1sJekLI9DUQBxTjxjomfPQWWtWCo7NX03jNnFZtMv5anM0IISLWaFY0",
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
"/build/highlight.718fe73d.js": "sha384-eHb21blXJ7SKqCFlMSxl4oblpusDif1B0Tsr2Iw1xQfUqxZxwtFwYYvMPllfw7BY",
"/build/highlight.0296a734.js": "sha384-vGFKI/KM+uyGIlzKsKhQh4w41hQteB4u14oRYqObMT03/YTZXq6Re5nUN6g3sGQH",
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
}
}

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{878:function(i,n,u){u(3385)},3385:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=878,i(i.s=n)}]);

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{585:function(i,n,u){"use strict";u.r(n)},2352:function(i,n,u){u(585)}},function(i){var n;n=2352,i(i.s=n)}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{2049:function(i,n,u){u(4860)},4860:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=2049,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{1555:function(i,n,u){u(6620)},6620:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=1555,i(i.s=n)}]);

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{239:function(i,n,u){"use strict";u.r(n)},8478:function(i,n,u){u(239)}},function(i){var n;n=8478,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{3631:function(i,n,u){"use strict";u.r(n)},4820:function(i,n,u){u(3631)}},function(i){var n;n=4820,i(i.s=n)}]);

View File

@@ -1,22 +1,22 @@
{
"build/app.css": "/build/app.c18ba3c6.css",
"build/app.js": "/build/app.307ea672.js",
"build/app-rtl.css": "/build/app-rtl.2003dce5.css",
"build/app-rtl.js": "/build/app-rtl.fe7c5bf2.js",
"build/app.css": "/build/app.6bfb9c66.css",
"build/app.js": "/build/app.41748d55.js",
"build/app-rtl.css": "/build/app-rtl.16271884.css",
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.5905454a.js",
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
"build/invoice.css": "/build/invoice.36018785.css",
"build/invoice.js": "/build/invoice.0217cc18.js",
"build/invoice.js": "/build/invoice.42b319e4.js",
"build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
"build/invoice-pdf.js": "/build/invoice-pdf.1b6e735c.js",
"build/chart.js": "/build/chart.2c85f027.js",
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
"build/chart.js": "/build/chart.56f16a68.js",
"build/calendar.css": "/build/calendar.d757753e.css",
"build/calendar.js": "/build/calendar.cef61816.js",
"build/calendar.js": "/build/calendar.36a92145.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.faf42d4e.js",
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
"build/highlight.css": "/build/highlight.98bf3927.css",
"build/highlight.js": "/build/highlight.718fe73d.js",
"build/runtime.js": "/build/runtime.6c399d29.js",
"build/highlight.js": "/build/highlight.0296a734.js",
"build/runtime.js": "/build/runtime.684e9f6d.js",
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.2582b0e4.ttf",
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.1815e004.ttf",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.2463b90d.woff2",

View File

@@ -0,0 +1 @@
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every(function(e){return t.O[e](n[a])})?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(r,{a:r}),r},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some(function(r){return 0!==e[r]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();

View File

@@ -1 +0,0 @@
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every((function(e){return t.O[e](n[a])}))?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(r,{a:r}),r},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some((function(r){return 0!==e[r]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();

View File

@@ -508,7 +508,7 @@ final class TranslationCommand extends Command
\sprintf('Missing english translation for key: %s in file %s', $key, $file)
);
}
$unit->target[0] = $translations[$key];
$unit->target[0] = $translations[$key]; // @phpstan-ignore-line
$unit->target['state'] = 'needs-translation';
$foundEmpty = true;
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.46.0';
public const VERSION = '2.48.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24600;
public const VERSION_ID = 24800;
/**
* The software name
*/

View File

@@ -108,10 +108,30 @@ final class DoctorController extends AbstractController
'logLines' => $logLines,
'logSize' => $this->getLogSize(),
'composer' => $this->getComposerPackages(),
'release' => $latestRelease
'release' => $latestRelease,
'opcache' => $this->getOpcacheConfiguration()
]);
}
/**
* @return array{enabled: bool, status: false|array<mixed>}
*/
private function getOpcacheConfiguration(): array
{
$status = \function_exists('opcache_get_status') ? opcache_get_status() : false;
$enabled = \is_array($status) && $status['opcache_enabled'];
if ($enabled && \array_key_exists('scripts', $status)) {
unset($status['scripts']);
}
return [
'enabled' => $enabled,
'status' => $status,
];
}
/**
* @return array<string, string>
*/
@@ -264,7 +284,12 @@ final class DoctorController extends AbstractController
'sys_temp_dir',
'date.timezone',
'session.gc_maxlifetime',
'disable_functions'
'disable_functions',
'opcache.enable',
'opcache.memory_consumption',
'opcache.interned_strings_buffer',
'opcache.max_accelerated_files',
'opcache.validate_timestamps',
];
$settings = [];

View File

@@ -256,6 +256,18 @@ class ExportTemplate
return \is_string($font) ? $font : null;
}
public function isAvailableForAll(): bool
{
$isAllowed = $this->getOption('user_access', false);
return \is_bool($isAllowed) ? $isAllowed : false;
}
public function setAvailableForAll(bool $userAccess): void
{
$this->setOption('user_access', $userAccess);
}
public function __toString(): string
{
return $this->title ?? 'New';

View File

@@ -301,7 +301,7 @@ class InvoiceTemplate implements EntityWithMetaFields
}
/**
* @return Collection|MetaTableTypeInterface[]
* @return Collection<int, InvoiceTemplateMeta>
*/
public function getMetaFields(): Collection
{

View File

@@ -376,14 +376,24 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
}
/**
* This method is called from the "edit user preferences" form.
* Therefor it just merges the values for existing preferences and adds new ones.
* But it will NOT remove existing preferences or replace the underlying collection.
*
* @param iterable<UserPreference> $preferences
*/
public function setPreferences(iterable $preferences): User
{
$this->preferences = new ArrayCollection();
foreach ($preferences as $preference) {
$this->addPreference($preference);
if (($name = $preference->getName()) === null) {
continue;
}
$p = $this->getPreference($name);
if ($p === null) {
$this->addPreference($preference);
} else {
$p->setValue($preference->getValue());
}
}
return $this;

View File

@@ -34,7 +34,7 @@ final class TimesheetsSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('export_own_timesheet')) {
foreach ($this->serviceExport->getTimesheetExporter() as $exporter) {
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => 'button.' . $exporter->getId(), 'translation_domain' => 'messages']);
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => $exporter->getTitle()]);
}
}
}

View File

@@ -35,7 +35,7 @@ final class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('export_other_timesheet')) {
foreach ($this->serviceExport->getTimesheetExporter() as $exporter) {
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('admin_timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => 'button.' . $exporter->getId(), 'translation_domain' => 'messages']);
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('admin_timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => $exporter->getTitle()]);
}
}
}

View File

@@ -24,7 +24,7 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\ExportPolicy;
use App\Twig\SecurityPolicy\StrictPolicy;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Symfony\Component\HttpFoundation\Response;
@@ -100,9 +100,12 @@ class HtmlRenderer implements ExportRendererInterface
$summary = $this->calculateSummary($exportItems);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
if (!$this->twig->hasExtension(SandboxExtension::class)) {
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
}
$sandbox = $this->twig->getExtension(SandboxExtension::class);
$sandbox->enableSandbox();
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $exportItems,

View File

@@ -17,7 +17,7 @@ use App\Pdf\PdfContext;
use App\Pdf\PdfRendererTrait;
use App\Project\ProjectStatisticService;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\ExportPolicy;
use App\Twig\SecurityPolicy\StrictPolicy;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
@@ -103,9 +103,12 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
$summary = $this->calculateSummary($exportItems);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
if (!$this->twig->hasExtension(SandboxExtension::class)) {
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
}
$sandbox = $this->twig->getExtension(SandboxExtension::class);
$sandbox->enableSandbox();
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $exportItems,
@@ -116,6 +119,8 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
'pdfContext' => $context
], $this->getOptions($query)));
$sandbox->disableSandbox();
$pdfOptions = array_merge($context->getOptions(), $this->getPdfOptions());
$content = $this->converter->convertToPdf($content, $pdfOptions);

View File

@@ -35,7 +35,7 @@ final class CsvRendererFactory
public function createDefault(): CsvRenderer
{
$template = new DefaultTemplate($this->eventDispatcher, 'csv');
$template = new DefaultTemplate($this->eventDispatcher, 'csv', 'en', 'csv');
return new CsvRenderer($this->converter, $this->translator, $template);
}

View File

@@ -25,7 +25,7 @@ final class HtmlRendererFactory
) {
}
public function create(string $id, string $template): HtmlRenderer
public function create(string $id, string $template, string $title = 'print'): HtmlRenderer
{
return new HtmlRenderer(
$this->twig,
@@ -33,7 +33,7 @@ final class HtmlRendererFactory
$this->projectStatisticService,
$this->activityStatisticService,
$id,
'print',
$title,
$template
);
}

View File

@@ -27,7 +27,7 @@ final class XlsxRendererFactory
public function createDefault(): XlsxRenderer
{
$template = new DefaultTemplate($this->eventDispatcher, 'xlsx');
$template = new DefaultTemplate($this->eventDispatcher, 'xlsx', 'en', 'xlsx');
return new XlsxRenderer($this->converter, $this->translator, $template);
}

View File

@@ -10,6 +10,7 @@
namespace App\Export;
use App\Entity\ExportableItem;
use App\Entity\ExportTemplate;
use App\Event\ExportItemsQueryEvent;
use App\Export\Renderer\CsvRendererFactory;
use App\Export\Renderer\HtmlRendererFactory;
@@ -74,6 +75,15 @@ final class ServiceExport
$this->renderer[] = $renderer;
}
private function filenameToTitle(string $title): string
{
if (str_contains($title, '.')) {
$title = explode('.', $title)[0];
}
return str_replace(['-', '_'], ' ', $title);
}
/**
* @return ExportRendererInterface[]
*/
@@ -82,32 +92,15 @@ final class ServiceExport
$renderer = [
$this->csvRendererFactory->createDefault(),
$this->xlsxRendererFactory->createDefault(),
$this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'default'),
$this->htmlRendererFactory->create('html', 'export/print.html.twig'),
$this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'pdf'),
$this->htmlRendererFactory->create('print', 'export/print.html.twig'),
];
foreach ($this->exportTemplateRepository->findAll() as $template) {
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
$tpl->setOptions($template->getOptions());
switch ($template->getRenderer()) {
case 'csv':
$renderer[] = $this->csvRendererFactory->create($tpl);
break;
case 'xlsx':
$renderer[] = $this->xlsxRendererFactory->create($tpl);
break;
case 'pdf':
$renderer[] = $this->pdfRendererFactory->createFromTemplate($tpl);
break;
default:
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
break;
try {
$renderer[] = $this->createTemplateFromExportTemplate($template);
} catch (\Exception $exception) {
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
}
}
@@ -124,7 +117,7 @@ final class ServiceExport
continue;
}
$renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName);
$renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName, $this->filenameToTitle($tplName));
}
}
@@ -136,7 +129,7 @@ final class ServiceExport
continue;
}
$renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName);
$renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName, $this->filenameToTitle($tplName));
}
}
}
@@ -144,6 +137,28 @@ final class ServiceExport
return array_merge($this->renderer, $renderer);
}
private function createTemplateFromExportTemplate(ExportTemplate $template): ExportRendererInterface
{
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
$tpl->setOptions($template->getOptions());
switch ($template->getRenderer()) {
case 'csv':
return $this->csvRendererFactory->create($tpl);
case 'xlsx':
return $this->xlsxRendererFactory->create($tpl);
case 'pdf':
return $this->pdfRendererFactory->createFromTemplate($tpl);
default:
throw new \Exception('Unknown export template type: ' . $template->getRenderer());
}
}
public function getRendererById(string $id): ?ExportRendererInterface
{
foreach ($this->getRenderer() as $renderer) {
@@ -165,6 +180,7 @@ final class ServiceExport
*/
public function getTimesheetExporter(): array
{
// TODO 3.0 cache the result, as this is one extra database query on the timesheet pages
$exporter = [
$this->pdfRendererFactory->create('pdf', '@export/timesheet.pdf.twig'),
$this->xlsxRendererFactory->createDefault(),
@@ -172,6 +188,17 @@ final class ServiceExport
$this->htmlRendererFactory->create('print', 'timesheet/export.html.twig'),
];
foreach ($this->exportTemplateRepository->findAll() as $template) {
if (!$template->isAvailableForAll()) {
continue;
}
try {
$exporter[] = $this->createTemplateFromExportTemplate($template);
} catch (\Exception $exception) {
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
}
}
return array_merge($this->timesheetExporter, $exporter);
}

View File

@@ -15,6 +15,7 @@ use App\Form\Type\ExportRendererType;
use App\Form\Type\ExportSummaryColumnsType;
use App\Form\Type\LanguageType;
use App\Form\Type\PdfFontType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -22,6 +23,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
/**
* TODO rename with 3.0 to ExportTemplateForm
*/
class ExportTemplateSpreadsheetForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -77,6 +81,11 @@ class ExportTemplateSpreadsheetForm extends AbstractType
'row_attr' => ['data-type' => 'pdf'],
'required' => false,
]);
$builder->add('availableForAll', YesNoType::class, [
'label' => 'user_access_all',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -19,9 +19,9 @@ final class ExportRendererType extends AbstractType
{
$resolver->setDefaults([
'choices' => [
'button.csv' => 'csv',
'button.xlsx' => 'xlsx',
'button.pdf' => 'pdf'
'csv' => 'csv',
'xlsx' => 'xlsx',
'pdf' => 'pdf'
],
]);
}

View File

@@ -33,8 +33,9 @@ final class TeamType extends AbstractType
return $team->getName();
},
'documentation' => [
'type' => 'integer',
'description' => 'Team ID',
'type' => 'array',
'items' => ['type' => 'integer', 'description' => 'Team IDs'],
'description' => 'Array of Team IDs',
],
]);

View File

@@ -30,11 +30,10 @@ final class UserPreferencesCollectionType extends AbstractType
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event): void {
/** @var ArrayCollection<UserPreference> $collection */
/** @var ArrayCollection<int, UserPreference> $collection */
$collection = $event->getData();
$preferences = new ArrayCollection();
foreach ($collection as $collectionItem) {
$collection->removeElement($collectionItem);
if (!($collectionItem instanceof UserPreference)) {
continue;
}
@@ -44,8 +43,9 @@ final class UserPreferencesCollectionType extends AbstractType
continue;
}
$collection->set($collectionItem->getName(), $collectionItem);
$preferences->set($collectionItem->getName(), clone $collectionItem);
}
$event->setData($preferences);
},
// must be a higher priority then the listener in UserPreferenceType
100

View File

@@ -13,7 +13,7 @@ use App\Invoice\InvoiceModel;
use App\Invoice\RendererInterface;
use App\Model\InvoiceDocument;
use App\Twig\LocaleFormatExtensions;
use App\Twig\SecurityPolicy\InvoicePolicy;
use App\Twig\SecurityPolicy\StrictPolicy;
use Symfony\Bridge\Twig\Extension\TranslationExtension;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Twig\Environment;
@@ -64,7 +64,7 @@ abstract class AbstractTwigRenderer implements RendererInterface
}
if (!$twig->hasExtension(SandboxExtension::class)) {
$twig->addExtension(new SandboxExtension(new InvoicePolicy()));
$twig->addExtension(new SandboxExtension(new StrictPolicy()));
}
$sandbox = $twig->getExtension(SandboxExtension::class);

View File

@@ -1,44 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig\SecurityPolicy;
use Twig\Sandbox\SecurityPolicyInterface;
final class ChainPolicy implements SecurityPolicyInterface
{
/** @var array<SecurityPolicyInterface> */
private array $policies = [];
public function addPolicy(SecurityPolicyInterface $policy): void
{
$this->policies[] = $policy;
}
public function checkSecurity($tags, $filters, $functions): void
{
foreach ($this->policies as $policy) {
$policy->checkSecurity($tags, $filters, $functions);
}
}
public function checkMethodAllowed($obj, $method): void
{
foreach ($this->policies as $policy) {
$policy->checkMethodAllowed($obj, $method);
}
}
public function checkPropertyAllowed($obj, $property): void
{
foreach ($this->policies as $policy) {
$policy->checkPropertyAllowed($obj, $property);
}
}
}

View File

@@ -9,72 +9,34 @@
namespace App\Twig\SecurityPolicy;
use App\Entity\User;
use App\Pdf\PdfContext;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerBag;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Twig\Sandbox\SecurityPolicyInterface;
/**
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
* @deprecated since 2.47.0 - use StrictPolicy instead
*/
#[Exclude]
final class DefaultPolicy implements SecurityPolicyInterface
{
private SecurityPolicyInterface $securityPolicy;
public function __construct()
{
$this->securityPolicy = new StrictPolicy();
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof ServerBag) {
throw new SecurityNotAllowedMethodError('Tried to access server environment', ServerBag::class, $method);
}
if ($obj instanceof SessionInterface) {
throw new SecurityNotAllowedMethodError('Tried to access session', SessionInterface::class, $method);
}
$lcm = strtolower($method);
if ($obj instanceof PdfContext) {
if ($lcm !== 'setoption') {
throw new SecurityNotAllowedMethodError('Tried to access forbidden method on PdfContext', PdfContext::class, $method);
}
return;
}
if (!str_starts_with($lcm, 'has') && !str_starts_with($lcm, 'is') && !str_starts_with($lcm, 'get') && $lcm !== '__tostring') {
throw new SecurityNotAllowedMethodError('Tried to access non-read method', $obj::class, $method);
}
if ($obj instanceof Request) {
if (!str_starts_with($lcm, 'get')) {
throw new SecurityNotAllowedMethodError('Tried to call setter() of app variable', AppVariable::class, $method);
}
return;
}
if ($obj instanceof AppVariable) {
if (!\in_array($lcm, ['getrequest', 'getuser', 'getlocale'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access forbidden app variable method', User::class, $method);
}
return;
}
if ($obj instanceof User) {
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
}
}
$this->securityPolicy->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->securityPolicy->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -9,33 +9,34 @@
namespace App\Twig\SecurityPolicy;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Twig\Sandbox\SecurityPolicyInterface;
/**
* Represents the security policy for custom Twig export templates.
* @deprecated since 2.47.0 - use StrictPolicy instead
*/
#[Exclude]
final class ExportPolicy implements SecurityPolicyInterface
{
private ChainPolicy $policy;
private SecurityPolicyInterface $securityPolicy;
public function __construct()
{
$this->policy = new ChainPolicy();
$this->policy->addPolicy(new DefaultPolicy());
$this->securityPolicy = new StrictPolicy();
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->policy->checkSecurity($tags, $filters, $functions);
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
{
$this->policy->checkMethodAllowed($obj, $method);
$this->securityPolicy->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->policy->checkPropertyAllowed($obj, $property);
$this->securityPolicy->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -9,220 +9,34 @@
namespace App\Twig\SecurityPolicy;
use App\Invoice\InvoiceModel;
use App\Pdf\PdfContext;
use Symfony\Component\String\UnicodeString;
use Twig\Markup;
use Twig\Sandbox\SecurityPolicy;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Template;
/**
* Represents the security policy for custom Twig invoice templates.
* @deprecated since 2.47.0 - use StrictPolicy instead
*/
#[Exclude]
final class InvoicePolicy implements SecurityPolicyInterface
{
private SecurityPolicyInterface $default;
private SecurityPolicyInterface $security;
private SecurityPolicyInterface $securityPolicy;
public function __construct()
{
$this->default = new DefaultPolicy();
$this->security = new SecurityPolicy(
['block', 'if', 'for', 'set', 'extends', 'import'],
[
// =================================================================
// vendor/twig/twig/src/Extension/CoreExtension.php
// formatting filters
'date',
'date_modify',
'format',
'replace',
'number_format',
'abs',
'round',
// encoding
'url_encode',
'json_encode',
'convert_encoding',
// string filters
'title',
'capitalize',
'upper',
'lower',
'striptags',
'trim',
'nl2br',
'spaceless',
// array helpers
'join',
'split',
'sort',
'merge',
'batch',
'column',
'filter',
'map',
'reduce',
// string/array filters
'reverse',
'length',
'slice',
'first',
'last',
// iteration and runtime
'default',
'keys',
// =================================================================
// vendor/twig/twig/src/Extension/EscaperExtension.php
'escape',
'e',
'raw',
// =================================================================
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
'trans',
// =================================================================
// vendor/twig/string-extra/StringExtension.php
'u',
'slug',
// =================================================================
// vendor/twig/intl-extra/IntlExtension.php
'country_name',
'currency_name',
'currency_symbol',
'language_name',
'locale_name',
'timezone_name',
'format_currency',
'format_number',
'format_decimal_number',
'format_currency_number',
'format_percent_number',
'format_scientific_number',
'format_spellout_number',
'format_ordinal_number',
'format_duration_number',
'format_datetime',
'format_date',
'format_time',
// =================================================================
// src/Twig/LocaleFormatExtensions.php
'month_name',
'day_name',
'date_short',
'date_time',
'date_full',
'date_format',
'date_weekday',
'time',
'duration',
'duration_decimal',
'money',
'amount',
// =================================================================
// src/Twig/RuntimeExtensions.php
'md2html',
'desc2html',
'comment2html',
'comment1line',
// =================================================================
// src/Twig/Extensions.php
'multiline_indent',
'color',
'font_contrast',
'default_color',
'nl2str',
],
[
PdfContext::class => ['setoption'],
InvoiceModel::class => ['toarray'],
],
[], // properties
[
// =================================================================
// vendor/twig/twig/src/Extension/CoreExtension.php
'max',
'min',
'range',
'constant',
'cycle',
'random',
'date',
'asset',
'range',
// =================================================================
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
't',
// =================================================================
// vendor/symfony/webpack-encore-bundle/src/Twig/EntryFilesTwigExtension.php
'encore_entry_css_source',
// =================================================================
// vendor/symfony/twig-bridge/Extension/AssetExtension.php
'asset',
// =================================================================
// vendor/symfony/twig-bridge/Extension/SecurityExtension.php
'is_granted',
// =================================================================
// Twig/RuntimeExtensions.php
'qr_code_data_uri',
// =================================================================
// Twig/Configuration.php
'config',
// =================================================================
// Twig/LocaleFormatExtensions.php
'create_date',
'month_names',
'locale_format',
]
);
$this->securityPolicy = new StrictPolicy();
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->default->checkSecurity($tags, $filters, $functions);
$this->security->checkSecurity($tags, $filters, $functions);
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof Template || $obj instanceof Markup || $obj instanceof UnicodeString) { // @phpstan-ignore instanceof.internalClass
return;
}
$this->default->checkMethodAllowed($obj, $method);
$lm = strtolower($method);
if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has') || $lm === '__tostring') {
return;
}
$this->security->checkMethodAllowed($obj, $method);
$this->securityPolicy->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->default->checkPropertyAllowed($obj, $property);
$this->security->checkPropertyAllowed($obj, $property);
$this->securityPolicy->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -0,0 +1,165 @@
<?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\Twig\SecurityPolicy;
use App\Entity\MetaTableTypeInterface;
use App\Entity\User;
use App\Pdf\PdfContext;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerBag;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\String\UnicodeString;
use Twig\Sandbox\SecurityNotAllowedFilterError;
use Twig\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityPolicyInterface;
/**
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
*/
final class StrictPolicy implements SecurityPolicyInterface
{
/** @var string[] */
private array $allowedTags = ['block', 'if', 'for', 'set', 'macro', 'import', 'extends', 'from'];
/** @var string[] */
private array $allowedFunctions = [
// vendor/twig/twig/src/Extension/CoreExtension.php
'max', 'min', 'range', 'constant', 'cycle', 'random', 'date',
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
't',
// vendor/symfony/webpack-encore-bundle/src/Twig/EntryFilesTwigExtension.php
'encore_entry_css_source', 'encore_entry_link_tags', 'encore_entry_script_tags',
// vendor/symfony/twig-bridge/Extension/SecurityExtension.php
'is_granted',
// Twig/RuntimeExtensions.php
'qr_code_data_uri',
// Twig/Configuration.php
'config',
// Twig/LocaleFormatExtensions.php
'create_date', 'month_names', 'locale_format',
// Twig/Extensions.php
'class_name'
];
/** @var string[] */
private array $allowedFilters = [
// vendor/twig/twig/src/Extension/CoreExtension.php
// formatting filters
'date', 'date_modify', 'format', 'replace', 'number_format', 'abs', 'round',
// encoding
'url_encode', 'json_encode',
// string filters
'title', 'capitalize', 'upper', 'lower', 'striptags', 'trim', 'nl2br', 'spaceless',
// array helpers
'join', 'split', 'sort', 'merge', 'column', 'filter', 'map',
// string/array filters
'reverse', 'length', 'slice', 'first', 'last',
// iteration and runtime
'default', 'keys',
// vendor/twig/twig/src/Extension/EscaperExtension.php
'escape', 'e', 'raw',
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
'trans',
// vendor/twig/string-extra/StringExtension.php
'u', 'slug',
// vendor/twig/intl-extra/IntlExtension.php
'country_name', 'currency_name', 'currency_symbol', 'language_name', 'locale_name', 'timezone_name',
'format_currency', 'format_number', 'format_decimal_number', 'format_currency_number',
'format_duration_number', 'format_datetime', 'format_date', 'format_time',
// src/Twig/LocaleFormatExtensions.php
'month_name', 'day_name', 'date_short', 'date_time', 'date_full', 'date_format',
'date_weekday', 'time', 'duration', 'duration_decimal', 'money', 'amount',
// src/Twig/RuntimeExtensions.php
'md2html', 'desc2html', 'comment2html', 'comment1line',
// src/Twig/Extensions.php
'multiline_indent', 'color', 'nl2str'
];
public function checkSecurity($tags, $filters, $functions): void
{
foreach ($tags as $tag) {
if (!\in_array($tag, $this->allowedTags, true)) {
throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag);
}
}
foreach ($filters as $filter) {
if (!\in_array($filter, $this->allowedFilters, true)) {
throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filter), $filter);
}
}
foreach ($functions as $function) {
if (!\in_array($function, $this->allowedFunctions, true)) {
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
}
}
}
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof UnicodeString) {
return;
}
if ($obj instanceof ServerBag) {
throw new SecurityNotAllowedMethodError('Tried to access server environment', ServerBag::class, $method);
}
if ($obj instanceof SessionInterface) {
throw new SecurityNotAllowedMethodError('Tried to access session', SessionInterface::class, $method);
}
$lcm = strtolower($method);
if ($obj instanceof PdfContext) {
if ($lcm !== 'setoption') {
throw new SecurityNotAllowedMethodError('Tried to access forbidden method on PdfContext', PdfContext::class, $method);
}
return;
}
if ($obj instanceof MetaTableTypeInterface && $lcm === 'merge') {
return;
}
if ($obj instanceof Request) {
if (!str_starts_with($lcm, 'get')) {
throw new SecurityNotAllowedMethodError('Tried to call setter() of app variable', AppVariable::class, $method);
}
return;
}
if ($obj instanceof AppVariable) {
if (!\in_array($lcm, ['getrequest', 'getuser', 'getlocale'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access forbidden app variable method', User::class, $method);
}
return;
}
if (!str_starts_with($lcm, 'get') && !str_starts_with($lcm, 'has') && !str_starts_with($lcm, 'is') && $lcm !== '__tostring') {
throw new SecurityNotAllowedMethodError('Tried to access non-read method', $obj::class, $method);
}
if ($obj instanceof User) {
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
}
}
}
public function checkPropertyAllowed($obj, $property): void
{
}
}

View File

@@ -21,10 +21,7 @@ final class ReleaseVersion
/**
* Get all releases from GitHub.
*
* @throws \Exception
* @return array|null
* @return array<string, array{'version': string, 'date': \DateTimeInterface, 'url': string, 'download': string, 'content': string}>
* @return array
* @return array<string, array{'version': non-empty-string, 'date': \DateTimeInterface, 'url': non-empty-string, 'download': non-empty-string, 'content': string}>
*/
private function getReleasesFromGithub(): array
{
@@ -41,19 +38,24 @@ final class ReleaseVersion
$context = stream_context_create($opts);
$releases = file_get_contents('https://api.github.com/repos/' . Constants::GITHUB_REPO . '/releases', false, $context);
$releases = json_decode($releases);
if (!isset($releases[0])) {
throw new \Exception('API error - no release found at GitHub repository: ' . Constants::GITHUB_REPO);
if ($releases === false) {
throw new \Exception('Could not load releases from GitHub repository: ' . Constants::GITHUB_REPO);
}
/** @var array<string, array{url: non-empty-string, html_url: non-empty-string, tag_name: non-empty-string, name: non-empty-string, draft: bool, immutable: bool, prerelease: bool, created_at: non-empty-string, updated_at: non-empty-string, published_at: non-empty-string, zipball_url: non-empty-string, body: string}> $releases */
$releases = json_decode($releases, true);
if ($releases === false) {
throw new \Exception('Failed parsing release found at GitHub repository: ' . Constants::GITHUB_REPO);
}
$parsed = [];
foreach ($releases as $release) {
if ($release->draft || $release->prerelease) {
if ($release['draft'] || $release['prerelease']) {
continue;
}
try {
$normalized = $versionParser->normalize($release->tag_name);
$normalized = $versionParser->normalize($release['tag_name']);
} catch (\UnexpectedValueException $e) {
continue;
}
@@ -62,19 +64,19 @@ final class ReleaseVersion
continue;
}
$date = $release->published_at;
$date = $release['published_at'];
try {
$date = new \DateTimeImmutable($date);
} catch (\Exception $ex) {
// can be ignored, we return a string
continue;
}
$parsed[$normalized] = [
'version' => $release->tag_name,
'version' => $release['tag_name'],
'date' => $date,
'url' => $release->html_url,
'download' => $release->zipball_url,
'content' => $release->body,
'url' => $release['html_url'],
'download' => $release['zipball_url'],
'content' => $release['body'],
];
}
$versions = Semver::rsort(array_keys($parsed));

View File

@@ -59,6 +59,6 @@ final class UserDurationYear extends AbstractCounterYear
protected function getFinancialYearTitle(): string
{
return 'stats.durationFinancialYear';
return 'stats.userDurationYear';
}
}

View File

@@ -13,7 +13,12 @@ final class DayAddon
{
private bool $billable = true;
public function __construct(private string $title, private int $duration, private int $visibleDuration)
public function __construct(
private readonly string $title,
private readonly int $duration,
private readonly int $visibleDuration,
private readonly ?string $type = null
)
{
}
@@ -41,4 +46,9 @@ final class DayAddon
{
return $this->visibleDuration;
}
public function getType(): ?string
{
return $this->type;
}
}

View File

@@ -261,12 +261,6 @@
"psr/event-dispatcher": {
"version": "1.0.0"
},
"psr/http-client": {
"version": "1.0.1"
},
"psr/http-factory": {
"version": "1.0.1"
},
"psr/http-message": {
"version": "1.0.1"
},

View File

@@ -26,19 +26,21 @@
{% endblock %}
{% block page_content_start %}
{% if app.session and app.session.started and app.session.flashbag.peekAll|length > 0 %}
{% set flashes = app.flashes %}
{% if flashes|length > 0 %}
{% set domain = 'flashmessages' %}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function(options) {
const ALERT = options.detail.kimai.getPlugin('alert');
{% for type, messages in app.session.flashbag.all %}
{% for type, messages in flashes %}
{% for message in messages %}
{% if type == 'error' %}
ALERT.error('{{ message|trans({}, domain)|e('js') }}');
{% elseif type == 'warning' %}
ALERT.warning('{{ message|trans({}, domain)|e('js') }}');
{% elseif type == 'success' %}
ALERT.success('{{ message|trans({}, domain)|e('js') }}');
// success is expected so we do not need to show messages like "saved successfully"
// ALERT.success('{{ message|trans({}, domain)|e('js') }}');
{% else %}
ALERT.info('{{ message|trans({}, domain)|e('js') }}');
{% endif %}

View File

@@ -158,28 +158,35 @@
</td>
{% set dayCount = 0 %}
{% for day in month.days %}
{% set class = 'text-end contractDay text-nowrap' %}
{% if day.workingTime.expectedTime == 0 %}
{% set class = class ~ ' weekend' %}
{% endif %}
{% set style = null %}
{% if day.workingTime.expectedTime > 0 and day.workingTime.actualTime == 0 and now > day.day %}
{% set class = class ~ ' bg-orange-lt' %}
{% set style = 'missing' %}
{% elseif day.workingTime.expectedTime == 0 and day.workingTime.actualTime > 0 and now > day.day %}
{% set class = class ~ ' bg-yellow-lt' %}
{% set style = 'unexpected' %}
{% endif %}
<td class="{{ class }}">
{% if (day.workingTime.expectedTime != 0 or day.workingTime.actualTime != 0) and (now > day.day or day.workingTime.actualTime > 0) %}
{% if day.hasAddons() %}
{% set statusTitle = '' %}
{% for addon in day.getAddons() %}
{% set statusTitle = statusTitle ~ (addon.title|trans) ~ ' (' ~ addon.visibleDuration|duration(decimal) ~ ')' %}
{% if not loop.last %}
{% set statusTitle = statusTitle ~ ', ' %}
{% endif %}
{% endfor %}
<span class="status-dot status-azure" data-toggle="tooltip" data-placement="top" title="{{ statusTitle }}"></span>
{% set statusTitle = null %}
{% if day.hasAddons() %}
{% for addon in day.getAddons() %}
{% set style = addon.type ?? style %}
{% set statusTitle = (statusTitle ?? '') ~ (addon.title|trans) %}
{% if addon.visibleDuration > 0 %}
{% set statusTitle = statusTitle ~ ' (' ~ addon.visibleDuration|duration(decimal) ~ ')' %}
{% endif %}
{% if not loop.last %}
{% set statusTitle = statusTitle ~ ', ' %}
{% endif %}
{% endfor %}
{% endif %}
<td class="text-end contractDay text-nowrap{% if style is not null %} bg-{{ style }}{% endif %}"{% if statusTitle is not null %} data-toggle="tooltip" data-placement="top" title="{{ statusTitle }}"{% endif %}>
{% if (day.workingTime.expectedTime != 0 or day.workingTime.actualTime != 0 or day.hasAddons()) and (now > day.day or day.workingTime.actualTime > 0) %}
{% if style is not null and statusTitle is not null %}
<i class="{{ style|icon }} fa-xs"></i>
{% endif %}
{% if day.workingTime.expectedTime == 0 and day.workingTime.actualTime == 0 %}
--:--
{% else %}
{{ work_times_result(day.workingTime.expectedTime, day.workingTime.actualTime, decimal) }}
{% endif %}
{{ work_times_result(day.workingTime.expectedTime, day.workingTime.actualTime, decimal) }}
{% endif %}
</td>
{% set dayCount = dayCount + 1 %}

View File

@@ -162,4 +162,72 @@
{% endblock %}
{% endembed %}
{% set opcache_warning = not opcache['enabled'] %}
{% embed '@theme/embeds/collapsible.html.twig' with {open: opcache_warning, boxtype: (opcache_warning ? 'danger' : 'success')} %}
{% block title %}OPCache{% endblock %}
{% block body_class %}p-0{% endblock %}
{% block body %}
{% import "macros/widgets.html.twig" as widgets %}
{% if opcache_warning %}
{{ widgets.alert('danger', 'Could not detect an active OPCache. This will significantly impact the performance of Kimai.') }}
{% endif %}
{% if opcache['status'] is iterable %}
<table class="table table-hover">
<tr>
<th class="text-nowrap w-min">Opcache Enabled</th>
<td class="text-break">{{ widgets.label_boolean(opcache['enabled']) }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Opcache Full</th>
<td class="text-break">{{ widgets.label_boolean(opcache['status']['cache_full']) }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Memory Used</th>
<td class="text-break">{{ (opcache['status']['memory_usage']['used_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Memory Free</th>
<td class="text-break">{{ (opcache['status']['memory_usage']['free_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Interned-Strings Used</th>
<td class="text-break">{{ (opcache['status']['interned_strings_usage']['used_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Interned-Strings Free</th>
<td class="text-break">{{ (opcache['status']['interned_strings_usage']['free_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Cached Scripts</th>
<td class="text-break">{{ opcache['status']['opcache_statistics']['num_cached_scripts'] }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Cached Keys</th>
<td class="text-break">{{ opcache['status']['opcache_statistics']['num_cached_keys'] }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Max Keys</th>
<td class="text-break">{{ opcache['status']['opcache_statistics']['max_cached_keys'] }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Hit Rate</th>
<td class="text-break">{{ opcache['status']['opcache_statistics']['opcache_hit_rate']|number_format(2) }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Jit enabled</th>
<td class="text-break">{{ widgets.label_boolean(opcache['status']['jit']['enabled']) }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Buffer Size</th>
<td class="text-break">{{ (opcache['status']['jit']['buffer_size'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Buffer Free</th>
<td class="text-break">{{ (opcache['status']['jit']['buffer_free'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
</table>
{% endif %}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -56,7 +56,7 @@
<td class="actions">
<div class="btn-group">
<a href="{{ path(edit_route, {'id': entity.id, 'rate': rate.id}) }}" class="modal-ajax-form btn btn-icon">{{ icon('edit', true) }}</a>
<a href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete', true) }}</a>
<a href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error">{{ icon('delete', true) }}</a>
</div>
</td>
</tr>

View File

@@ -124,17 +124,13 @@
<div class="btn-group me-auto" id="export-buttons" role="group">
{% set allowCreate = is_granted('create_export_template') %}
{% for type, group in buttons %}
{% set btnTitle = ('button.' ~ type)|trans %}
{% if btnTitle == ('button.' ~ type) %}
{% set btnTitle = type|trans %}
{% if type == 'html' %}
{% set type = 'print' %}
{% endif %}
{% set btnTitle = type|trans %}
{% if group|length == 1 %}
{% for id, cfg in group %}
{% set title = cfg.title %}
{% set btnTitle2 = ('button.' ~ title)|trans %}
{% if btnTitle2 != ('button.' ~ title) %}
{% set btnTitle = btnTitle2 %}
{% endif %}
<button type="button" id="export-{{ id }}-button" class="btn btn-primary startExportBtn" data-type="{{ id }}">
{{ btnTitle }}
</button>
@@ -146,19 +142,15 @@
</button>
<div class="dropdown-menu">
{% for id, cfg in group %}
{% set title = cfg.title %}
{% set btnTitle = id|trans({}, 'export') %}
{% if btnTitle == id %}
{% set btnTitle = title|trans %}
{% endif %}
{% if btnTitle == id %}
{% set btnTitle = id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
{% set btnTitle = (cfg.title)|trans %}
{% if loop.first %}
{% set btnTitle = 'default'|trans %}
{% endif %}
<a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}">
{{ btnTitle }}
{% if allowCreate and cfg.internal %}
<span class="dropdown-action modal-ajax-form open-edit ps-2 ms-auto" data-href="{{ path('export_template_edit', {exportTemplate: id}) }}">{{ icon('edit') }}</span>
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete') }}</span>
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error">{{ icon('delete') }}</span>
{% endif %}
</a>
{% endfor %}

View File

@@ -1,24 +0,0 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport">
<link rel="stylesheet" href="{{ asset('bundles/tabler/tabler.css') }}">
{{ encore_entry_link_tags('app') }}
{{ encore_entry_script_tags('app') }}
{% block styles %}{% endblock %}
{% if block('document_title') is defined %}
<title>{{ block('document_title') }}</title>
{% endif %}
</head>
<body class="pad export_print">
<div class="wrapper">
<section class="export">
{% block export %}{% endblock %}
</section>
</div>
{% block javascripts %}
<script src="{{ asset('bundles/tabler/tabler.js') }}"></script>
{% endblock %}
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
{% set showCustomerSummary = false %}
{% set decimal = true %}
{% set showDateTimeShort = true %}
{% block title %}{{ 'timesheet.pdf.twig'|trans({}, 'export') }}{% endblock %}
{% block title %}{{ 'timesheet'|trans }}{% endblock %}
{% block description %}
{% if entry.description is not empty %}
{{ entry.description|desc2html }}

View File

@@ -0,0 +1,42 @@
{% macro meta_field_value(entity, field) %}
{% set metaField = entity.metaField(field.name) %}
{% if not metaField is null %}
{% set metaField = metaField.merge(field) %}
{% set type = metaField.type %}
{% set value = metaField.value %}
{% if 'DurationType' in type %}
{{ value|duration }}
{% elseif 'YesNoType' in type or 'CheckboxType' in type %}
{{ value ? ('yes'|trans) : ('no'|trans) }}
{% elseif 'DatePickerType' in type %}
{{ value|date_short }}
{% elseif 'DateTimePickerType' in type %}
{{ value|date_time }}
{% elseif 'CountryType' in type %}
{{ value|country_name }}
{% elseif 'CurrencyType' in type %}
{{ value|currency_name }}
{% elseif 'LanguageType' in type %}
{{ value|locale_name }}
{% elseif 'MoneyType' in type %}
{% set classname = class_name(entity) %}
{% if classname == 'App\\Entity\\Timesheet' %}
{{ value|money(entity.project.customer.currency) }}
{% elseif classname == 'App\\Entity\\Customer' %}
{{ value|money(entity.currency) }}
{% elseif classname == 'App\\Entity\\Project' %}
{{ value|money(entity.customer.currency) }}
{% elseif classname == 'App\\Entity\\Activity' and entity.project is not null %}
{{ value|money(entity.project.customer.currency) }}
{% else %}
{{ value }}
{% endif %}
{% elseif 'TextareaType' in type %}
{{ value|nl2br }}
{% else %}
{# EmailType, UrlType, TagsType, ColorPickerType, ColorChoiceType #}
{{ value }}
{% endif %}
{% endif %}
{% endmacro %}

View File

@@ -314,9 +314,7 @@
{% macro form_type_value(type, value, entity) %}
{% if 'ColorPickerType' in type or 'ColorChoiceType' in type %}
{% if value is not empty %}
<span class="label-color" data-toggle="tooltip" data-placement="top" title="{{ value }}">
<i class="dot {{ 'dot'|icon(true) }} fa-fw" style="color:{{ value }}"></i>
</span>
{{ _self.label_color_dot('color', true, value, null, value) }}
{% endif %}
{% elseif 'DurationType' in type %}
{{ value|duration }}
@@ -340,7 +338,7 @@
{% endfor %}
{% elseif 'MoneyType' in type %}
{% set classname = class_name(entity) %}
{% if entity is null %}
{% if classname is null %}
{{ value }}
{% elseif classname == 'App\\Entity\\Timesheet' %}
{{ value|money(entity.project.customer.currency) }}

View File

@@ -20,16 +20,14 @@
{% block main %}
{% if form is defined %}
<div class="card mb-3">
<div class="card-body">
{% block report_form %}
{{ form_start(form, {'attr': {'class': 'form-reporting', 'id': 'report-form'}}) }}
<div class="{% block report_form_container_class %}btn-list w-100{% endblock %}">
{% block report_form_layout %}{{ form_rest(form) }}{% endblock %}
</div>
{{ form_end(form) }}
{% endblock %}
</div>
<div class="mb-3">
{% block report_form %}
{{ form_start(form, {'attr': {'class': 'form-reporting', 'id': 'report-form'}}) }}
<div class="{% block report_form_container_class %}btn-list w-100{% endblock %}">
{% block report_form_layout %}{{ form_rest(form) }}{% endblock %}
</div>
{{ form_end(form) }}
{% endblock %}
</div>
{% endif %}

View File

@@ -42,27 +42,34 @@
document.addEventListener('kimai.initialized', (event) => {
/** @type {KimaiAPI} API */
const API = event.detail.kimai.getPlugin('api');
/** @type {KimaiAlert} ALERT */
const ALERT = event.detail.kimai.getPlugin('alert');
const successHandle = (json) => {
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
};
const errorHandle = (error) => {
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
API.handleError('action.update.error', error);
};
{% if customerForm is defined and customerForm is not null %}
document.getElementById('team_customer_form').addEventListener('change',
(event) => {
if (event.target.matches('input[type=checkbox]')) {
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
const checkbox = event.target;
const customerId = checkbox.value;
if (checkbox.checked) {
API.post(
'{{ path('post_team_customer', {'id': team.id, 'customerId': '000'}) }}'.replace(/000/, customerId),
{},
(json) => {ALERT.success('action.update.success');},
(error) => {ALERT.error('action.update.error', error);}, // TODO test me
successHandle,
errorHandle,
);
} else {
API.delete(
'{{ path('delete_team_customer', {'id': team.id, 'customerId': '000'}) }}'.replace(/000/, customerId),
(json) => {ALERT.success('action.delete.success');},
(error) => {ALERT.error('action.delete.error', error);}, // TODO test me
successHandle,
errorHandle,
);
}
}
@@ -74,20 +81,21 @@
document.getElementById('team_project_form').addEventListener('change',
(event) => {
if (event.target.matches('input[type=checkbox]')) {
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
const checkbox = event.target;
const projectId = checkbox.value;
if (checkbox.checked) {
API.post(
'{{ path('post_team_project', {'id': team.id, 'projectId': '000'}) }}'.replace(/000/, projectId),
{},
(json) => {ALERT.success('action.update.success');},
(error) => {ALERT.error('action.update.error', error);}, // TODO test me
successHandle,
errorHandle,
);
} else {
API.delete(
'{{ path('delete_team_project', {'id': team.id, 'projectId': '000'}) }}'.replace(/000/, projectId),
(json) => {ALERT.success('action.delete.success');},
(error) => {ALERT.error('action.delete.error', error);}, // TODO test me
successHandle,
errorHandle,
);
}
}

View File

@@ -1,12 +1,9 @@
<!DOCTYPE html>
{% import "macros/datatables.html.twig" as tables %}
{% set language = app.request is not null ? app.request.locale : 'en' %}
{% set decimal = false %}
{% set showUserColumn = true %}
{% if query.user %}
{% set showUserColumn = false %}
{% endif %}
<html lang="{{ language }}">
<html lang="{{ app.locale }}">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
@@ -79,10 +76,11 @@
{{ 'customer'|trans }}: {{ entry.project.customer.name }}
</span>
</td>
{% from "macros/meta_fields.html.twig" import meta_field_value %}
{% for field in timesheetMetaFields %}
<td>{{ tables.datatable_meta_column(entry, field) }}</td>
<td>{{ meta_field_value(entry, field) }}</td>
{% endfor %}
<td class="text-nowrap">{{ entry.duration|duration(decimal) }}</td>
<td class="text-nowrap">{{ entry.duration|duration }}</td>
</tr>
{% endfor %}
</tbody>
@@ -96,7 +94,7 @@
<th></th>
{% endfor %}
<th>{{ 'invoice.total_working_time'|trans }}</th>
<th class="text-nowrap">{{ timeWorked|duration(decimal) }}</th>
<th class="text-nowrap">{{ timeWorked|duration }}</th>
</tr>
</tfoot>
</table>

View File

@@ -69,7 +69,6 @@
'data-method': 'DELETE',
'data-question': 'confirm.delete',
'data-msg-error': 'action.delete.error',
'data-msg-success': 'action.delete.success'
}}) }}
</td>
</tr>

View File

@@ -105,7 +105,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// assert export type buttons are available
$expected = [
'csv' => 'csv',
'html' => 'html',
'print' => 'html',
'pdf' => 'pdf',
'xlsx' => 'xlsx'
];
@@ -162,7 +162,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// assert export type buttons are available
$expected = [
'csv' => 'csv',
'html' => 'html',
'print' => 'html',
'pdf' => 'pdf',
'xlsx' => 'xlsx'
];
@@ -227,7 +227,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// don't add daterange to make sure the current month is the default range
$client->submit($form, [
'renderer' => 'html',
'renderer' => 'print',
'markAsExported' => 1
]);

View File

@@ -55,7 +55,7 @@ abstract class AbstractUserPeriodControllerTestCase extends AbstractControllerBa
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->importReportingFixture(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, \sprintf('%s?user=%s&date=12999119191&sumType=%s', $this->getReportUrl(), $user, $dataType));
self::assertStringContainsString(\sprintf('<div class="card-body %s', $this->getBoxId()), $client->getResponse()->getContent());
self::assertStringContainsString(\sprintf('<div class="card-body p-0 %s', $this->getBoxId()), $client->getResponse()->getContent());
$option = $client->getCrawler()->filterXPath("//select[@id='user']/option[@selected]");
self::assertEquals($user, $option->attr('value'));
$cell = $client->getCrawler()->filterXPath("//th[contains(@class, 'reportDataTypeTitle')]");
@@ -90,7 +90,7 @@ abstract class AbstractUserPeriodControllerTestCase extends AbstractControllerBa
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importReportingFixture(User::ROLE_USER);
$this->assertAccessIsGranted($client, \sprintf('%s?date=12999119191', $this->getReportUrl()));
self::assertStringContainsString(\sprintf('<div class="card-body %s', $this->getBoxId()), $client->getResponse()->getContent());
self::assertStringContainsString(\sprintf('<div class="card-body p-0 %s', $this->getBoxId()), $client->getResponse()->getContent());
$select = $client->getCrawler()->filterXPath("//select[@id='user']");
self::assertEquals(0, $select->count());
$cell = $client->getCrawler()->filterXPath("//th[contains(@class, 'reportDataTypeTitle')]");

View File

@@ -55,7 +55,7 @@ abstract class AbstractUsersPeriodControllerTestCase extends AbstractControllerB
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->importReportingFixture(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, \sprintf('%s?date=12999119191&sumType=%s', $this->getReportUrl(), $dataType));
self::assertStringContainsString(\sprintf('<div class="card-body %s', $this->getBoxId()), $client->getResponse()->getContent());
self::assertStringContainsString(\sprintf('<div class="card-body p-0 %s', $this->getBoxId()), $client->getResponse()->getContent());
$cell = $client->getCrawler()->filterXPath("//th[contains(@class, 'reportDataTypeTitle')]");
self::assertEquals($title, $cell->text());
}
@@ -66,7 +66,7 @@ abstract class AbstractUsersPeriodControllerTestCase extends AbstractControllerB
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->importReportingFixture(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, \sprintf('%s?date=12999119191&sumType=%s', $this->getReportUrl(), $dataType));
self::assertStringContainsString(\sprintf('<div class="card-body %s', $this->getBoxId()), $client->getResponse()->getContent());
self::assertStringContainsString(\sprintf('<div class="card-body p-0 %s', $this->getBoxId()), $client->getResponse()->getContent());
$select = $client->getCrawler()->filterXPath("//select[@id='user']");
self::assertEquals(0, $select->count());
$cell = $client->getCrawler()->filterXPath("//th[contains(@class, 'reportDataTypeTitle')]");

View File

@@ -294,9 +294,6 @@ class UserTest extends TestCase
self::assertInstanceOf(UserPreference::class, $prefs[2]);
self::assertEquals('export_decimal', $prefs[2]->getName());
$user->setPreferences(new ArrayCollection([]));
self::assertCount(0, $user->getPreferences());
}
public function testDisplayName(): void

View File

@@ -26,6 +26,8 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
#[CoversClass(ServiceExport::class)]
#[CoversClass(CsvRenderer::class)]
@@ -35,7 +37,7 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[Group('integration')]
class DefaultRendererTest extends AbstractRendererTestCase
{
private function createServiceExport(): ServiceExport
private function createServiceExport(?Environment $environment = null): ServiceExport
{
$repository = $this->createMock(ExportTemplateRepository::class);
$repository->expects($this->once())->method('findAll')->willReturn([]);
@@ -43,8 +45,8 @@ class DefaultRendererTest extends AbstractRendererTestCase
return new ServiceExport(
$this->createMock(EventDispatcherInterface::class),
(new HtmlRendererFactoryMock($this))->create(),
(new PdfRendererFactoryMock($this))->create(),
(new HtmlRendererFactoryMock($this))->create($environment),
(new PdfRendererFactoryMock($this))->create($environment),
(new CsvRendererFactoryMock($this))->create(),
(new XlsxRendererFactoryMock($this))->create(),
$repository,
@@ -54,7 +56,10 @@ class DefaultRendererTest extends AbstractRendererTestCase
public function testRenderDefaultTemplates(): void
{
$sut = $this->createServiceExport();
/** @var Environment $twig */
$twig = $this->getContainer()->get(Environment::class);
$sut = $this->createServiceExport($twig);
$renderer = $sut->getRenderer();
self::assertCount(4, $renderer);
@@ -98,12 +103,21 @@ class DefaultRendererTest extends AbstractRendererTestCase
->files()
;
/** @var Environment $twig */
$twig = $this->getContainer()->get(Environment::class);
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$files = [];
$dirs = [];
foreach ($finder->getIterator() as $filename => $splFile) {
$files[] = $splFile->getRealPath();
$dir = \dirname($splFile->getRealPath());
$dirs[$dir] = $dir;
if (!\array_key_exists($dir, $dirs)) {
$dirs[$dir] = $dir;
$loader->addPath($dir . '/', 'export');
}
}
$dirs = array_keys($dirs);
@@ -113,7 +127,7 @@ class DefaultRendererTest extends AbstractRendererTestCase
return;
}
$sut = $this->createServiceExport();
$sut = $this->createServiceExport($twig);
foreach ($dirs as $dir) {
$sut->addDirectory($dir);
}

View File

@@ -25,10 +25,10 @@ use Twig\Environment;
#[Group('integration')]
class HtmlRendererTest extends AbstractRendererTestCase
{
protected function getAbstractRenderer(): HtmlRenderer
protected function getAbstractRenderer(?Environment $environment = null): HtmlRenderer
{
return new HtmlRenderer(
$this->createMock(Environment::class),
$environment ?? $this->createMock(Environment::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(ProjectStatisticService::class),
$this->createMock(ActivityStatisticService::class),
@@ -60,7 +60,10 @@ class HtmlRendererTest extends AbstractRendererTestCase
public function testRender(): void
{
$sut = $this->getAbstractRenderer();
/** @var Environment $twig */
$twig = $this->getContainer()->get(Environment::class);
$sut = $this->getAbstractRenderer($twig);
$response = $this->render($sut);
self::assertInstanceOf(Response::class, $response);

View File

@@ -26,14 +26,13 @@ use Twig\Environment;
#[Group('integration')]
class PdfRendererTest extends AbstractRendererTestCase
{
protected function getAbstractRenderer(bool $exportDecimal = false): PDFRenderer
protected function getAbstractRenderer(?Environment $environment = null): PDFRenderer
{
$twig = $this->createMock(Environment::class);
$converter = $this->createMock(HtmlToPdfConverter::class);
$projectStatisticService = $this->createMock(ProjectStatisticService::class);
return new PDFRenderer(
$twig,
$environment ?? $this->createMock(Environment::class),
$converter,
$projectStatisticService,
'foo',
@@ -70,7 +69,10 @@ class PdfRendererTest extends AbstractRendererTestCase
public function testRender(): void
{
$sut = $this->getAbstractRenderer();
/** @var Environment $twig */
$twig = $this->getContainer()->get(Environment::class);
$sut = $this->getAbstractRenderer($twig);
$response = $this->render($sut);
self::assertInstanceOf(Response::class, $response);

View File

@@ -54,7 +54,7 @@ class XlsxRendererTest extends AbstractRendererTestCase
self::assertEquals('xlsx', $sut->getType());
self::assertEquals('xlsx', $sut->getId());
self::assertEquals('default', $sut->getTitle());
self::assertEquals('xlsx', $sut->getTitle());
self::assertFalse($sut->isInternal());
$sut->setInternal(true);
self::assertTrue($sut->isInternal());

View File

@@ -131,6 +131,7 @@ abstract class AbstractRendererTestCase extends KernelTestCase
$currentUser = new User();
$currentUser->setPreferenceValue('export_decimal', $exportDecimal);
$currentUser->setUserIdentifier('foo-bar');
$query = new TimesheetQuery();
$query->setActivities([$activity]);

View File

@@ -59,7 +59,7 @@ class PdfRendererTest extends KernelTestCase
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$loader->addPath(__DIR__ . '/../templates/', 'invoice');
$loader->addPath($this->getInvoiceTemplatePath(), 'invoice');
$sut = new PdfRenderer($twig, new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir));
$model = $this->getInvoiceModel();
@@ -115,46 +115,85 @@ class PdfRendererTest extends KernelTestCase
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$loader->addPath(__DIR__ . '/../templates/', 'invoice');
$dirs = [
__DIR__ . '/../../../templates/invoice/renderer/',
];
$files = [];
$additionalTemplatesDir = __DIR__ . '/../../../var/templates';
if (is_dir($additionalTemplatesDir)) {
$dirs = [
realpath($this->getInvoiceTemplatePath()),
realpath(__DIR__ . '/../templates/'),
realpath(__DIR__ . '/../../../var/invoices/'),
];
foreach ($dirs as $dir) {
if ($dir === false || !is_dir($dir)) {
continue;
}
$finder = new Finder();
$finder
->in($additionalTemplatesDir)
->in($dir)
->name('*.pdf.twig')
->path('invoice-tpl/')
->sortByName()
->files();
foreach ($finder->getIterator() as $splFile) {
$filename = $splFile->getRealPath();
$files[] = $filename;
$loader->addPath(\dirname($filename) . '/', 'invoice');
if ($filename === false) {
continue;
}
$dir = \dirname($filename) . '/';
if (!\array_key_exists($dir, $files)) {
$loader->addPath($dir . '/', 'invoice');
}
$files[$dir][] = $filename;
}
}
// search for custom templates, that shall not be shipped
$dirs = [
realpath(__DIR__ . '/../../../var/templates/'),
];
foreach ($dirs as $dir) {
if (!is_dir($dir)) {
if ($dir === false || !is_dir($dir)) {
continue;
}
$dir = realpath($dir);
$loader->addPath($dir . '/', 'invoice');
$found = glob($dir . '/*.pdf.twig');
if ($found !== false) {
$files = array_merge($files, $found);
$finder = new Finder();
$finder
->in($dir)
->name('*.pdf.twig')
->path('invoice-tpl/')
->sortByName()
->files();
foreach ($finder->getIterator() as $splFile) {
$filename = $splFile->getRealPath();
if ($filename === false) {
continue;
}
$dir = \dirname($filename) . '/';
if (!\array_key_exists($dir, $files)) {
$loader->addPath($dir . '/', 'invoice');
}
$files[$dir][] = $filename;
}
}
$sut = new PdfRenderer($twig, new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir));
$model = $this->getInvoiceModel();
foreach ($files as $filename) {
$allFiles = [];
foreach ($files as $templates) {
foreach ($templates as $filename) {
$allFiles[] = $filename;
}
}
self::assertGreaterThanOrEqual(3, \count($allFiles));
foreach ($allFiles as $filename) {
$document = new InvoiceDocument(new \SplFileInfo($filename));
$response = $sut->render($document, $model);

View File

@@ -15,6 +15,7 @@ use App\Model\InvoiceDocument;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
@@ -93,34 +94,85 @@ class TwigRendererTest extends KernelTestCase
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$loader->addPath($this->getInvoiceTemplatePath(), 'invoice');
$dirs = [
__DIR__ . '/../../../templates/invoice/renderer/',
__DIR__ . '/../../../var/invoices/',
__DIR__ . '/../../../var/invoices_customer/',
__DIR__ . '/../../../var/invoices_old/',
];
$files = [];
$dirs = [
realpath($this->getInvoiceTemplatePath()),
realpath(__DIR__ . '/../templates/'),
realpath(__DIR__ . '/../../../var/invoices/'),
];
foreach ($dirs as $dir) {
if (!is_dir($dir)) {
if ($dir === false || !is_dir($dir)) {
continue;
}
$dir = realpath($dir);
$loader->addPath($dir . '/', 'invoice');
$found = glob($dir . '/*.html.twig');
if ($found !== false) {
$files = array_merge($files, $found);
$finder = new Finder();
$finder
->in($dir)
->name('*.html.twig')
->sortByName()
->files();
foreach ($finder->getIterator() as $splFile) {
$filename = $splFile->getRealPath();
if ($filename === false) {
continue;
}
$dir = \dirname($filename) . '/';
if (!\array_key_exists($dir, $files)) {
$loader->addPath($dir . '/', 'invoice');
}
$files[$dir][] = $filename;
}
}
// search for custom templates, that shall not be shipped
$dirs = [
realpath(__DIR__ . '/../../../var/templates/'),
];
foreach ($dirs as $dir) {
if ($dir === false || !is_dir($dir)) {
continue;
}
$finder = new Finder();
$finder
->in($dir)
->name('*.html.twig')
->path('invoice-tpl/')
->sortByName()
->files();
foreach ($finder->getIterator() as $splFile) {
$filename = $splFile->getRealPath();
if ($filename === false) {
continue;
}
$dir = \dirname($filename) . '/';
if (!\array_key_exists($dir, $files)) {
$loader->addPath($dir . '/', 'invoice');
}
$files[$dir][] = $filename;
}
}
$allFiles = [];
foreach ($files as $templates) {
foreach ($templates as $filename) {
$allFiles[] = $filename;
}
}
self::assertGreaterThanOrEqual(2, \count($allFiles));
$sut = new TwigRenderer($twig);
$model = $this->getInvoiceModel();
$model->getTemplate()->setLanguage('de');
foreach ($files as $filename) {
foreach ($allFiles as $filename) {
$document = new InvoiceDocument(new \SplFileInfo($filename));
$response = $sut->render($document, $model);

View File

@@ -18,10 +18,10 @@ use Twig\Environment;
class HtmlRendererFactoryMock extends AbstractMockFactory
{
public function create(): HtmlRendererFactory
public function create(?Environment $environment = null): HtmlRendererFactory
{
return new HtmlRendererFactory(
$this->createMock(Environment::class),
$environment ?? $this->createMock(Environment::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(ProjectStatisticService::class),
$this->createMock(ActivityStatisticService::class)

View File

@@ -22,7 +22,7 @@ use Twig\Environment;
class PdfRendererFactoryMock extends AbstractMockFactory
{
public function create(): PdfRendererFactory
public function create(?Environment $environment = null): PdfRendererFactory
{
$converter = new ColumnConverter(
$this->createMock(EventDispatcherInterface::class),
@@ -31,7 +31,7 @@ class PdfRendererFactoryMock extends AbstractMockFactory
);
return new PdfRendererFactory(
$this->createMock(Environment::class),
$environment ?? $this->createMock(Environment::class),
$this->createMock(HtmlToPdfConverter::class),
$this->createMock(ProjectStatisticService::class),
$this->createMock(LocaleSwitcher::class),

View File

@@ -1,66 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Twig\SecurityPolicy;
use App\Twig\SecurityPolicy\ChainPolicy;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Twig\Sandbox\SecurityPolicyInterface;
#[CoversClass(ChainPolicy::class)]
class ChainPolicyTest extends TestCase
{
public function testCheckSecurity(): void
{
$policy1 = $this->createMock(SecurityPolicyInterface::class);
$policy1->expects(self::once())->method('checkSecurity')->with(['tag'], ['filter'], ['function']);
$policy2 = $this->createMock(SecurityPolicyInterface::class);
$policy2->expects(self::once())->method('checkSecurity')->with(['tag'], ['filter'], ['function']);
$sut = new ChainPolicy();
$sut->addPolicy($policy1);
$sut->addPolicy($policy2);
$sut->checkSecurity(['tag'], ['filter'], ['function']);
}
public function testCheckMethodAllowed(): void
{
$obj = new \stdClass();
$policy1 = $this->createMock(SecurityPolicyInterface::class);
$policy1->expects(self::once())->method('checkMethodAllowed')->with($obj, 'method');
$policy2 = $this->createMock(SecurityPolicyInterface::class);
$policy2->expects(self::once())->method('checkMethodAllowed')->with($obj, 'method');
$sut = new ChainPolicy();
$sut->addPolicy($policy1);
$sut->addPolicy($policy2);
$sut->checkMethodAllowed($obj, 'method');
}
public function testCheckPropertyAllowed(): void
{
$obj = new \stdClass();
$policy1 = $this->createMock(SecurityPolicyInterface::class);
$policy1->expects(self::once())->method('checkPropertyAllowed')->with($obj, 'property');
$policy2 = $this->createMock(SecurityPolicyInterface::class);
$policy2->expects(self::once())->method('checkPropertyAllowed')->with($obj, 'property');
$sut = new ChainPolicy();
$sut->addPolicy($policy1);
$sut->addPolicy($policy2);
$sut->checkPropertyAllowed($obj, 'property');
}
}

View File

@@ -1,23 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Twig\SecurityPolicy;
use App\Twig\SecurityPolicy\DefaultPolicy;
use PHPUnit\Framework\Attributes\CoversClass;
use Twig\Sandbox\SecurityPolicyInterface;
#[CoversClass(DefaultPolicy::class)]
class DefaultPolicyTest extends AbstractPolicyTestCase
{
protected function createPolicy(): SecurityPolicyInterface
{
return new DefaultPolicy();
}
}

View File

@@ -1,23 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Twig\SecurityPolicy;
use App\Twig\SecurityPolicy\ExportPolicy;
use PHPUnit\Framework\Attributes\CoversClass;
use Twig\Sandbox\SecurityPolicyInterface;
#[CoversClass(ExportPolicy::class)]
class ExportPolicyTest extends AbstractPolicyTestCase
{
protected function createPolicy(): SecurityPolicyInterface
{
return new ExportPolicy();
}
}

Some files were not shown because too many files have changed in this diff Show More