Compare commits
3 Commits
2.26.0
...
activity-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5cef1a2f | ||
|
|
52a9ff9860 | ||
|
|
ecded0a065 |
@@ -13,19 +13,20 @@ try {
|
||||
]);
|
||||
} catch(\Exception $ex) {
|
||||
switch ($ex->getCode()) {
|
||||
// we can immediately stop startup here and show the error message
|
||||
case 1045:
|
||||
// we can immediately stop here and show the error message
|
||||
echo 'Access denied (1045)';
|
||||
die(1);
|
||||
// we can immediately stop startup here and show the error message
|
||||
case 1049:
|
||||
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
|
||||
return;
|
||||
echo 'Unknown database (1049)';
|
||||
die(2);
|
||||
// a lot of errors share the same meaningless error code zero
|
||||
case 0:
|
||||
// this error includes the database name, so we can only search for the static part of the error message
|
||||
if (stripos($ex->getMessage(), 'SQLSTATE[HY000] [1049] Unknown database') !== false) {
|
||||
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
|
||||
return;
|
||||
echo 'Unknown database (0-1049)';
|
||||
die(3);
|
||||
}
|
||||
switch ($ex->getMessage()) {
|
||||
// eg. no response (fw) - the startup script should retry it a couple of times
|
||||
|
||||
@@ -76,6 +76,7 @@ function handleStartup() {
|
||||
function prepareKimai() {
|
||||
# These are idempotent, so we can run them on every start-up
|
||||
/opt/kimai/bin/console -n kimai:install
|
||||
/opt/kimai/bin/console -n kimai:update
|
||||
if [ ! -z "$ADMINPASS" ] && [ ! -a "$ADMINMAIL" ]; then
|
||||
/opt/kimai/bin/console kimai:user:create admin "$ADMINMAIL" ROLE_SUPER_ADMIN "$ADMINPASS"
|
||||
fi
|
||||
|
||||
18
.gitattributes
vendored
18
.gitattributes
vendored
@@ -1,18 +0,0 @@
|
||||
.docker export-ignore
|
||||
.github export-ignore
|
||||
assets export-ignore
|
||||
tests export-ignore
|
||||
.codecov.yml export-ignore
|
||||
.editorconfig export-ignore
|
||||
.eslintrc.js export-ignore
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
php-cs-fixer.dist.php export-ignore
|
||||
babel.config.js export-ignore
|
||||
package.json export-ignore
|
||||
php-cs-fixer.sh export-ignore
|
||||
phpstan.neon export-ignore
|
||||
phpstan.sh export-ignore
|
||||
phpunit.xml.dist export-ignore
|
||||
webpack.config.js export-ignore
|
||||
yarn.lock export-ignore
|
||||
2
.github/workflows/testing.yaml
vendored
2
.github/workflows/testing.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
|
||||
name: Integration (${{ matrix.php }})
|
||||
steps:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,7 +18,6 @@
|
||||
# for keeping empty directories
|
||||
/config/packages/local.yaml
|
||||
/config/bundles-local.php
|
||||
/var/dev/*
|
||||
/var/data/*
|
||||
/var/cache/*
|
||||
/var/invoices*
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
# Contributing
|
||||
|
||||
Kimai is an open source project, contributions made by the community are welcome.
|
||||
But we can only accept contributions with a signed CLA (Contributor License Agreement) to prevent issues in the future (you will see a link when opening a PR).
|
||||
|
||||
Kimai is an open source project, contributions made by the community are welcome.
|
||||
Send your ideas, code reviews, pull requests and feature requests to help to improve this project.
|
||||
|
||||
## Pull request rules
|
||||
|
||||
- Make your changes in a new git branch, based on the latest code in `main`
|
||||
- Apply our code-style by running `composer codestyle-fix`
|
||||
- Run the static code analysis with `composer phpstan`
|
||||
- Verify everything still works with `composer tests-unit`
|
||||
- Verify everything still works with `composer tests-unit` and `composer tests-integration`
|
||||
- Add tests for your changes
|
||||
- When sending in a PR, you must accept that your contributions/code will be published under MIT license (see the [LICENSE](LICENSE) file as well), otherwise your PR will be closed
|
||||
- If one of the PR checks/builds fails, fix it before asking for a review
|
||||
|
||||
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -218,18 +218,20 @@ COPY --from=php-ext-intl /usr/local/lib/php/extensions/no-debug-non-zts-20230831
|
||||
COPY --from=php-ext-opcache /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini
|
||||
|
||||
###########################
|
||||
# fetch Kimai sources
|
||||
# Shared tools
|
||||
###########################
|
||||
|
||||
FROM alpine:latest AS git-prod
|
||||
# full kimai source
|
||||
FROM alpine:latest AS git-dev
|
||||
ARG KIMAI
|
||||
ARG TIMEZONE
|
||||
# the convention in the Kimai repository is: tags are always version numbers, branch names always start with a letter
|
||||
# if the KIMAI variable starts with a number (e.g. 2.24.0) we assume its a tag, otherwise its a branch
|
||||
RUN [[ $KIMAI =~ ^[0-9] ]] && export REF='tags' || export REF='heads' && \
|
||||
wget -O "/opt/kimai.tar.gz" "https://github.com/kimai/kimai/archive/refs/${REF}/${KIMAI}.tar.gz" && \
|
||||
tar -xpzf /opt/kimai.tar.gz -C /opt/ && \
|
||||
mv /opt/kimai-${KIMAI} /opt/kimai
|
||||
RUN apk add --no-cache git && \
|
||||
git clone --depth 1 --branch ${KIMAI} https://github.com/kimai/kimai.git /opt/kimai
|
||||
|
||||
# production kimai source
|
||||
FROM git-dev AS git-prod
|
||||
WORKDIR /opt/kimai
|
||||
RUN rm -r tests
|
||||
|
||||
###########################
|
||||
# global base build
|
||||
@@ -284,9 +286,10 @@ CMD [ "/entrypoint.sh" ]
|
||||
# development build
|
||||
FROM base AS dev
|
||||
# copy kimai develop source
|
||||
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
|
||||
COPY --from=git-dev --chown=www-data:www-data /opt/kimai /opt/kimai
|
||||
COPY .docker /assets
|
||||
# do the composer deps installation
|
||||
RUN echo \$PATH
|
||||
RUN \
|
||||
export COMPOSER_HOME=/composer && \
|
||||
composer --no-ansi install --working-dir=/opt/kimai --optimize-autoloader && \
|
||||
@@ -302,7 +305,7 @@ ENV APP_ENV=dev
|
||||
ENV DATABASE_URL=
|
||||
ENV memory_limit=512M
|
||||
|
||||
# the "prod" stage (production build) is configured as last stage in the file, as this is the default target in BuildKit
|
||||
# production build
|
||||
FROM base AS prod
|
||||
# copy kimai production source
|
||||
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
|
||||
|
||||
@@ -7,4 +7,5 @@ As announced in the [README](README.md) security fixes will only be added to the
|
||||
| main branch | :white_check_mark: |
|
||||
| older releases | :x: |
|
||||
|
||||
|
||||
You find all information in our [Bughunter documentation](https://www.kimai.org/documentation/bughunter.html).
|
||||
|
||||
0
assets/.gitignore
vendored
Normal file
0
assets/.gitignore
vendored
Normal file
@@ -48,10 +48,10 @@ export default class KimaiCopyDataForm extends KimaiFormPlugin {
|
||||
if (element.dataset.event !== undefined) {
|
||||
for (const event of element.dataset.event.split(' ')) {
|
||||
target.dispatchEvent(new Event(event));
|
||||
const form = target.closest('form');
|
||||
if (form !== null) {
|
||||
form.dispatchEvent(new Event(event));
|
||||
}
|
||||
}
|
||||
} else if (element.dataset.eventBubbles !== undefined) {
|
||||
for (const event of element.dataset.eventBubbles.split(' ')) {
|
||||
target.dispatchEvent(new Event(event, {bubbles: true}));
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
@@ -15,7 +15,7 @@ export default class KimaiMultiUpdateTable extends KimaiPlugin {
|
||||
|
||||
init()
|
||||
{
|
||||
if (document.getElementsByClassName('multi_update_all').length === 0) {
|
||||
if (document.getElementById('multi_update_all') === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,64 +23,64 @@ export default class KimaiMultiUpdateTable extends KimaiPlugin {
|
||||
// via KimaiDatable and everything inside will be removed, including event listeners
|
||||
const element = document.querySelector('div.page-body');
|
||||
element.addEventListener('change', (event) => {
|
||||
if (event.target.matches('.multi_update_all')) {
|
||||
if (event.target.matches('#multi_update_all')) {
|
||||
// the "check all" checkbox in the upper start corner of the table
|
||||
const checked = event.target.checked;
|
||||
const table = event.target.closest('table');
|
||||
for (const element of table.querySelectorAll('.multi_update_single')) {
|
||||
for (const element of document.querySelectorAll('.multi_update_single')) {
|
||||
element.checked = checked;
|
||||
}
|
||||
this._toggleForm(table);
|
||||
this._toggleForm();
|
||||
event.stopPropagation();
|
||||
} else if (event.target.matches('.multi_update_single')) {
|
||||
// single checkboxes in front of each row
|
||||
this._toggleForm(event.target.closest('table'));
|
||||
this._toggleForm();
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener('click', (event) => {
|
||||
if (event.target.matches('.multi_update_table_action')) {
|
||||
const selectedButton = event.target;
|
||||
const form = selectedButton.form;
|
||||
const ids = form.querySelector('.multi_update_ids').value.split(',');
|
||||
const question = form.dataset['question'].replace(/%action%/, selectedButton.textContent).replace(/%count%/, ids.length.toString());
|
||||
const selectedItem = event.target;
|
||||
const ids = this._getSelectedIds();
|
||||
const form = document.getElementById('multi_update_form');
|
||||
const question = form.dataset['question'].replace(/%action%/, selectedItem.textContent).replace(/%count%/, ids.length.toString());
|
||||
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.getPlugin('alert');
|
||||
ALERT.question(question, function(value) {
|
||||
if (value) {
|
||||
form.action = selectedButton.dataset['href'];
|
||||
const form = document.getElementById('multi_update_form');
|
||||
form.action = selectedItem.dataset['href'];
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLTableElement} table
|
||||
* @private
|
||||
*/
|
||||
_toggleForm(table)
|
||||
|
||||
_getSelectedIds()
|
||||
{
|
||||
const card = table.closest('div.card.data_table')
|
||||
|
||||
let ids = [];
|
||||
for (const box of table.querySelectorAll('input.multi_update_single:checked')) {
|
||||
for (const box of document.querySelectorAll('input.multi_update_single:checked')) {
|
||||
ids.push(box.value);
|
||||
}
|
||||
|
||||
card.querySelector('.multi_update_ids').value = ids.join(',');
|
||||
return ids;
|
||||
}
|
||||
|
||||
_toggleForm()
|
||||
{
|
||||
const ids = this._getSelectedIds();
|
||||
document.getElementById('multi_update_table_entities').value = ids.join(',');
|
||||
|
||||
if (ids.length > 0) {
|
||||
for (const element of card.querySelectorAll('.multi_update_form_hide')) {
|
||||
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
|
||||
element.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
card.querySelector('form.multi_update_form').style.display = null;//'block';
|
||||
document.getElementById('multi_update_form').style.display = null;//'block';
|
||||
} else {
|
||||
card.querySelector('form.multi_update_form').style.setProperty('display', 'none', 'important');
|
||||
for (const element of card.querySelectorAll('.multi_update_form_hide')) {
|
||||
document.getElementById('multi_update_form').style.setProperty('display', 'none', 'important');
|
||||
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
|
||||
element.style.display = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,30 +5,111 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
body { font-family: sans-serif; font-size: 10pt; margin: 4px; padding: 0; }
|
||||
table, tr, td, th, p, h1, h2, h3, h4, h5 { padding: 0; margin: 0;}
|
||||
th { border: none; text-align: left; }
|
||||
td { vertical-align: top; }
|
||||
p { margin-bottom: 12px; }
|
||||
.text-small { font-size: 7pt; }
|
||||
.padding-left { padding-left: 15px; }
|
||||
.text-center { text-align: center; }
|
||||
.text-left, .text-begin { text-align: left; }
|
||||
.text-right, .text-end { text-align: right; }
|
||||
.text-nowrap { white-space: nowrap; }
|
||||
.date { font-size: 80%; text-align: right; padding-top: 6px; }
|
||||
table { width: 100%; border-spacing: 0; }
|
||||
table.addresses, p { line-height: 14pt; }
|
||||
table.addresses { margin-bottom: 30px; }
|
||||
table.header { padding-bottom: 4px; border-bottom: 1px solid #999; }
|
||||
table.header .title { font-weight: normal; font-size: 140%; }
|
||||
table.footer { border-top: 1px solid #999; font-size: 80%; padding-top: 8px; line-height: 14px; }
|
||||
table.footer td { vertical-align: top; }
|
||||
table.items { border-collapse: collapse; margin-top: 50px; margin-bottom: 60px; }
|
||||
table.items thead th, table.items tbody td { border-bottom: 1px solid #ccc; }
|
||||
table.items thead th.first, table.items tbody td.first { padding-left: 0; }
|
||||
table.items thead th.last, table.items tbody td.last, table.items tfoot td.last { padding-right: 0; }
|
||||
table.items td, table.items th { padding: 8px 6px; }
|
||||
table.items tfoot td, table.items tfoot th { padding: 20px 0 0 0; line-height: 1px; }
|
||||
table.items tfoot td.last { padding-left: 10px; }
|
||||
table.items tr.odd { background-color: #f5f5f5; }
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
font-size: 10pt;
|
||||
margin: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
table, tr, td, th, p, h1, h2, h3, h4, h5 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
th {
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.text-small {
|
||||
font-size: 7pt;
|
||||
}
|
||||
.padding-left {
|
||||
padding-left: 15px;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-left, .text-begin {
|
||||
text-align: left;
|
||||
}
|
||||
.text-right, .text-end {
|
||||
text-align: right;
|
||||
}
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.date {
|
||||
font-size: 80%;
|
||||
text-align: right;
|
||||
padding-top: 6px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
table.addresses, p {
|
||||
line-height: 14pt;
|
||||
}
|
||||
/* The address table */
|
||||
table.addresses {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
/* The PDF header */
|
||||
table.header {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #999;
|
||||
}
|
||||
table.header .title {
|
||||
font-weight: normal;
|
||||
font-size: 140%;
|
||||
}
|
||||
/* The PDF footer */
|
||||
table.footer {
|
||||
border-top: 1px solid #999;
|
||||
font-size: 80%;
|
||||
padding-top: 8px;
|
||||
line-height: 14px;
|
||||
}
|
||||
table.footer td {
|
||||
vertical-align: top;
|
||||
}
|
||||
/* The invoice items list */
|
||||
table.items {
|
||||
border-collapse: collapse;
|
||||
margin-top: 50px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
table.items thead th,
|
||||
table.items tbody td {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
table.items thead th.first,
|
||||
table.items tbody td.first {
|
||||
padding-left: 0;
|
||||
}
|
||||
table.items thead th.last,
|
||||
table.items tbody td.last,
|
||||
table.items tfoot td.last {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
table.items td,
|
||||
table.items th {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
table.items tfoot td,
|
||||
table.items tfoot th {
|
||||
padding: 20px 0 0 0;
|
||||
line-height: 1px;
|
||||
}
|
||||
table.items tfoot td.last {
|
||||
padding-left: 10px;
|
||||
}
|
||||
table.items tr.odd {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
@@ -49,14 +49,3 @@ fieldset:empty {
|
||||
--litepicker-is-in-range-color: var(--tblr-primary-text-emphasis);
|
||||
}
|
||||
}
|
||||
|
||||
/* fixes contrast of batch-update checkboxes - https://github.com/kimai/kimai/issues/5146 */
|
||||
.multiupdater[type=checkbox]
|
||||
{
|
||||
--tblr-border-color-translucent: rgba(4, 32, 69, .2);
|
||||
}
|
||||
|
||||
/* fixes height for long tags https://github.com/kimai/kimai/issues/5169 */
|
||||
.tag {
|
||||
--tblr-tag-height: unset;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ table.dataTable {
|
||||
width: 15px;
|
||||
}
|
||||
&.overlapping {
|
||||
border-top: 2px solid rgba(214,57,57,.2);
|
||||
border-top: 2px solid rgba(214,57,57,.1);
|
||||
}
|
||||
&.exported {
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
|
||||
"php": "8.1.*||8.2.*||8.3.*",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
@@ -61,7 +61,6 @@
|
||||
"symfony/mailer": "^6.0",
|
||||
"symfony/mime": "^6.0",
|
||||
"symfony/monolog-bundle": "^3.4",
|
||||
"symfony/process": "^6.0",
|
||||
"symfony/rate-limiter": "^6.0",
|
||||
"symfony/runtime": "^6.0",
|
||||
"symfony/security-bundle": "^6.0",
|
||||
@@ -84,12 +83,12 @@
|
||||
"doctrine/doctrine-fixtures-bundle": "^3.2",
|
||||
"fakerphp/faker": "^1.15",
|
||||
"friendsofphp/php-cs-fixer": "^3.3",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||
"phpstan/phpstan-doctrine": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"phpstan/phpstan": "^1.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpstan/phpstan-doctrine": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.0",
|
||||
"phpstan/phpstan-symfony": "^1.0",
|
||||
"phpunit/phpunit": "9.5.*",
|
||||
"symfony/browser-kit": "^6.0",
|
||||
"symfony/css-selector": "^6.0",
|
||||
@@ -135,10 +134,6 @@
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-mbstring": "*",
|
||||
"symfony/polyfill-intl": "*",
|
||||
"symfony/polyfill-intl-icu": "*",
|
||||
"symfony/polyfill-intl-grapheme": "*",
|
||||
"symfony/polyfill-intl-idn": "*",
|
||||
"symfony/polyfill-intl-normalizer": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
"symfony/polyfill-php81": "*",
|
||||
"symfony/polyfill-php80": "*",
|
||||
|
||||
1841
composer.lock
generated
1841
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,7 @@ kimai:
|
||||
BILLABLE: ['edit_billable_own_timesheet','edit_billable_other_timesheet']
|
||||
TEAMS: ['view_team','create_team','edit_team','delete_team']
|
||||
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
|
||||
REPORTING: ['view_reporting','view_other_reporting','project_reporting','customer_reporting']
|
||||
REPORTING: ['view_reporting','view_other_reporting','activity_reporting','project_reporting','customer_reporting']
|
||||
EVERYONE: ['api_access','hours_own_profile']
|
||||
# permissions which are deactivated, as these features are hidden for now
|
||||
# brave users can try to activate them and be surprised what happens
|
||||
|
||||
@@ -5,5 +5,7 @@ twig:
|
||||
- 'form/vertical.html.twig'
|
||||
exception_controller: null
|
||||
paths:
|
||||
# used in templates/emails/layout.html.twig
|
||||
'%kernel.project_dir%/assets/css': css
|
||||
'%kernel.project_dir%/templates/bundles/TablerBundle': theme
|
||||
'%kernel.project_dir%/vendor/kevinpapst/tabler-bundle/templates': theme
|
||||
|
||||
116
kimai.sh
116
kimai.sh
@@ -1,116 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# --------------------------------------------------------------------------------
|
||||
# This script was added with 2.24.0 and is in BETA status.
|
||||
#
|
||||
# To improve this script across platforms I need your feedback!
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
function update_kimai() {
|
||||
if [[ "$1" =~ ^([0-9]+\.){2,3}[0-9]+$ ]]; then
|
||||
export VERSION=$1
|
||||
else
|
||||
echo "You need to supply a full Kimai version like: \"2.24.0\""
|
||||
exit 1
|
||||
fi
|
||||
git checkout -- composer.json
|
||||
git checkout -- composer.lock
|
||||
git checkout -- symfony.lock
|
||||
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "Cannot update: file changes detected. Run \"git status\" for details."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf var/cache/* 2>&1
|
||||
git fetch --tags
|
||||
git checkout "$VERSION"
|
||||
$KIMAI_PHP "$KIMAI_COMPOSER" install --no-dev --optimize-autoloader
|
||||
|
||||
$KIMAI_PHP bin/console kimai:install
|
||||
|
||||
install_plugins
|
||||
|
||||
if [[ -z "${KIMAI_NO_PERMS}" ]]; then
|
||||
set_permission
|
||||
fi
|
||||
}
|
||||
|
||||
function install_plugins() {
|
||||
# detect if there are additional plugins that we need to install
|
||||
packages="$($PHP bin/console kimai:plugin --composer)"
|
||||
export PACKAGES=$packages
|
||||
if [ -n "$PACKAGES" ]; then
|
||||
$KIMAI_PHP "$KIMAI_COMPOSER" require "$PACKAGES"
|
||||
$KIMAI_PHP bin/console kimai:plugins --install
|
||||
fi
|
||||
}
|
||||
|
||||
function set_permission() {
|
||||
chown -R "$KIMAI_USER":"$KIMAI_GROUP" .
|
||||
chmod -R g+r .
|
||||
chmod -R g+rw var/
|
||||
}
|
||||
|
||||
if [[ -z "${KIMAI_USER}" ]]; then
|
||||
export KIMAI_USER=""
|
||||
fi
|
||||
|
||||
if [[ -z "${KIMAI_GROUP}" ]]; then
|
||||
export KIMAI_GROUP="www-data"
|
||||
fi
|
||||
|
||||
if [[ -z "${KIMAI_PHP}" ]]; then
|
||||
export KIMAI_PHP="php"
|
||||
fi
|
||||
|
||||
if [[ -z "${KIMAI_COMPOSER}" ]]; then
|
||||
export KIMAI_COMPOSER="composer"
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")" || { echo "Cannot change working directory."; exit 1; }
|
||||
|
||||
# we need a few commands installed in order for this script to complete
|
||||
command -v $KIMAI_COMPOSER >/dev/null 2>&1 || { echo >&2 "Update requires 'composer' but it's not installed."; exit 1; }
|
||||
command -v git >/dev/null 2>&1 || { echo >&2 "Update requires 'git' but it's not installed."; exit 1; }
|
||||
command -v $KIMAI_PHP >/dev/null 2>&1 || { echo >&2 "Update requires 'php' but it's not installed."; exit 1; }
|
||||
|
||||
if [[ -n $1 ]]; then
|
||||
if [ "$1" == 'update' ]; then
|
||||
update_kimai "$2"
|
||||
exit
|
||||
elif [ "$1" == 'permission' ]; then
|
||||
set_permission
|
||||
exit
|
||||
elif [ "$1" == 'plugins' ]; then
|
||||
install_plugins
|
||||
exit
|
||||
else
|
||||
echo ""
|
||||
echo ">> Unknown command: $1"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "This script has the following sub-commands:"
|
||||
echo ""
|
||||
echo "$0 update <version> - Install Kimai version <version>"
|
||||
echo "$0 permission - Fix file permissions"
|
||||
echo "$0 plugins - Install plugins from var/packages/*.zip"
|
||||
echo ""
|
||||
echo "Use the following environment variables to customize the runtime:"
|
||||
echo ""
|
||||
echo "KIMAI_USER - Username of the webserver/php process that needs write access"
|
||||
echo "KIMAI_GROUP - Group of the webserver/php process that needs write access"
|
||||
echo "KIMAI_PHP - Full path to PHP executable in the correct version"
|
||||
echo "KIMAI_COMPOSER - Path to composer executable or .phar file"
|
||||
echo "KIMAI_NO_PERMS - Skip changing permissions"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo ""
|
||||
echo "$0 2.24.0"
|
||||
echo "KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0"
|
||||
echo "KIMAI_PHP=/usr/bin/php8.3 KIMAI_COMPOSER=/tmp/composer.phar $0 2.24.0"
|
||||
echo "KIMAI_PHP=php8.3 KIMAI_GROUP=httpd $0 2.24.0"
|
||||
echo "KIMAI_NO_PERMS=1 KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0"
|
||||
echo ""
|
||||
@@ -24,9 +24,9 @@
|
||||
],
|
||||
"packageManager": "yarn@3.2.1",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26",
|
||||
"@babel/core": "^7.19",
|
||||
"@babel/eslint-parser": "^7.19",
|
||||
"@babel/preset-env": "^7.26",
|
||||
"@babel/preset-env": "^7.19",
|
||||
"@fortawesome/fontawesome-free": "^6.5",
|
||||
"@fullcalendar/bootstrap5": "^5.11",
|
||||
"@fullcalendar/core": "^5.11",
|
||||
@@ -47,10 +47,7 @@
|
||||
"sass": "^1.62",
|
||||
"sass-loader": "^13.2",
|
||||
"tom-select": "^2.2",
|
||||
"webpack": "^5.96",
|
||||
"webpack": "^5.91",
|
||||
"webpack-cli": "^4.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
101
phpstan.neon
101
phpstan.neon
@@ -21,10 +21,12 @@ parameters:
|
||||
booleansInConditions: false
|
||||
uselessCast: true
|
||||
requireParentConstructorCall: true
|
||||
disallowedConstructs: false
|
||||
overwriteVariablesWithLoop: true
|
||||
closureUsesThis: true
|
||||
matchingInheritedMethodNames: true
|
||||
numericOperandsInArithmeticOperators: true
|
||||
strictCalls: false
|
||||
switchConditionsMatchingType: true
|
||||
noVariableVariables: false
|
||||
paths:
|
||||
@@ -288,7 +290,7 @@ parameters:
|
||||
path: src/Command/ExportCreateCommand.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$locale of static method Locale\\:\\:setDefault\\(\\) expects string, mixed given\\.$#"
|
||||
message: "#^Parameter \\#1 \\$locale of static method Symfony\\\\Polyfill\\\\Intl\\\\Icu\\\\Locale\\:\\:setDefault\\(\\) expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ExportCreateCommand.php
|
||||
|
||||
@@ -313,7 +315,7 @@ parameters:
|
||||
path: src/Command/ExportCreateCommand.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$name of method Symfony\\\\Component\\\\HttpFoundation\\\\File\\\\File\\:\\:move\\(\\) expects string\\|null, list\\<string\\>\\|string given\\.$#"
|
||||
message: "#^Parameter \\#2 \\$name of method Symfony\\\\Component\\\\HttpFoundation\\\\File\\\\File\\:\\:move\\(\\) expects string\\|null, array\\<int, string\\>\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ExportCreateCommand.php
|
||||
|
||||
@@ -327,6 +329,16 @@ parameters:
|
||||
count: 4
|
||||
path: src/Command/InstallCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method get\\(\\) on Symfony\\\\Component\\\\Console\\\\Helper\\\\HelperSet\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Command/InstallCommand.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Command\\\\InstallCommand\\:\\:askConfirmation\\(\\) should return bool but returns mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Command/InstallCommand.php
|
||||
|
||||
-
|
||||
message: "#^Binary operation \"\\.\" between non\\-empty\\-string and non\\-empty\\-list\\<string\\>\\|string results in an error\\.$#"
|
||||
count: 1
|
||||
@@ -463,7 +475,7 @@ parameters:
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type list\\<string\\>\\|false supplied for foreach, only iterables are supported\\.$#"
|
||||
message: "#^Argument of an invalid type array\\<int, string\\>\\|false supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 4
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
@@ -542,6 +554,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method find\\(\\) on Symfony\\\\Component\\\\Console\\\\Application\\|null\\.$#"
|
||||
count: 3
|
||||
path: src/Command/UpdateCommand.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Configuration\\\\LdapConfiguration\\:\\:getConnectionParameters\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -697,6 +714,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Controller/ActivityController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$returnTo of method OneLogin\\\\Saml2\\\\Auth\\:\\:login\\(\\) expects string\\|null, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/Auth/SamlController.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'user' on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -1287,6 +1309,16 @@ parameters:
|
||||
count: 1
|
||||
path: src/Entity/Team.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Entity\\\\Team\\:\\:getTeamleads\\(\\) should return array\\<App\\\\Entity\\\\User\\> but returns array\\<int, App\\\\Entity\\\\User\\|null\\>\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/Team.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Entity\\\\Team\\:\\:getUsers\\(\\) should return array\\<App\\\\Entity\\\\User\\> but returns array\\<int, App\\\\Entity\\\\User\\|null\\>\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/Team.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Entity\\\\Team\\:\\:\\$activities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
@@ -1448,7 +1480,7 @@ parameters:
|
||||
path: src/Entity/UserPreference.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, string\\|null given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/UserPreference.php
|
||||
|
||||
@@ -1462,6 +1494,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Entity/UserPreference.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Entity\\\\UserPreference\\:\\:\\$value \\(string\\|null\\) does not accept mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/UserPreference.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Event\\\\AbstractTimesheetMultipleEvent\\:\\:getTimesheets\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -1787,6 +1824,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Export/Spreadsheet/EntityWithMetaFieldsExporter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Spreadsheet\\\\Extractor\\\\AnnotationExtractor\\:\\:extract\\(\\) should return array\\<App\\\\Export\\\\Spreadsheet\\\\ColumnDefinition\\> but returns array\\<int, App\\\\Export\\\\Spreadsheet\\\\ColumnDefinition\\|null\\>\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Spreadsheet/Extractor/AnnotationExtractor.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$objectOrClass of class ReflectionClass constructor expects class\\-string\\<T of object\\>\\|T of object, string given\\.$#"
|
||||
count: 1
|
||||
@@ -1978,7 +2020,7 @@ parameters:
|
||||
path: src/Form/Helper/ActivityHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/ActivityHelper.php
|
||||
|
||||
@@ -1993,7 +2035,7 @@ parameters:
|
||||
path: src/Form/Helper/CustomerHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/CustomerHelper.php
|
||||
|
||||
@@ -2008,7 +2050,7 @@ parameters:
|
||||
path: src/Form/Helper/ProjectHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/ProjectHelper.php
|
||||
|
||||
@@ -3338,7 +3380,7 @@ parameters:
|
||||
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, mixed given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
|
||||
|
||||
@@ -3368,7 +3410,7 @@ parameters:
|
||||
path: src/Invoice/Renderer/DocxRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\<float\\|int\\|string\\>\\|string, mixed given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Invoice/Renderer/DocxRenderer.php
|
||||
|
||||
@@ -3532,6 +3574,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Ldap/LdapUserProvider.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\.\\.\\.\\$addresses of method Symfony\\\\Component\\\\Mime\\\\Email\\:\\:from\\(\\) expects string\\|Symfony\\\\Component\\\\Mime\\\\Address, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Mail/KimaiMailer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\.\\.\\.\\$arrays of function array_merge expects array, mixed given\\.$#"
|
||||
count: 1
|
||||
@@ -3547,6 +3594,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Model/MonthlyStatistic.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Model\\\\MonthlyStatistic\\:\\:getYears\\(\\) should return array\\<string\\> but returns array\\<int, int\\|string\\>\\.$#"
|
||||
count: 1
|
||||
path: src/Model/MonthlyStatistic.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Model\\\\Statistic\\\\Day\\:\\:getDetails\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3617,6 +3669,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Plugin/PluginMetadata.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Plugin/PluginMetadata.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'duration' on mixed\\.$#"
|
||||
count: 3
|
||||
@@ -3867,6 +3924,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Repository/TimesheetRepository.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getRawData\\(\\) should return array but returns mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Repository/TimesheetRepository.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$amountThisMonth of method App\\\\Model\\\\TimesheetStatistic\\:\\:setAmountThisMonth\\(\\) expects float\\|int, mixed given\\.$#"
|
||||
count: 1
|
||||
@@ -3962,6 +4024,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Saml/Security/SamlAuthenticationFailureHandler.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandler\\:\\:determineTargetUrl\\(\\) should return string but returns mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Saml/Security/SamlAuthenticationSuccessHandler.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandler\\:\\:\\$defaultOptions has no type specified\\.$#"
|
||||
count: 1
|
||||
@@ -4403,7 +4470,7 @@ parameters:
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:durationDecimal\\(\\) should return string but returns string\\|false\\.$#"
|
||||
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:durationDecimal\\(\\) should return string but returns bool\\|string\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
@@ -4413,17 +4480,17 @@ parameters:
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:money\\(\\) should return string but returns string\\|false\\.$#"
|
||||
count: 2
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$num of method NumberFormatter\\:\\:format\\(\\) expects float\\|int, float\\|int\\|string given\\.$#"
|
||||
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:money\\(\\) should return string but returns bool\\|string\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$currency of method NumberFormatter\\:\\:formatCurrency\\(\\) expects string, string\\|null given\\.$#"
|
||||
message: "#^Parameter \\#1 \\$num of method Symfony\\\\Polyfill\\\\Intl\\\\Icu\\\\NumberFormatter\\:\\:format\\(\\) expects float\\|int, float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$currency of method Symfony\\\\Polyfill\\\\Intl\\\\Icu\\\\NumberFormatter\\:\\:formatCurrency\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/app.6cdb49cb.js
Normal file
2
public/build/app.6cdb49cb.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.74179306.js",
|
||||
"/build/app.dd395c1d.js"
|
||||
"/build/app.6cdb49cb.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.6bd98062.css"
|
||||
"/build/app.1ed18844.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"/build/app-rtl.97153087.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.6a14a027.css"
|
||||
"/build/app-rtl.d38ad12c.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -72,10 +72,10 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
|
||||
"/build/app.dd395c1d.js": "sha384-OMqLzNbXe5Dx/gPoy67bNHWTehw5vz1UYQ0GsfRwpaokM+MwBRymwkC4MT28rkjz",
|
||||
"/build/app.6bd98062.css": "sha384-BB9TrvQK/jlfhbdISEdBH5ZaZvbrPnZ+AcocXs1uQNh2yIQFboslUY9Up3iqnY4h",
|
||||
"/build/app.6cdb49cb.js": "sha384-psXCV8SG68O80ZJHHeSbCJPQ8RoepaMeI1pEypTHu0Q8ySLrhlclhKHIL2dZ0ZQJ",
|
||||
"/build/app.1ed18844.css": "sha384-fEjS/tjc8m7jrw28/eWZFOTO6bugR9ILfksKvBrF46wtmqlage1s/P4+ZeYuqgpq",
|
||||
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
|
||||
"/build/app-rtl.6a14a027.css": "sha384-ZmUAtO0Z/WfjYxJNleP3rBJpDmAnbaTlFMtejV/YpsshxkqmyNUneCIos9uMX7kH",
|
||||
"/build/app-rtl.d38ad12c.css": "sha384-eFvMRueGVAxDxpr3s4y1EhHnVb8FQOr+KQgOuTzbW11ILydpLIvObiiuqNpf4mWO",
|
||||
"/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"build/app.css": "/build/app.6bd98062.css",
|
||||
"build/app.js": "/build/app.dd395c1d.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.6a14a027.css",
|
||||
"build/app.css": "/build/app.1ed18844.css",
|
||||
"build/app.js": "/build/app.6cdb49cb.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.d38ad12c.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.97153087.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.1442bee7.js",
|
||||
|
||||
@@ -4,6 +4,9 @@ use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
// TODO remove once PARTIAL usage was replaced entirely
|
||||
\Doctrine\Deprecations\Deprecation::ignoreDeprecations('https://github.com/doctrine/orm/issues/8471');
|
||||
|
||||
return function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ use App\Entity\Timesheet;
|
||||
use App\Event\PageActionsEvent;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\API;
|
||||
|
||||
use App\Activity\ActivityService;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityRate;
|
||||
use App\Entity\User;
|
||||
@@ -47,8 +46,7 @@ final class ActivityController extends BaseApiController
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly ActivityRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ActivityRateRepository $activityRateRepository,
|
||||
private readonly ActivityService $activityService
|
||||
private readonly ActivityRateRepository $activityRateRepository
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -210,25 +208,6 @@ final class ActivityController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing activity
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked timesheets.
|
||||
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
|
||||
*/
|
||||
#[IsGranted('delete', 'activity')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one activity')])]
|
||||
#[OA\Parameter(name: 'id', description: 'Activity ID to delete', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}', name: 'delete_activity', requirements: ['id' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteAction(Activity $activity): Response
|
||||
{
|
||||
$this->activityService->deleteActivity($activity);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing activity
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\API\Model\TimesheetConfig;
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -46,8 +46,7 @@ final class CustomerController extends BaseApiController
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly CustomerRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly CustomerRateRepository $customerRateRepository,
|
||||
private readonly CustomerService $customerService,
|
||||
private readonly CustomerRateRepository $customerRateRepository
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -186,25 +185,6 @@ final class CustomerController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing customer
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked projects, project activities and timesheets.
|
||||
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
|
||||
*/
|
||||
#[IsGranted('delete', 'customer')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one customer')])]
|
||||
#[OA\Parameter(name: 'id', description: 'Customer ID to delete', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}', name: 'delete_customer', requirements: ['id' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteAction(Customer $customer): Response
|
||||
{
|
||||
$this->customerService->deleteCustomer($customer);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing customer
|
||||
*/
|
||||
|
||||
@@ -240,25 +240,6 @@ final class ProjectController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing project
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked activities and timesheets.
|
||||
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
|
||||
*/
|
||||
#[IsGranted('delete', 'project')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one project')])]
|
||||
#[OA\Parameter(name: 'id', description: 'Project ID to delete', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}', name: 'delete_project', requirements: ['id' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteAction(Project $project): Response
|
||||
{
|
||||
$this->projectService->deleteProject($project);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing project
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ use App\API\Model\Version;
|
||||
use App\Plugin\PluginManager;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -37,7 +37,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
@@ -182,11 +181,8 @@ final class TimesheetController extends BaseApiController
|
||||
/** @var array<string> $tags */
|
||||
$tags = $paramFetcher->get('tags');
|
||||
if (\is_array($tags) && \count($tags) > 0) {
|
||||
$tagsByName = $this->tagRepository->findTagsByName($tags, true);
|
||||
if (\count($tagsByName) === 0) {
|
||||
throw new BadRequestHttpException('Given tags were not found');
|
||||
}
|
||||
foreach ($tagsByName as $tag) {
|
||||
$tags = $this->tagRepository->findTagsByName($tags, true);
|
||||
foreach ($tags as $tag) {
|
||||
$query->addTag($tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Entity\Project;
|
||||
use App\Event\ActivityCreateEvent;
|
||||
use App\Event\ActivityCreatePostEvent;
|
||||
use App\Event\ActivityCreatePreEvent;
|
||||
use App\Event\ActivityDeleteEvent;
|
||||
use App\Event\ActivityMetaDefinitionEvent;
|
||||
use App\Event\ActivityUpdatePostEvent;
|
||||
use App\Event\ActivityUpdatePreEvent;
|
||||
@@ -70,13 +69,8 @@ class ActivityService
|
||||
return $activity;
|
||||
}
|
||||
|
||||
public function deleteActivity(Activity $activity): void
|
||||
{
|
||||
$this->dispatcher->dispatch(new ActivityDeleteEvent($activity));
|
||||
$this->repository->deleteActivity($activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Activity $activity
|
||||
* @param string[] $groups
|
||||
* @throws ValidationFailedException
|
||||
*/
|
||||
|
||||
@@ -10,24 +10,34 @@
|
||||
namespace App\Activity;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Event\ActivityBudgetStatisticEvent;
|
||||
use App\Event\ActivityStatisticEvent;
|
||||
use App\Model\ActivityBudgetStatisticModel;
|
||||
use App\Model\ActivityStatistic;
|
||||
use App\Reporting\ActivityView\ActivityViewModel;
|
||||
use App\Reporting\ActivityView\ActivityViewQuery;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\Loader\ActivityLoader;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @final
|
||||
*/
|
||||
class ActivityStatisticService
|
||||
{
|
||||
public function __construct(private readonly TimesheetRepository $timesheetRepository, private readonly EventDispatcherInterface $dispatcher)
|
||||
public function __construct(
|
||||
private readonly TimesheetRepository $timesheetRepository,
|
||||
private readonly ActivityRepository $activityRepository,
|
||||
private readonly EventDispatcherInterface $dispatcher
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -197,4 +207,201 @@ class ActivityStatisticService
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Activity[]
|
||||
*/
|
||||
public function findActivitiesForView(ActivityViewQuery $query): array
|
||||
{
|
||||
$user = $query->getUser();
|
||||
$today = clone $query->getToday();
|
||||
|
||||
$qb = $this->activityRepository->createQueryBuilder('a');
|
||||
$qb
|
||||
->select('a')
|
||||
->leftJoin('a.project', 'p')
|
||||
->leftJoin('p.customer', 'c')
|
||||
->andWhere($qb->expr()->eq('a.visible', true))
|
||||
->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('a.project'),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('p.visible', true),
|
||||
$qb->expr()->eq('c.visible', true),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('p.end'),
|
||||
$qb->expr()->gte('p.end', ':project_end')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
->addGroupBy('a')
|
||||
->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
if ($query->getProject() !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('p', ':project'));
|
||||
$qb->setParameter('project', $query->getProject()->getId());
|
||||
}
|
||||
|
||||
if (!$query->isIncludeNoWork()) {
|
||||
$qb
|
||||
->leftJoin(Timesheet::class, 't', 'WITH', 'a.id = t.activity')
|
||||
->andHaving($qb->expr()->gt('SUM(t.duration)', 0))
|
||||
;
|
||||
}
|
||||
|
||||
if ($query->isIncludeWithBudget()) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gt('a.timeBudget', 0),
|
||||
$qb->expr()->gt('a.budget', 0)
|
||||
)
|
||||
);
|
||||
} elseif ($query->isIncludeWithoutBudget()) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('a.timeBudget', 0),
|
||||
$qb->expr()->eq('a.budget', 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->activityRepository->addPermissionCriteria($qb, $user);
|
||||
|
||||
/** @var Activity[] $activities */
|
||||
$activities = $qb->getQuery()->getResult();
|
||||
|
||||
// pre-cache project objects instead of joining them
|
||||
$loader = new ActivityLoader($this->activityRepository->createQueryBuilder('a')->getEntityManager());
|
||||
$loader->loadResults($activities);
|
||||
|
||||
return $activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Activity[] $activities
|
||||
* @return ActivityViewModel[]
|
||||
*/
|
||||
public function getActivityView(User $user, array $activities, DateTimeInterface $today): array
|
||||
{
|
||||
$factory = DateTimeFactory::createByUser($user);
|
||||
$today = clone $today;
|
||||
|
||||
$startOfWeek = $factory->getStartOfWeek($today);
|
||||
$endOfWeek = $factory->getEndOfWeek($today);
|
||||
$startMonth = (clone $startOfWeek)->modify('first day of this month');
|
||||
$endMonth = (clone $startOfWeek)->modify('last day of this month');
|
||||
|
||||
$activityView = [];
|
||||
foreach ($activities as $activity) {
|
||||
$activityView[$activity->getId()] = new ActivityViewModel($activity);
|
||||
}
|
||||
|
||||
$budgetStats = $this->getBudgetStatisticModelForActivities($activities, $today);
|
||||
foreach ($budgetStats as $model) {
|
||||
$activityView[$model->getActivity()->getId()]->setBudgetStatisticModel($model);
|
||||
}
|
||||
|
||||
$activityIds = array_keys($activityView);
|
||||
|
||||
$tplQb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$tplQb
|
||||
->select('IDENTITY(t.activity) AS id')
|
||||
->addSelect('COUNT(t.id) as amount')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) AS duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) AS rate')
|
||||
->andWhere($tplQb->expr()->in('t.activity', ':activity'))
|
||||
->groupBy('t.activity')
|
||||
->setParameter('activity', $activityIds)
|
||||
;
|
||||
|
||||
$qb = clone $tplQb;
|
||||
$qb->addSelect('MAX(t.date) as lastRecord');
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float, lastRecord: string}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$activityView[$row['id']]->setDurationTotal($row['duration']);
|
||||
$activityView[$row['id']]->setRateTotal($row['rate']);
|
||||
$activityView[$row['id']]->setTimesheetCounter($row['amount']);
|
||||
if ($row['lastRecord'] !== null) {
|
||||
// might be the wrong timezone
|
||||
$activityView[$row['id']]->setLastRecord($factory->createDateTime($row['lastRecord']));
|
||||
}
|
||||
}
|
||||
|
||||
// values for today
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->andWhere('DATE(t.date) = :start_date')
|
||||
->setParameter('start_date', $today, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$activityView[$row['id']]->setDurationDay($row['duration'] ?? 0);
|
||||
}
|
||||
|
||||
// values for the current week
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
|
||||
->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE)
|
||||
->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$activityView[$row['id']]->setDurationWeek($row['duration']);
|
||||
}
|
||||
|
||||
// values for the current month
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
|
||||
->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE)
|
||||
->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$activityView[$row['id']]->setDurationMonth($row['duration']);
|
||||
}
|
||||
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->addSelect('t.exported')
|
||||
->addSelect('t.billable')
|
||||
->addGroupBy('t.exported')
|
||||
->addGroupBy('t.billable')
|
||||
;
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float, exported: bool, billable: bool}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
/** @var ActivityViewModel $view */
|
||||
$view = $activityView[$row['id']];
|
||||
if ($row['billable'] === 1 && $row['exported'] === 1) {
|
||||
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
|
||||
$view->setBillableRate($view->getBillableRate() + $row['rate']);
|
||||
} elseif ($row['billable'] === 1 && $row['exported'] === 0) {
|
||||
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
|
||||
$view->setBillableRate($view->getBillableRate() + $row['rate']);
|
||||
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
|
||||
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
|
||||
$view->setNotBilledDuration($view->getNotBilledDuration() + $row['duration']);
|
||||
$view->setNotBilledRate($view->getNotBilledRate() + $row['rate']);
|
||||
} elseif ($row['billable'] === 0 && $row['exported'] === 0) {
|
||||
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
|
||||
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
|
||||
}
|
||||
// the last possible case $row['billable'] === 0 && $row['exported'] === 1 is extremely unlikely and not used
|
||||
}
|
||||
|
||||
return array_values($activityView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
@@ -25,10 +26,10 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:install', description: 'Kimai installation command', aliases: ['kimai:update'])]
|
||||
#[AsCommand(name: 'kimai:install')]
|
||||
final class InstallCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly Connection $connection)
|
||||
public function __construct(private Connection $connection, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -36,7 +37,8 @@ final class InstallCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp('This command will perform the installation steps to bootstrap the application, database and plugins.')
|
||||
->setDescription('Basic installation for Kimai')
|
||||
->setHelp('This command will perform the basic installation steps to get Kimai up and running.')
|
||||
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache re-generation')
|
||||
;
|
||||
}
|
||||
@@ -45,23 +47,19 @@ final class InstallCommand extends Command
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->text('Start installation ...');
|
||||
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
$environment = $application->getKernel()->getEnvironment();
|
||||
$io->title('Kimai installation running ...');
|
||||
|
||||
// create the database, in case it is not yet existing
|
||||
try {
|
||||
// creates the database if it is not yet existing
|
||||
$this->createDatabase($io, $output);
|
||||
$this->createDatabase($io, $input, $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to create database: ' . $ex->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
|
||||
try {
|
||||
// bootstrap database ONLY via doctrine migrations, so all installation will have the same state
|
||||
$this->importMigrations($io, $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to set migration status: ' . $ex->getMessage());
|
||||
@@ -70,22 +68,12 @@ final class InstallCommand extends Command
|
||||
}
|
||||
|
||||
if (!$input->getOption('no-cache')) {
|
||||
// show manual steps in case this fails
|
||||
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
|
||||
|
||||
if ($cacheResult !== Command::SUCCESS) {
|
||||
$io->warning(
|
||||
[
|
||||
'Please run the cache commands manually:',
|
||||
'bin/console cache:clear --env=' . $environment . PHP_EOL .
|
||||
'bin/console cache:warmup --env=' . $environment
|
||||
]
|
||||
);
|
||||
}
|
||||
// flush the cache, just to make sure ... and ignore result
|
||||
$this->rebuildCaches($this->kernelEnvironment, $io, $input, $output);
|
||||
}
|
||||
|
||||
$io->success(
|
||||
\sprintf('Successfully installed %s version %s 🎉', Constants::SOFTWARE, Constants::VERSION)
|
||||
\sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION)
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -93,13 +81,11 @@ final class InstallCommand extends Command
|
||||
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io->text('Rebuilding cache ...');
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
$command = $this->getApplication()->find('cache:clear');
|
||||
try {
|
||||
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
|
||||
throw new \RuntimeException('Invalid file permissions?');
|
||||
}
|
||||
$command->run(new ArrayInput(['--env' => $environment]), $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to clear cache: ' . $ex->getMessage());
|
||||
|
||||
@@ -108,9 +94,7 @@ final class InstallCommand extends Command
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
try {
|
||||
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
|
||||
throw new \RuntimeException('Invalid file permissions?');
|
||||
}
|
||||
$command->run(new ArrayInput(['--env' => $environment]), $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to warmup cache: ' . $ex->getMessage());
|
||||
|
||||
@@ -122,36 +106,55 @@ final class InstallCommand extends Command
|
||||
|
||||
private function importMigrations(SymfonyStyle $io, OutputInterface $output): void
|
||||
{
|
||||
$io->text('Creating database ...');
|
||||
|
||||
$command = $this->getApplication()->find('doctrine:migrations:migrate');
|
||||
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
$result = $command->run($cmdInput, $output);
|
||||
$command->run($cmdInput, $output);
|
||||
|
||||
if (0 !== $result) {
|
||||
throw new \Exception('Failed updating database.');
|
||||
}
|
||||
$io->writeln('');
|
||||
}
|
||||
|
||||
private function createDatabase(SymfonyStyle $io, OutputInterface $output): void
|
||||
private function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
try {
|
||||
if ($this->connection->isConnected()) {
|
||||
// database exists: we can skip this step
|
||||
$io->note(\sprintf('Database is existing and connection could be established'));
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
// this means the database does not exist and the connection failed
|
||||
|
||||
if (!$this->askConfirmation($input, $output, \sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
|
||||
throw new \Exception('Skipped database creation, aborting installation');
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
// this likely means that the database does not exist. the latest doctrine release
|
||||
// changed the behavior: in previous version this code did not throw an exception.
|
||||
}
|
||||
|
||||
$options = ['--if-not-exists' => true];
|
||||
|
||||
$command = $this->getApplication()->find('doctrine:database:create');
|
||||
$cmdInput = new ArrayInput(['--if-not-exists' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
$result = $command->run($cmdInput, $output);
|
||||
$result = $command->run(new ArrayInput($options), $output);
|
||||
|
||||
if (0 !== $result) {
|
||||
throw new \Exception('Failed creating database: check your DATABASE_URL.');
|
||||
throw new \Exception('Failed creating database. Check your credentials in DATABASE_URL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @param string $question
|
||||
* @param bool $default
|
||||
* @return bool
|
||||
*/
|
||||
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false): bool
|
||||
{
|
||||
/** @var QuestionHelper $questionHelper */
|
||||
$questionHelper = $this->getHelperSet()->get('question');
|
||||
$text = \sprintf('<info>%s (yes/no)</info> [<comment>%s</comment>]:', $question, $default ? 'yes' : 'no');
|
||||
$question = new ConfirmationQuestion(' ' . $text . ' ', $default, '/^y|yes/i');
|
||||
|
||||
return $questionHelper->ask($input, $output, $question);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,144 +9,37 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Plugin\Package;
|
||||
use App\Plugin\PackageManager;
|
||||
use App\Plugin\Plugin;
|
||||
use App\Plugin\PluginManager;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Process\PhpSubprocess;
|
||||
|
||||
#[AsCommand(name: 'kimai:plugins', description: 'Manage Kimai plugins')]
|
||||
/**
|
||||
* Command used to fetch plugin information.
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:plugins')]
|
||||
final class PluginCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PluginManager $pluginManager,
|
||||
private readonly PackageManager $packageManager
|
||||
)
|
||||
public function __construct(private PluginManager $plugins)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setHelp('Shows information about already installed plugins by default.');
|
||||
$this->addOption('available', null, InputOption::VALUE_NONE, 'Show list of available plugins in ' . PackageManager::PACKAGE_DIR);
|
||||
$this->addOption('composer', null, InputOption::VALUE_NONE, 'Dump list of available composer packages in ' . PackageManager::PACKAGE_DIR);
|
||||
$this->addOption('install', null, InputOption::VALUE_NONE, 'Run plugins installer, previously installed via ./kimai.sh');
|
||||
$this
|
||||
->setDescription('Receive plugin information')
|
||||
->setHelp('This command prints detailed plugin information.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if ($input->getOption('available')) {
|
||||
return $this->listPackages($io, $this->packageManager->getAvailablePackages());
|
||||
} elseif ($input->getOption('composer')) {
|
||||
return $this->listComposerPackages($io, $this->packageManager->getAvailablePackages());
|
||||
} elseif ($input->getOption('install')) {
|
||||
return $this->installPlugins($io, $output, $this->pluginManager->getPlugins());
|
||||
}
|
||||
|
||||
return $this->listInstalledPlugins($io, $this->pluginManager->getPlugins());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Plugin[] $plugins
|
||||
*/
|
||||
private function installPlugins(SymfonyStyle $io, OutputInterface $output, array $plugins): int
|
||||
{
|
||||
foreach ($plugins as $plugin) {
|
||||
$config = $plugin->getPath() . '/migrations/doctrine_migrations.yaml';
|
||||
if (!file_exists($config)) {
|
||||
$config = $plugin->getPath() . '/Migrations/doctrine_migrations.yaml';
|
||||
if (!file_exists($config)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// using getApplication()->find('doctrine:migrations:migrate') does NOT work here
|
||||
// because the Doctrine command can only be executed once
|
||||
// if run more than once it fails with a "Container is frozen" exception
|
||||
|
||||
$process = new PhpSubprocess([
|
||||
'bin/console',
|
||||
'doctrine:migrations:migrate',
|
||||
'--allow-no-migration',
|
||||
'--no-interaction',
|
||||
'--configuration=' . $config
|
||||
]);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->error('Failed to install bundle database: ' . PHP_EOL . $config);
|
||||
$io->error($process->getErrorOutput());
|
||||
} else {
|
||||
if ($io->isVerbose()) {
|
||||
$io->write($process->getOutput());
|
||||
} else {
|
||||
$io->success('Successfully installed: ' . $plugin->getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Package[] $packages
|
||||
*/
|
||||
private function listComposerPackages(SymfonyStyle $io, array $packages): int
|
||||
{
|
||||
if (empty($packages)) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$all = [];
|
||||
foreach ($packages as $package) {
|
||||
$all[] = $package->getMetadata()->getPackage();
|
||||
}
|
||||
$io->write(implode(' ', $all));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Package[] $packages
|
||||
*/
|
||||
private function listPackages(SymfonyStyle $io, array $packages): int
|
||||
{
|
||||
if (empty($packages)) {
|
||||
$io->warning('No packages to install found');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($packages as $package) {
|
||||
$metadata = $package->getMetadata();
|
||||
$rows[] = [
|
||||
$metadata->getName(),
|
||||
$metadata->getVersion(),
|
||||
$metadata->getKimaiVersion(),
|
||||
$metadata->getPackage(),
|
||||
$package->getPackageFile()->getPathname(),
|
||||
];
|
||||
}
|
||||
$io->table(['Name', 'Version', 'Requires', 'Package', 'Directory'], $rows);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Plugin> $plugins
|
||||
*/
|
||||
private function listInstalledPlugins(SymfonyStyle $io, array $plugins): int
|
||||
{
|
||||
$plugins = $this->plugins->getPlugins();
|
||||
if (empty($plugins)) {
|
||||
$io->warning('No plugins installed');
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use Symfony\Component\Intl\Locales;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:reset:locales', description: 'Regenerate the locale definition file')]
|
||||
#[AsCommand(name: 'kimai:reset:locales')]
|
||||
final class RegenerateLocalesCommand extends Command
|
||||
{
|
||||
/**
|
||||
@@ -66,6 +66,11 @@ final class RegenerateLocalesCommand extends Command
|
||||
return $this->kernelEnvironment !== 'prod';
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Regenerate the locale definition file');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
@@ -131,15 +136,7 @@ final class RegenerateLocalesCommand extends Command
|
||||
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT);
|
||||
|
||||
$settings['date'] = $shortDate->getPattern();
|
||||
if ($settings['date'] === false) {
|
||||
$io->error('Invalid date pattern for locale: ' . $locale);
|
||||
continue;
|
||||
}
|
||||
$settings['time'] = $shortTime->getPattern();
|
||||
if ($settings['time'] === false) {
|
||||
$io->error('Invalid time pattern for locale: ' . $locale);
|
||||
continue;
|
||||
}
|
||||
|
||||
// see https://github.com/kimai/kimai/issues/4402 - Korean time format failed parsing
|
||||
// special case when time pattern starts with A / a => this will lead to an error
|
||||
|
||||
@@ -48,9 +48,7 @@ final class TranslationCommand extends Command
|
||||
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
|
||||
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
|
||||
->addOption('move-resname', null, InputOption::VALUE_REQUIRED, 'Move a resname from one file to another (needs "source" and "target" options)')
|
||||
->addOption('move-all', null, InputOption::VALUE_NONE, 'Move all keys from one file to another (needs "source" and "target" options)')
|
||||
->addOption('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use')
|
||||
->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories')
|
||||
->addOption('target', null, InputOption::VALUE_REQUIRED, 'Single target file to use')
|
||||
// DEEPL TRANSLATION FEATURE - UNTESTED
|
||||
->addOption('translate-locale', null, InputOption::VALUE_REQUIRED, 'Translate into the given locale with Deepl')
|
||||
@@ -70,13 +68,10 @@ final class TranslationCommand extends Command
|
||||
|
||||
$bases = [
|
||||
'core' => $this->projectDirectory . '/translations/*.xlf',
|
||||
'plugins' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf',
|
||||
'theme' => $this->projectDirectory . '/vendor/kevinpapst/tabler-bundle/translations/*.xlf',
|
||||
];
|
||||
|
||||
if (!$input->getOption('only-core')) {
|
||||
$bases['plugins'] = $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf';
|
||||
$bases['theme'] = $this->projectDirectory . '/vendor/kevinpapst/tabler-bundle/translations/*.xlf';
|
||||
}
|
||||
|
||||
$sources = [];
|
||||
if ($input->getOption('source') !== null) {
|
||||
/** @var string $tmp */
|
||||
@@ -151,19 +146,6 @@ final class TranslationCommand extends Command
|
||||
return $this->moveResname($io, $moveResname, $sources, $targets);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Move all keys from source to target
|
||||
// ==========================================================================
|
||||
if ($input->getOption('move-all')) {
|
||||
if (\count($sources) === 0 || \count($targets) === 0) {
|
||||
$io->error('Moving all keys only works with one source and one target file');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return $this->moveAll($io, $sources, $targets);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Fill empty translations with english version
|
||||
// ==========================================================================
|
||||
@@ -652,66 +634,4 @@ final class TranslationCommand extends Command
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $sources
|
||||
* @param array<string> $targets
|
||||
*/
|
||||
private function moveAll(SymfonyStyle $io, array $sources, array $targets): int
|
||||
{
|
||||
foreach ($sources as $source) {
|
||||
$tmp = basename($source);
|
||||
$pos = strpos($tmp, '.');
|
||||
if ($pos === false) {
|
||||
$io->error('Unexpected filename: ' . $source);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$suffix = substr($tmp, $pos);
|
||||
$target = null;
|
||||
foreach ($targets as $t) {
|
||||
if (str_ends_with($t, $suffix)) {
|
||||
$target = $t;
|
||||
}
|
||||
}
|
||||
|
||||
if ($target === null) {
|
||||
$io->error('Cannot find translation target file for source: ' . $source);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$sourceDocument = new \DOMDocument('1.0');
|
||||
$sourceDocument->load($source);
|
||||
|
||||
$targetDocument = new \DOMDocument('1.0');
|
||||
$targetDocument->load($target);
|
||||
$removeNodes = [];
|
||||
|
||||
/** @var \DOMElement $element */
|
||||
foreach ($sourceDocument->getElementsByTagName('trans-unit') as $element) {
|
||||
$newNode = $targetDocument->importNode($element, true);
|
||||
$targetDocument->documentElement->firstElementChild->firstElementChild->appendChild($newNode); // @phpstan-ignore-line
|
||||
$removeNodes[] = $element;
|
||||
}
|
||||
|
||||
foreach ($removeNodes as $node) {
|
||||
$sourceDocument->documentElement->firstElementChild->firstElementChild->removeChild($node); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($sourceDocument->saveXML()); // @phpstan-ignore-line
|
||||
file_put_contents($source, $xmlDocument->saveXML());
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($targetDocument->saveXML()); // @phpstan-ignore-line
|
||||
file_put_contents($target, $xmlDocument->saveXML());
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
137
src/Command/UpdateCommand.php
Normal file
137
src/Command/UpdateCommand.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?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\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\ConnectionException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Command used to update a Kimai installation.
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:update')]
|
||||
final class UpdateCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $connection, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDescription('Update your Kimai installation')
|
||||
->setHelp('This command will execute all required steps to update your Kimai installation.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai updates running ...');
|
||||
|
||||
$environment = $this->kernelEnvironment;
|
||||
|
||||
// make sure database is available, Kimai running and installed
|
||||
try {
|
||||
if (!$this->connection->createSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
|
||||
$io->error('Tables missing. Did you run the installer already?');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$this->connection->createSchemaManager()->tablesExist(['migration_versions'])) {
|
||||
$io->error('Unknown migration status, aborting database update');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} catch (ConnectionException $e) {
|
||||
$io->error(['Database connection could not be established.', $e->getMessage()]);
|
||||
|
||||
return Command::FAILURE;
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(['Failed to validate database.', $ex->getMessage()]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// execute latest doctrine migrations
|
||||
try {
|
||||
$command = $this->getApplication()->find('doctrine:migrations:migrate');
|
||||
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
if (0 !== $command->run($cmdInput, $output)) {
|
||||
throw new \RuntimeException('CRITICAL: problem when migrating database');
|
||||
}
|
||||
|
||||
$io->writeln('');
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// flush the cache, in case values from the database are cached
|
||||
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
|
||||
|
||||
if ($cacheResult !== Command::SUCCESS) {
|
||||
$io->warning(
|
||||
[
|
||||
\sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION),
|
||||
'Please run the cache commands manually:',
|
||||
'bin/console cache:clear --env=' . $environment . PHP_EOL .
|
||||
'bin/console cache:warmup --env=' . $environment
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$io->success(
|
||||
\sprintf('Congratulations! Successfully updated %s to version %s', Constants::SOFTWARE, Constants::VERSION)
|
||||
);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
$command = $this->getApplication()->find('cache:clear');
|
||||
try {
|
||||
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
|
||||
throw new \RuntimeException('Could not clear cache, missing permissions?');
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
try {
|
||||
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
|
||||
throw new \RuntimeException('Could not warmup cache, missing permissions?');
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,11 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.26.0';
|
||||
public const VERSION = '2.24.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 22600;
|
||||
public const VERSION_ID = 22400;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -12,24 +12,18 @@ namespace App\Controller\Auth;
|
||||
use App\Configuration\SamlConfigurationInterface;
|
||||
use App\Saml\SamlAuthFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Http\SecurityRequestAttributes;
|
||||
use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||
|
||||
#[Route(path: '/saml')]
|
||||
final class SamlController extends AbstractController
|
||||
{
|
||||
use TargetPathTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly SamlAuthFactory $authFactory,
|
||||
private readonly SamlConfigurationInterface $samlConfiguration,
|
||||
private readonly Security $security,
|
||||
private readonly SamlConfigurationInterface $samlConfiguration
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -60,12 +54,8 @@ final class SamlController extends AbstractController
|
||||
throw new \RuntimeException($error);
|
||||
}
|
||||
|
||||
$firewallName = $this->security->getFirewallConfig($request)?->getName();
|
||||
if ($firewallName === null || $firewallName === '') {
|
||||
throw new ServiceUnavailableHttpException(message: 'Unknown firewall.');
|
||||
}
|
||||
|
||||
$redirectTarget = $this->getTargetPath($session, $firewallName);
|
||||
// this does set headers and exit as $stay is not set to true
|
||||
$redirectTarget = $session->get('_security.main.target_path');
|
||||
if ($redirectTarget === null || $redirectTarget === '') {
|
||||
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
}
|
||||
|
||||
@@ -253,6 +253,7 @@ final class DoctorController extends AbstractController
|
||||
'max_execution_time',
|
||||
'date.timezone',
|
||||
'allow_url_fopen',
|
||||
'allow_url_include',
|
||||
'default_charset',
|
||||
'default_mimetype',
|
||||
'display_errors',
|
||||
|
||||
57
src/Controller/Reporting/ActivityViewController.php
Normal file
57
src/Controller/Reporting/ActivityViewController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?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\Controller\Reporting;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Controller\AbstractController;
|
||||
use App\Reporting\ActivityView\ActivityViewForm;
|
||||
use App\Reporting\ActivityView\ActivityViewQuery;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
final class ActivityViewController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/activity/project_view', name: 'report_activity_view', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('report:project')]
|
||||
#[IsGranted(new Expression("is_granted('budget_any', 'activity')"))]
|
||||
public function __invoke(Request $request, ActivityStatisticService $service): Response
|
||||
{
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ActivityViewQuery($dateFactory->createDateTime(), $user);
|
||||
$form = $this->createFormForGetRequest(ActivityViewForm::class, $query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$activities = $service->findActivitiesForView($query);
|
||||
$entries = $service->getActivityView($user, $activities, $query->getToday());
|
||||
|
||||
$byCustomer = [];
|
||||
foreach ($entries as $entry) {
|
||||
$project = $entry->getActivity()->getProject();
|
||||
$key = ($project === null || $project->getId() === null ? '__EMPTY__' : $project->getId());
|
||||
if (!\array_key_exists($key, $byCustomer)) {
|
||||
$byCustomer[$key] = ['project' => $project, 'activities' => [], 'name' => $project !== null ? $project->getName() : ''];
|
||||
}
|
||||
$byCustomer[$key]['activities'][] = $entry;
|
||||
}
|
||||
|
||||
return $this->render('reporting/activity_view.html.twig', [
|
||||
'entries' => $byCustomer,
|
||||
'form' => $form->createView(),
|
||||
'report_title' => 'report_activity_view',
|
||||
'tableName' => 'activity_view_reporting',
|
||||
'now' => $dateFactory->createDateTime(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ use App\Form\Model\MultiUserTimesheet;
|
||||
use App\Form\TimesheetAdminEditForm;
|
||||
use App\Form\TimesheetMultiUserEditForm;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\TimesheetQueryHint;
|
||||
use App\Utils\PageSetup;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -112,7 +111,6 @@ final class TimesheetTeamController extends TimesheetAbstractController
|
||||
$tags[] = $tag;
|
||||
}
|
||||
|
||||
$newTimesheets = [];
|
||||
foreach ($allUsers as $user) {
|
||||
$newTimesheet = $entry->createCopy();
|
||||
$newTimesheet->setUser($user);
|
||||
@@ -120,11 +118,6 @@ final class TimesheetTeamController extends TimesheetAbstractController
|
||||
$newTimesheet->addTag($tag);
|
||||
}
|
||||
$this->service->prepareNewTimesheet($newTimesheet, $request);
|
||||
$this->service->validateTimesheet($newTimesheet);
|
||||
$newTimesheets[] = $newTimesheet;
|
||||
}
|
||||
|
||||
foreach ($newTimesheets as $newTimesheet) {
|
||||
$this->service->saveNewTimesheet($newTimesheet);
|
||||
}
|
||||
|
||||
@@ -181,7 +174,6 @@ final class TimesheetTeamController extends TimesheetAbstractController
|
||||
protected function prepareQuery(TimesheetQuery $query): void
|
||||
{
|
||||
$query->setCurrentUser($this->getUser());
|
||||
$query->addQueryHint(TimesheetQueryHint::USER_PREFERENCES); // e.g. for latest approval
|
||||
}
|
||||
|
||||
protected function getCreateForm(Timesheet $entry): FormInterface
|
||||
|
||||
@@ -14,7 +14,6 @@ use App\Entity\Customer;
|
||||
use App\Event\CustomerCreateEvent;
|
||||
use App\Event\CustomerCreatePostEvent;
|
||||
use App\Event\CustomerCreatePreEvent;
|
||||
use App\Event\CustomerDeleteEvent;
|
||||
use App\Event\CustomerMetaDefinitionEvent;
|
||||
use App\Event\CustomerUpdatePostEvent;
|
||||
use App\Event\CustomerUpdatePreEvent;
|
||||
@@ -74,12 +73,6 @@ final class CustomerService
|
||||
return $customer;
|
||||
}
|
||||
|
||||
public function deleteCustomer(Customer $customer): void
|
||||
{
|
||||
$this->dispatcher->dispatch(new CustomerDeleteEvent($customer));
|
||||
$this->repository->deleteCustomer($customer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $groups
|
||||
* @throws ValidationFailedException
|
||||
|
||||
@@ -331,9 +331,6 @@ final class Configuration implements ConfigurationInterface
|
||||
->booleanNode('require_activity')
|
||||
->defaultTrue()
|
||||
->end()
|
||||
->booleanNode('break_time_active')
|
||||
->defaultFalse()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
|
||||
@@ -24,7 +24,7 @@ final class UTCDateTimeImmutableType extends DateTimeImmutableType
|
||||
/**
|
||||
* @param T $value
|
||||
* @return (T is null ? null : string)
|
||||
* @template T
|
||||
* @template T<\DateTimeImmutable>
|
||||
* @throws ConversionException
|
||||
*/
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
|
||||
|
||||
@@ -25,7 +25,7 @@ final class UTCDateTimeType extends DateTimeType
|
||||
* @param T $value
|
||||
* @param AbstractPlatform $platform
|
||||
* @return (T is null ? null : string)
|
||||
* @template T
|
||||
* @template T<\DateTime>
|
||||
* @throws ConversionException
|
||||
*/
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
|
||||
|
||||
@@ -9,13 +9,12 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AccessTokenRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_access_token')]
|
||||
#[ORM\Entity(repositoryClass: AccessTokenRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\AccessTokenRepository')]
|
||||
#[ORM\UniqueConstraint(columns: ['token'])]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(fields: ['token'])]
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace App\Entity;
|
||||
use App\Doctrine\Behavior\CreatedAt;
|
||||
use App\Doctrine\Behavior\CreatedTrait;
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -25,7 +24,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Index(columns: ['visible', 'project_id'])]
|
||||
#[ORM\Index(columns: ['visible', 'project_id', 'name'])]
|
||||
#[ORM\Index(columns: ['visible', 'name'])]
|
||||
#[ORM\Entity(repositoryClass: ActivityRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Serializer\VirtualProperty('ProjectName', exp: 'object.getProject() === null ? null : object.getProject().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Activity'])])]
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ActivityRateRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -17,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_activities_rates')]
|
||||
#[ORM\UniqueConstraint(columns: ['user_id', 'activity_id'])]
|
||||
#[ORM\Entity(repositoryClass: ActivityRateRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRateRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(['user', 'activity'], ignoreNull: false)]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\BookmarkRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_bookmarks')]
|
||||
#[ORM\UniqueConstraint(columns: ['user_id', 'name'])]
|
||||
#[ORM\Entity(repositoryClass: BookmarkRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\BookmarkRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(fields: ['user', 'name'])]
|
||||
class Bookmark
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_configuration')]
|
||||
#[ORM\UniqueConstraint(columns: ['name'])]
|
||||
#[ORM\Entity(repositoryClass: ConfigurationRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ConfigurationRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
class Configuration
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace App\Entity;
|
||||
use App\Doctrine\Behavior\CreatedAt;
|
||||
use App\Doctrine\Behavior\CreatedTrait;
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -23,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_customers')]
|
||||
#[ORM\Index(columns: ['visible'])]
|
||||
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\CustomerRateRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -17,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_customers_rates')]
|
||||
#[ORM\UniqueConstraint(columns: ['user_id', 'customer_id'])]
|
||||
#[ORM\Entity(repositoryClass: CustomerRateRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRateRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(['user', 'customer'], ignoreNull: false)]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
|
||||
@@ -11,19 +11,17 @@ namespace App\Entity;
|
||||
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_invoices')]
|
||||
#[ORM\UniqueConstraint(columns: ['invoice_number'])]
|
||||
#[ORM\UniqueConstraint(columns: ['invoice_filename'])]
|
||||
#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('invoiceNumber')]
|
||||
#[UniqueEntity('invoiceFilename')]
|
||||
@@ -67,14 +65,12 @@ class Invoice implements EntityWithMetaFields
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[OA\Property(ref: '#/components/schemas/Customer')]
|
||||
private ?Customer $customer = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[OA\Property(ref: '#/components/schemas/User')]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_invoice_templates')]
|
||||
#[ORM\UniqueConstraint(columns: ['name'])]
|
||||
#[ORM\Entity(repositoryClass: InvoiceTemplateRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceTemplateRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
class InvoiceTemplate
|
||||
|
||||
@@ -109,14 +109,16 @@ trait MetaTableTypeTrait
|
||||
|
||||
// unchecked checkboxes / false bool would save an empty string in the database
|
||||
// those cannot be searched in the database
|
||||
switch ($this->type) {
|
||||
case YesNoType::class:
|
||||
case CheckboxType::class:
|
||||
if ($value === false || $value === '' || !\is_scalar($value)) {
|
||||
$value = 0;
|
||||
} else {
|
||||
$value = 1;
|
||||
}
|
||||
if (null !== $value) {
|
||||
switch ($this->type) {
|
||||
case YesNoType::class:
|
||||
case CheckboxType::class:
|
||||
if (!\is_int($value) && !\is_bool($value) && !\is_string($value)) {
|
||||
throw new \InvalidArgumentException('Failed converting meta-field bool value');
|
||||
} else {
|
||||
$value = (string) $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace App\Entity;
|
||||
use App\Doctrine\Behavior\CreatedAt;
|
||||
use App\Doctrine\Behavior\CreatedTrait;
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -24,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Table(name: 'kimai2_projects')]
|
||||
#[ORM\Index(columns: ['customer_id', 'visible', 'name'])]
|
||||
#[ORM\Index(columns: ['customer_id', 'visible', 'id'])]
|
||||
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Serializer\VirtualProperty('CustomerName', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Project'])])]
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ProjectRateRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -17,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_projects_rates')]
|
||||
#[ORM\UniqueConstraint(columns: ['user_id', 'project_id'])]
|
||||
#[ORM\Entity(repositoryClass: ProjectRateRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRateRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(['user', 'project'], ignoreNull: false)]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\RoleRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_roles')]
|
||||
#[ORM\UniqueConstraint(name: 'roles_name', columns: ['name'])]
|
||||
#[ORM\Entity(repositoryClass: RoleRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\RoleRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
class Role
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\RolePermissionRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_roles_permissions')]
|
||||
#[ORM\UniqueConstraint(name: 'role_permission', columns: ['role_id', 'permission'])]
|
||||
#[ORM\Entity(repositoryClass: RolePermissionRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\RolePermissionRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(['role', 'permission'])]
|
||||
class RolePermission
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\TagRepository;
|
||||
use App\Utils\Color;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
@@ -18,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_tags')]
|
||||
#[ORM\UniqueConstraint(columns: ['name'])]
|
||||
#[ORM\Entity(repositoryClass: TagRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\TagRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
|
||||
@@ -178,13 +178,13 @@ class Team
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<User>
|
||||
* @return User[]
|
||||
*/
|
||||
public function getTeamleads(): array
|
||||
{
|
||||
$leads = [];
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->isTeamlead() && $member->getUser() !== null) {
|
||||
if ($member->isTeamlead()) {
|
||||
$leads[] = $member->getUser();
|
||||
}
|
||||
}
|
||||
@@ -273,15 +273,13 @@ class Team
|
||||
/**
|
||||
* Returns all users in the team, both teamlead and normal member.
|
||||
*
|
||||
* @return list<User>
|
||||
* @return User[]
|
||||
*/
|
||||
public function getUsers(): array
|
||||
{
|
||||
$users = [];
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->getUser() !== null) {
|
||||
$users[] = $member->getUser();
|
||||
}
|
||||
$users[] = $member->getUser();
|
||||
}
|
||||
|
||||
return $users;
|
||||
|
||||
@@ -11,7 +11,6 @@ namespace App\Entity;
|
||||
|
||||
use App\Doctrine\Behavior\ModifiedAt;
|
||||
use App\Doctrine\Behavior\ModifiedTrait;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
@@ -38,7 +37,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Index(columns: ['end_time', 'user', 'start_time'], name: 'IDX_TIMESHEET_TICKTAC')]
|
||||
#[ORM\Index(columns: ['user', 'project_id', 'activity_id'], name: 'IDX_TIMESHEET_RECENT_ACTIVITIES')]
|
||||
#[ORM\Index(columns: ['user', 'id', 'duration'], name: 'IDX_TIMESHEET_RESULT_STATS')]
|
||||
#[ORM\Entity(repositoryClass: TimesheetRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\TimesheetRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
@@ -625,9 +624,11 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function createCopy(): Timesheet
|
||||
public function createCopy(?Timesheet $timesheet = null): Timesheet
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
if (null === $timesheet) {
|
||||
$timesheet = new Timesheet();
|
||||
}
|
||||
|
||||
$values = get_object_vars($this);
|
||||
foreach ($values as $k => $v) {
|
||||
@@ -638,9 +639,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
|
||||
/** @var TimesheetMeta $meta */
|
||||
foreach ($this->meta as $meta) {
|
||||
$tmp = clone $meta;
|
||||
$tmp->setEntity($timesheet);
|
||||
$timesheet->setMetaField($tmp);
|
||||
$timesheet->setMetaField(clone $meta);
|
||||
}
|
||||
|
||||
$timesheet->tags = new ArrayCollection();
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Utils\StringHelper;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use App\WorkingTime\Mode\WorkingTimeModeNone;
|
||||
@@ -34,7 +33,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Table(name: 'kimai2_users')]
|
||||
#[ORM\UniqueConstraint(columns: ['username'])]
|
||||
#[ORM\UniqueConstraint(columns: ['email'])]
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\UserRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('username')]
|
||||
#[UniqueEntity('email')]
|
||||
@@ -753,6 +752,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* This method should not be called by plugins and returns true on success or false on a failure.
|
||||
*
|
||||
* @internal immutable property that cannot be set by plugins
|
||||
* @param bool $canSeeAllData
|
||||
* @return bool
|
||||
* @throws Exception
|
||||
*/
|
||||
public function initCanSeeAllData(bool $canSeeAllData): bool
|
||||
{
|
||||
|
||||
@@ -150,26 +150,11 @@ class UserPreference
|
||||
* integer, float, string, boolean or null
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return UserPreference
|
||||
*/
|
||||
public function setValue($value): UserPreference
|
||||
{
|
||||
// unchecked checkboxes / false bool would save an empty string in the database
|
||||
// those cannot be searched in the database
|
||||
switch ($this->type) {
|
||||
case YesNoType::class:
|
||||
case CheckboxType::class:
|
||||
if ($value === false || $value === '' || !\is_scalar($value)) {
|
||||
$value = 0;
|
||||
} else {
|
||||
$value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
$this->value = $value;
|
||||
} elseif (\is_scalar($value)) {
|
||||
$this->value = (string) $value;
|
||||
}
|
||||
$this->value = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -1,17 +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\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before a activity will be deleted.
|
||||
*/
|
||||
final class ActivityDeleteEvent extends AbstractActivityEvent
|
||||
{
|
||||
}
|
||||
@@ -1,17 +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\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before a customer will be deleted.
|
||||
*/
|
||||
final class CustomerDeleteEvent extends AbstractCustomerEvent
|
||||
{
|
||||
}
|
||||
@@ -157,7 +157,7 @@ class PageActionsEvent extends ThemeEvent
|
||||
|
||||
public function addQuickImport(string $url): void
|
||||
{
|
||||
$this->addAction('import', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'import', 'icon' => 'upload']);
|
||||
$this->addAction('import', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'import', 'icon' => 'upload', 'translation_domain' => 'actions']);
|
||||
}
|
||||
|
||||
public function addQuickExport(string $url): void
|
||||
@@ -172,7 +172,7 @@ class PageActionsEvent extends ThemeEvent
|
||||
|
||||
public function addEdit(string $url, bool $modal = true, string $class = ''): void
|
||||
{
|
||||
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'title' => 'edit']);
|
||||
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'translation_domain' => 'actions', 'title' => 'edit']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,20 +180,20 @@ class PageActionsEvent extends ThemeEvent
|
||||
*/
|
||||
public function addSettings(string $url): void
|
||||
{
|
||||
$this->addAction('settings', ['url' => $url, 'class' => 'modal-ajax-form', 'title' => 'settings', 'accesskey' => 'h']);
|
||||
$this->addAction('settings', ['url' => $url, 'class' => 'modal-ajax-form', 'title' => 'settings', 'translation_domain' => 'actions', 'accesskey' => 'h']);
|
||||
}
|
||||
|
||||
public function addConfig(string $url): void
|
||||
{
|
||||
$this->addAction('settings', ['url' => $url, 'title' => 'settings']);
|
||||
$this->addAction('settings', ['url' => $url, 'title' => 'settings', 'translation_domain' => 'actions']);
|
||||
}
|
||||
|
||||
public function addDelete(string $url, bool $remoteConfirm = true): void
|
||||
{
|
||||
if ($remoteConfirm) {
|
||||
$this->addAction('trash', ['url' => $url, 'class' => 'modal-ajax-form text-red', 'title' => 'trash']);
|
||||
$this->addAction('trash', ['url' => $url, 'class' => 'modal-ajax-form text-red', 'translation_domain' => 'actions', 'title' => 'trash']);
|
||||
} else {
|
||||
$this->addAction('trash', ['url' => $url, 'class' => 'confirmation-link text-red', 'attr' => ['data-question' => 'confirm.delete'], 'title' => 'trash']);
|
||||
$this->addAction('trash', ['url' => $url, 'class' => 'confirmation-link text-red', 'attr' => ['data-question' => 'confirm.delete'], 'translation_domain' => 'actions', 'title' => 'trash']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +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\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before a project will be deleted.
|
||||
*/
|
||||
final class ProjectDeleteEvent extends AbstractProjectEvent
|
||||
{
|
||||
}
|
||||
@@ -21,7 +21,7 @@ abstract class AbstractActionsSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(private readonly AuthorizationCheckerInterface $auth, private readonly UrlGeneratorInterface $urlGenerator)
|
||||
public function __construct(private AuthorizationCheckerInterface $auth, private UrlGeneratorInterface $urlGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if (!$timesheet->isRunning() && $this->isGranted('start', $timesheet)) {
|
||||
$event->addAction('repeat', ['title' => 'repeat', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
|
||||
$event->addAction('repeat', ['title' => 'repeat', 'translation_domain' => 'actions', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $timesheet)) {
|
||||
@@ -38,7 +38,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('duplicate', $timesheet)) {
|
||||
$class = $event->isView('edit') ? '' : 'modal-ajax-form';
|
||||
$event->addAction('copy', ['title' => 'copy', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class . ' dd-ts-duplicate']);
|
||||
$event->addAction('copy', ['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class . ' dd-ts-duplicate']);
|
||||
}
|
||||
|
||||
if ($event->countActions() > 0) {
|
||||
@@ -49,6 +49,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
$event->addAction('trash', [
|
||||
'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]),
|
||||
'class' => 'api-link text-red dd-ts-trash',
|
||||
'translation_domain' => 'actions',
|
||||
'attr' => [
|
||||
'data-event' => 'kimai.timesheetDelete',
|
||||
'data-method' => 'DELETE',
|
||||
|
||||
@@ -31,7 +31,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if (!$event->isView('activity_details') && $this->isGranted('view', $activity)) {
|
||||
$event->addAction('details', ['title' => 'details', 'url' => $this->path('activity_details', ['id' => $activity->getId()])]);
|
||||
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('activity_details', ['id' => $activity->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $activity)) {
|
||||
@@ -40,7 +40,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('permissions', $activity)) {
|
||||
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_activity_permissions', ['id' => $activity->getId()]), 'class' => $class]);
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity_permissions', ['id' => $activity->getId()]), 'class' => $class]);
|
||||
}
|
||||
|
||||
if ($event->countActions() > 0) {
|
||||
@@ -53,7 +53,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
$parameters['customers[]'] = $activity->getProject()->getCustomer()->getId();
|
||||
$parameters['projects[]'] = $activity->getProject()->getId();
|
||||
}
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', $parameters)]);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', $parameters)]);
|
||||
}
|
||||
|
||||
if ($event->hasSubmenu('filter')) {
|
||||
@@ -65,7 +65,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
if (!$activity->isGlobal()) {
|
||||
$parameters['project'] = $activity->getProject()->getId();
|
||||
}
|
||||
$event->addAction('create-timesheet', ['title' => 'create-timesheet', 'icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
|
||||
$event->addAction('create-timesheet', ['title' => 'create-timesheet', 'translation_domain' => 'actions', 'icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
|
||||
}
|
||||
|
||||
if (($event->isIndexView() || $event->isView('project_details')) && $this->isGranted('delete', $activity)) {
|
||||
|
||||
@@ -34,7 +34,7 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
|
||||
$isListingView = $event->isIndexView() || $event->isCustomView();
|
||||
|
||||
if (!$event->isView('customer_details') && $canView) {
|
||||
$event->addAction('details', ['title' => 'details', 'url' => $this->path('customer_details', ['id' => $customer->getId()])]);
|
||||
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('customer_details', ['id' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $customer)) {
|
||||
@@ -43,7 +43,7 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('permissions', $customer)) {
|
||||
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_customer_permissions', ['id' => $customer->getId()]), 'class' => $class]);
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_customer_permissions', ['id' => $customer->getId()]), 'class' => $class]);
|
||||
}
|
||||
|
||||
if ($isListingView) {
|
||||
@@ -61,15 +61,15 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_project') || $this->isGranted('view_teamlead_project') || $this->isGranted('view_team_project')) {
|
||||
$event->addActionToSubmenu('filter', 'project', ['title' => 'project.filter', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'project', ['title' => 'project.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_activity')) {
|
||||
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($event->hasSubmenu('filter')) {
|
||||
|
||||
@@ -56,7 +56,7 @@ final class InvoiceSubscriber extends AbstractActionsSubscriber
|
||||
$allowDelete = $this->isGranted('delete_invoice');
|
||||
if (!$invoice->isCanceled()) {
|
||||
$id = $allowDelete ? 'invoice.cancel' : 'trash';
|
||||
$event->addAction($id, ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]), 'title' => 'invoice.cancel']);
|
||||
$event->addAction($id, ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]), 'title' => 'invoice.cancel', 'translation_domain' => 'actions']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('delete_invoice')) {
|
||||
|
||||
@@ -34,7 +34,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
$isListingView = $event->isIndexView() || $event->isCustomView();
|
||||
|
||||
if (!$event->isView('project_details') && $this->isGranted('view', $project)) {
|
||||
$event->addAction('details', ['title' => 'details', 'url' => $this->path('project_details', ['id' => $project->getId()])]);
|
||||
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('project_details', ['id' => $project->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $project)) {
|
||||
@@ -43,7 +43,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('permissions', $project)) {
|
||||
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_project_permissions', ['id' => $project->getId()]), 'class' => $class]);
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_project_permissions', ['id' => $project->getId()]), 'class' => $class]);
|
||||
}
|
||||
|
||||
if ($event->countActions() > 0) {
|
||||
@@ -51,11 +51,11 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_activity')) {
|
||||
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('create_export')) {
|
||||
@@ -79,7 +79,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
if (\array_key_exists('token', $payload) && $this->isGranted('edit', $project) && $this->isGranted('create_project')) {
|
||||
$event->addAction(
|
||||
'copy',
|
||||
['title' => 'copy', 'url' => $this->path('admin_project_duplicate', ['id' => $project->getId(), 'token' => $payload['token']])]
|
||||
['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path('admin_project_duplicate', ['id' => $project->getId(), 'token' => $payload['token']])]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,13 +46,14 @@ final class TagSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['tags' => $name])]);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['tags' => $name])]);
|
||||
}
|
||||
|
||||
if ($event->isIndexView() && $this->isGranted('delete_tag')) {
|
||||
$event->addAction('trash', [
|
||||
'url' => $this->path('delete_tag', ['id' => $id]),
|
||||
'class' => 'api-link text-red',
|
||||
'translation_domain' => 'actions',
|
||||
'attr' => [
|
||||
'data-event' => 'kimai.tagDelete kimai.tagUpdate',
|
||||
'data-method' => 'DELETE',
|
||||
|
||||
@@ -36,7 +36,7 @@ final class TeamSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('create_team')) {
|
||||
$event->addAction('copy', ['url' => $this->path('team_duplicate', ['id' => $team->getId()]), 'title' => 'copy', 'class' => 'modal-ajax-form']);
|
||||
$event->addAction('copy', ['url' => $this->path('team_duplicate', ['id' => $team->getId()]), 'title' => 'copy', 'translation_domain' => 'actions', 'class' => 'modal-ajax-form']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ final class TeamSubscriber extends AbstractActionsSubscriber
|
||||
$event->addAction('trash', [
|
||||
'url' => $this->path('delete_team', ['id' => $team->getId()]),
|
||||
'class' => 'api-link text-red',
|
||||
'translation_domain' => 'actions',
|
||||
'attr' => [
|
||||
'data-event' => 'kimai.teamDelete kimai.teamUpdate',
|
||||
'data-method' => 'DELETE',
|
||||
|
||||
@@ -22,7 +22,7 @@ final class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
|
||||
{
|
||||
if ($this->isGranted('create_other_timesheet')) {
|
||||
$event->addAction('create', ['title' => 'create', 'url' => $this->path('admin_timesheet_create'), 'class' => 'create-ts modal-ajax-form']);
|
||||
$event->addAction('multi-user', ['title' => 'create-timesheet-multiuser', 'url' => $this->path('admin_timesheet_create_multiuser'), 'class' => 'create-ts-mu modal-ajax-form', 'icon' => 'fas fa-user-plus']);
|
||||
$event->addAction('multi-user', ['title' => 'create-timesheet-multiuser', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet_create_multiuser'), 'class' => 'create-ts-mu modal-ajax-form', 'icon' => 'fas fa-user-plus']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('export_other_timesheet')) {
|
||||
|
||||
@@ -31,7 +31,7 @@ final class UserFormsSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $user)) {
|
||||
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats']);
|
||||
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats', 'translation_domain' => 'actions']);
|
||||
}
|
||||
if ($this->isGranted('preferences', $user)) {
|
||||
$event->addConfig($this->path('user_profile_preferences', ['username' => $user->getUserIdentifier()]));
|
||||
|
||||
@@ -40,7 +40,7 @@ final class UserSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('view', $user)) {
|
||||
$event->addAction('profile-stats', ['icon' => 'avatar', 'url' => $this->path('user_profile', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats']);
|
||||
$event->addAction('profile-stats', ['icon' => 'avatar', 'url' => $this->path('user_profile', ['username' => $user->getUserIdentifier()]), 'translation_domain' => 'actions', 'title' => 'profile-stats']);
|
||||
$event->addDivider();
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ final class UserSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($user->isEnabled() && $this->isGranted('view_other_timesheet')) {
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['url' => $this->path('admin_timesheet', ['users[]' => $user->getId()]), 'title' => 'timesheet.filter']);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['url' => $this->path('admin_timesheet', ['users[]' => $user->getId()]), 'title' => 'timesheet.filter', 'translation_domain' => 'actions']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_team')) {
|
||||
|
||||
@@ -28,7 +28,7 @@ final class AnnotationExtractor implements ExtractorInterface
|
||||
|
||||
/**
|
||||
* @param string $value
|
||||
* @return list<ColumnDefinition>
|
||||
* @return ColumnDefinition[]
|
||||
* @throws ExtractorException
|
||||
*/
|
||||
public function extract($value): array
|
||||
@@ -154,7 +154,11 @@ final class AnnotationExtractor implements ExtractorInterface
|
||||
}
|
||||
}
|
||||
|
||||
$columns = array_filter($columns, function ($value) { return $value !== null; });
|
||||
foreach ($columns as $name => $definition) {
|
||||
if (null === $definition) {
|
||||
unset($columns[$name]);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($columns);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,11 @@ namespace App\Export;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use App\Repository\Query\TimesheetQueryHint;
|
||||
use App\Repository\TimesheetRepository;
|
||||
|
||||
final class TimesheetExportRepository implements ExportRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly TimesheetRepository $repository)
|
||||
public function __construct(private TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -42,12 +41,7 @@ final class TimesheetExportRepository implements ExportRepositoryInterface
|
||||
|
||||
public function getExportItemsForQuery(ExportQuery $query): iterable
|
||||
{
|
||||
$query->addQueryHint(TimesheetQueryHint::CUSTOMER_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::PROJECT_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::ACTIVITY_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::USER_PREFERENCES);
|
||||
|
||||
return $this->repository->getTimesheetResult($query)->getResults();
|
||||
return $this->repository->getTimesheetsForQuery($query, true);
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
|
||||
@@ -29,7 +29,6 @@ final class MultiUpdateTable extends AbstractType
|
||||
|
||||
$builder->add('entities', HiddenType::class, [
|
||||
'required' => false,
|
||||
'attr' => ['class' => 'multi_update_ids']
|
||||
]);
|
||||
|
||||
$builder->get('entities')->addModelTransformer(
|
||||
|
||||
@@ -38,8 +38,6 @@ final class ActivityType extends AbstractType
|
||||
public function groupBy(Activity $activity, $key, $index): string
|
||||
{
|
||||
if (null === $activity->getProject()) {
|
||||
// this creates a optgroup with an empty title. previously this was null, which resulted in options without optgroup
|
||||
// and those are ordered by Tomselect at the top - so globals always came first, see #4674
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ class Kernel extends BaseKernel
|
||||
throw new \Exception(\sprintf('Bundle "%s" does not implement %s, which is not supported since 2.0.', $bundleName, PluginInterface::class));
|
||||
}
|
||||
|
||||
$meta = PluginMetadata::createFromPath($fullPath);
|
||||
$meta = new PluginMetadata($fullPath);
|
||||
|
||||
if ($meta->getKimaiVersion() > Constants::VERSION_ID) {
|
||||
throw new \Exception(\sprintf('Bundle "%s" requires minimum Kimai version %s, but yours is lower: %s (%s). Please update Kimai or use a lower Plugin version.', $bundleName, $meta->getKimaiVersion(), Constants::VERSION, Constants::VERSION_ID));
|
||||
@@ -169,8 +169,11 @@ class Kernel extends BaseKernel
|
||||
$routes->import($configDir . '/routes/' . $this->environment . '/*.yaml');
|
||||
}
|
||||
|
||||
// load application routes
|
||||
$routes->import($configDir . '/routes.yaml');
|
||||
|
||||
foreach ($this->getBundles() as $bundle) {
|
||||
if ($bundle instanceof PluginInterface || str_contains(\get_class($bundle), 'KimaiPlugin\\')) {
|
||||
if (str_contains(\get_class($bundle), 'KimaiPlugin\\')) {
|
||||
if (is_dir($bundle->getPath() . '/Resources/config/')) {
|
||||
$routes->import($bundle->getPath() . '/Resources/config/routes' . self::CONFIG_EXTS);
|
||||
} elseif (is_dir($bundle->getPath() . '/config/')) {
|
||||
@@ -178,8 +181,5 @@ class Kernel extends BaseKernel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// load application routes as last one, so bundles cannot override application ones
|
||||
$routes->import($configDir . '/routes.yaml');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ use App\Configuration\MailConfiguration;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Mailer\Envelope;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Mime\RawMessage;
|
||||
|
||||
@@ -32,11 +31,7 @@ final class KimaiMailer implements MailerInterface
|
||||
}
|
||||
|
||||
if (\count($message->getFrom()) === 0) {
|
||||
$fallback = $this->configuration->getFromAddress();
|
||||
if ($fallback === null) {
|
||||
throw new \RuntimeException('Missing email "from" address');
|
||||
}
|
||||
$message->from(new Address($fallback, 'Kimai'));
|
||||
$message->from($this->configuration->getFromAddress());
|
||||
}
|
||||
|
||||
$this->mailer->send($message);
|
||||
|
||||
@@ -17,7 +17,7 @@ use DateTimeInterface;
|
||||
final class MonthlyStatistic implements DateStatisticInterface
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<int<1, 12>, StatisticDate>>
|
||||
* @var array<string|int, array<int<1, 12>, StatisticDate>>
|
||||
*/
|
||||
private array $years = [];
|
||||
private DateTimeInterface $begin;
|
||||
@@ -50,7 +50,7 @@ final class MonthlyStatistic implements DateStatisticInterface
|
||||
$day = (int) $begin->format('d');
|
||||
|
||||
while ($tmp < $this->end) {
|
||||
$curYear = (string) $tmp->format('Y');
|
||||
$curYear = $tmp->format('Y');
|
||||
if (!isset($years[$curYear])) {
|
||||
$year = [];
|
||||
for ($i = 1; $i < 13; $i++) {
|
||||
@@ -67,7 +67,7 @@ final class MonthlyStatistic implements DateStatisticInterface
|
||||
}
|
||||
$tmp->modify('+1 month');
|
||||
}
|
||||
$this->years = $years; // @phpstan-ignore assign.propertyType
|
||||
$this->years = $years;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,17 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
||||
|
||||
abstract class AbstractPluginExtension extends Extension
|
||||
{
|
||||
protected function registerIcon(ContainerBuilder $container, string $name, string $icon): void
|
||||
{
|
||||
$container->setParameter(
|
||||
'tabler_bundle.icons',
|
||||
array_merge(
|
||||
$container->getParameter('tabler_bundle.icons'),
|
||||
[$name => $icon]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected function registerBundleConfiguration(ContainerBuilder $container, array $configs): void
|
||||
{
|
||||
$bundleConfig = [$this->getAlias() => $configs];
|
||||
|
||||
@@ -1,30 +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\Plugin;
|
||||
|
||||
/**
|
||||
* @internaö
|
||||
*/
|
||||
final class Package
|
||||
{
|
||||
public function __construct(private readonly \SplFileInfo $packageFile, private readonly PluginMetadata $pluginMetadata)
|
||||
{
|
||||
}
|
||||
|
||||
public function getPackageFile(): \SplFileInfo
|
||||
{
|
||||
return $this->packageFile;
|
||||
}
|
||||
|
||||
public function getMetadata(): PluginMetadata
|
||||
{
|
||||
return $this->pluginMetadata;
|
||||
}
|
||||
}
|
||||
@@ -1,155 +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\Plugin;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class PackageManager
|
||||
{
|
||||
public const PACKAGE_DIR = 'var/packages';
|
||||
|
||||
public function __construct(private readonly string $projectDirectory)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Package[]
|
||||
*/
|
||||
public function getAvailablePackages(): array
|
||||
{
|
||||
return $this->findAvailablePackages($this->projectDirectory . '/' . self::PACKAGE_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from Composer\Repository\ArtifactRepository
|
||||
* @see https://github.com/composer/composer/blob/main/src/Composer/Repository/ArtifactRepository.php
|
||||
*
|
||||
* @return Package[]
|
||||
*/
|
||||
private function findAvailablePackages(string $path): array
|
||||
{
|
||||
$packages = [];
|
||||
|
||||
$directory = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS);
|
||||
$iterator = new \RecursiveIteratorIterator($directory);
|
||||
$regex = new \RegexIterator($iterator, '/^.+\.zip$/i');
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($regex as $file) {
|
||||
if (!$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$package = $this->getComposerJson($file->getPathname());
|
||||
if ($package === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = json_decode($package, true);
|
||||
if (\JSON_ERROR_NONE !== json_last_error() || !\is_array($content)) {
|
||||
throw new \RuntimeException('Failed to parse composer.json file in: ' . $file->getPathname());
|
||||
}
|
||||
|
||||
$packages[] = new Package($file, PluginMetadata::createFromArray($content));
|
||||
}
|
||||
|
||||
return $packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from Composer\Util\Zip
|
||||
* @see https://github.com/composer/composer/blob/main/src/Composer/Util/Zip.php
|
||||
*/
|
||||
private function getComposerJson(string $pathToZip): ?string
|
||||
{
|
||||
if (!\extension_loaded('zip')) {
|
||||
throw new \RuntimeException('The Zip Util requires PHP\'s zip extension');
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($pathToZip) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (0 === $zip->numFiles) {
|
||||
$zip->close();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$foundFileIndex = self::locateFile($zip, 'composer.json');
|
||||
|
||||
$content = null;
|
||||
$configurationFileName = $zip->getNameIndex($foundFileIndex);
|
||||
if ($configurationFileName !== false) {
|
||||
$stream = $zip->getStream($configurationFileName);
|
||||
|
||||
if (false !== $stream) {
|
||||
$content = stream_get_contents($stream);
|
||||
if ($content === false) {
|
||||
$content = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from Composer\Util\Zip
|
||||
* @see https://github.com/composer/composer/blob/main/src/Composer/Util/Zip.php
|
||||
*/
|
||||
private static function locateFile(\ZipArchive $zip, string $filename): int
|
||||
{
|
||||
// return root composer.json if it is there and is a file
|
||||
if (false !== ($index = $zip->locateName($filename)) && $zip->getFromIndex($index) !== false) {
|
||||
return $index;
|
||||
}
|
||||
|
||||
$topLevelPaths = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if ($name === false) {
|
||||
continue;
|
||||
}
|
||||
$dirname = \dirname($name);
|
||||
|
||||
// ignore OSX specific resource fork folder
|
||||
if (strpos($name, '__MACOSX') !== false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// handle archives with proper TOC
|
||||
if ($dirname === '.') {
|
||||
$topLevelPaths[$name] = true;
|
||||
if (\count($topLevelPaths) > 1) {
|
||||
throw new \RuntimeException('Archive has more than one top level directories, and no composer.json was found on the top level, so it\'s an invalid archive. Top level paths found were: ' . implode(',', array_keys($topLevelPaths)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// handle archives which do not have a TOC record for the directory itself
|
||||
if (false === strpos($dirname, '\\') && false === strpos($dirname, '/')) {
|
||||
$topLevelPaths[$dirname . '/'] = true;
|
||||
if (\count($topLevelPaths) > 1) {
|
||||
throw new \RuntimeException('Archive has more than one top level directories, and no composer.json was found on the top level, so it\'s an invalid archive. Top level paths found were: ' . implode(',', array_keys($topLevelPaths)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($topLevelPaths && false !== ($index = $zip->locateName(key($topLevelPaths) . $filename)) && $zip->getFromIndex($index) !== false) {
|
||||
return $index;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('No composer.json found either at the top level or within the topmost directory');
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ final class Plugin
|
||||
public function getMetadata(): PluginMetadata
|
||||
{
|
||||
if ($this->metadata === null) {
|
||||
$this->metadata = PluginMetadata::createFromPath($this->getPath());
|
||||
$this->metadata = new PluginMetadata($this->getPath());
|
||||
}
|
||||
|
||||
return $this->metadata;
|
||||
@@ -33,7 +33,12 @@ final class Plugin
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->getMetadata()->getName();
|
||||
$meta = $this->getMetadata();
|
||||
if ($meta->getName() !== null) {
|
||||
return $meta->getName();
|
||||
}
|
||||
|
||||
return $this->getId();
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
|
||||
@@ -13,79 +13,57 @@ use App\Constants;
|
||||
|
||||
class PluginMetadata
|
||||
{
|
||||
private string $package;
|
||||
private string $version;
|
||||
private int $kimaiVersion;
|
||||
private string $homepage;
|
||||
private string $description;
|
||||
private string $name;
|
||||
private ?string $version = null;
|
||||
private ?int $kimaiVersion = null;
|
||||
private ?string $homepage = null;
|
||||
private ?string $description = null;
|
||||
private ?string $name = null;
|
||||
|
||||
public static function createFromPath(string $path): self
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct(string $path)
|
||||
{
|
||||
if (!is_dir($path) || !is_readable($path)) {
|
||||
throw new \Exception(\sprintf('Bundle directory "%s" cannot be accessed.', $path));
|
||||
}
|
||||
|
||||
$pluginName = basename($path);
|
||||
$composer = $path . '/composer.json';
|
||||
|
||||
if (!file_exists($composer) || !is_readable($composer)) {
|
||||
throw new \Exception('Bundle does not ship composer.json, which is required since 2.0.');
|
||||
throw new \Exception(\sprintf('Bundle "%s" does not ship composer.json, which is required since 2.0.', $pluginName));
|
||||
}
|
||||
|
||||
/** @var array<mixed>|null $json */
|
||||
$json = json_decode(file_get_contents($composer), true);
|
||||
|
||||
if ($json === null) {
|
||||
throw new \Exception('Could not parse composer.json, invalid JSON?');
|
||||
}
|
||||
|
||||
return self::createFromArray($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $json
|
||||
*/
|
||||
public static function createFromArray(array $json): self
|
||||
{
|
||||
if (!\array_key_exists('extra', $json)) {
|
||||
throw new \Exception('Bundle "%s" does not define an "extra" node in composer.json, which is required since 2.0.');
|
||||
throw new \Exception(\sprintf('Bundle "%s" does not define an "extra" node in composer.json, which is required since 2.0.', $pluginName));
|
||||
}
|
||||
|
||||
if (!\array_key_exists('kimai', $json['extra'])) {
|
||||
throw new \Exception('Bundle does not define the "extra.kimai" node in composer.json, which is required since 2.0.');
|
||||
throw new \Exception(\sprintf('Bundle "%s" does not define the "extra.kimai" node in composer.json, which is required since 2.0.', $pluginName));
|
||||
}
|
||||
|
||||
if (!\array_key_exists('require', $json['extra']['kimai'])) {
|
||||
throw new \Exception('Bundle does not define the minimum Kimai version in "extra.kimai.required" in composer.json, which is required since 2.0.');
|
||||
throw new \Exception(\sprintf('Bundle "%s" does not define the minimum Kimai version in "extra.kimai.required" in composer.json, which is required since 2.0.', $pluginName));
|
||||
}
|
||||
|
||||
if (!\array_key_exists('name', $json['extra']['kimai'])) {
|
||||
throw new \Exception('Bundle does not define its name in "extra.kimai.name" in composer.json, which is required since 2.0.');
|
||||
throw new \Exception(\sprintf('Bundle "%s" does not define its name in "extra.kimai.name" in composer.json, which is required since 2.0.', $pluginName));
|
||||
}
|
||||
|
||||
if (!\is_int($json['extra']['kimai']['require'])) {
|
||||
throw new \Exception('Bundle defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.');
|
||||
throw new \Exception(\sprintf('Bundle "%s" defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.', $pluginName));
|
||||
}
|
||||
|
||||
$meta = new self();
|
||||
|
||||
$meta->package = $json['name'] ?? '';
|
||||
$meta->description = $json['description'] ?? '';
|
||||
$meta->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
|
||||
$meta->name = $json['extra']['kimai']['name'];
|
||||
$meta->kimaiVersion = $json['extra']['kimai']['require'];
|
||||
$this->description = $json['description'] ?? '';
|
||||
$this->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
|
||||
$this->name = $json['extra']['kimai']['name'];
|
||||
$this->kimaiVersion = $json['extra']['kimai']['require'];
|
||||
|
||||
// the version field is required if we use composer to install a plugin via var/packages/
|
||||
$meta->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public function getPackage(): string
|
||||
{
|
||||
return $this->package;
|
||||
$this->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
@@ -93,22 +71,22 @@ class PluginMetadata
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
public function getVersion(): ?string
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function getKimaiVersion(): int
|
||||
public function getKimaiVersion(): ?int
|
||||
{
|
||||
return $this->kimaiVersion;
|
||||
}
|
||||
|
||||
public function getHomepage(): string
|
||||
public function getHomepage(): ?string
|
||||
{
|
||||
return $this->homepage;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ use App\Entity\Project;
|
||||
use App\Repository\ActivityRateRepository;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\ProjectRateRepository;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
|
||||
final class ProjectDuplicationService
|
||||
{
|
||||
@@ -54,11 +53,7 @@ final class ProjectDuplicationService
|
||||
$this->projectRateRepository->saveRate($newRate);
|
||||
}
|
||||
|
||||
$query = new ActivityQuery();
|
||||
$query->addProject($project);
|
||||
$query->setExcludeGlobals(true);
|
||||
|
||||
$allActivities = $this->activityRepository->getActivitiesForQuery($query);
|
||||
$allActivities = $this->activityRepository->findByProject($project);
|
||||
foreach ($allActivities as $activity) {
|
||||
$newActivity = clone $activity;
|
||||
$newActivity->setProject($newProject);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user