Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Papst
8a5bc3bfc1 merge base 2024-10-04 11:17:24 +02:00
Kevin Papst
0c2236d7f6 Merge branch 'refs/heads/main' into docker-ips
# Conflicts:
#	.docker/Dockerfile.kimai-base
2024-10-04 11:16:17 +02:00
Kevin Papst
a629e3d83e pass proxy values for Apache webserver 2024-09-22 16:22:03 +02:00
140 changed files with 1100 additions and 1382 deletions

View File

@@ -8,6 +8,20 @@
PassEnv DATABASE_URL PassEnv DATABASE_URL
PassEnv MAILER_URL PassEnv MAILER_URL
PassEnv TRUSTED_PROXIES PassEnv TRUSTED_PROXIES
PassEnv TRUSTED_HOSTS
SetEnvIf X-Forwarded-Proto https HTTPS=on
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s"
RemoteIPHeader X-Forwarded-For
# Define a macro to set the RemoteIPTrustedProxy directive
<Macro SetTrustedProxy PROXY>
RemoteIPTrustedProxy ${PROXY}
</Macro>
# Use the macro, passing the environment variable TRUSTED_PROXIES
Use SetTrustedProxy %{TRUSTED_PROXIES}e
<Directory "/opt/kimai/public"> <Directory "/opt/kimai/public">
Require all granted Require all granted

View File

@@ -13,19 +13,20 @@ try {
]); ]);
} catch(\Exception $ex) { } catch(\Exception $ex) {
switch ($ex->getCode()) { switch ($ex->getCode()) {
// we can immediately stop startup here and show the error message
case 1045: case 1045:
// we can immediately stop here and show the error message
echo 'Access denied (1045)'; echo 'Access denied (1045)';
die(1); die(1);
// we can immediately stop startup here and show the error message
case 1049: case 1049:
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai echo 'Unknown database (1049)';
return; die(2);
// a lot of errors share the same meaningless error code zero // a lot of errors share the same meaningless error code zero
case 0: case 0:
// this error includes the database name, so we can only search for the static part of the error message // 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) { if (stripos($ex->getMessage(), 'SQLSTATE[HY000] [1049] Unknown database') !== false) {
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai echo 'Unknown database (0-1049)';
return; die(3);
} }
switch ($ex->getMessage()) { switch ($ex->getMessage()) {
// eg. no response (fw) - the startup script should retry it a couple of times // eg. no response (fw) - the startup script should retry it a couple of times

View File

@@ -76,6 +76,7 @@ function handleStartup() {
function prepareKimai() { function prepareKimai() {
# These are idempotent, so we can run them on every start-up # 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:install
/opt/kimai/bin/console -n kimai:update
if [ ! -z "$ADMINPASS" ] && [ ! -a "$ADMINMAIL" ]; then if [ ! -z "$ADMINPASS" ] && [ ! -a "$ADMINMAIL" ]; then
/opt/kimai/bin/console kimai:user:create admin "$ADMINMAIL" ROLE_SUPER_ADMIN "$ADMINPASS" /opt/kimai/bin/console kimai:user:create admin "$ADMINMAIL" ROLE_SUPER_ADMIN "$ADMINPASS"
fi fi

18
.gitattributes vendored
View File

@@ -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

View File

@@ -1,11 +1,6 @@
name: 'Docker Build' name: 'Docker Build'
on: on:
workflow_dispatch:
inputs:
kimai_tag:
description: 'Kimai tag to build'
required: true
release: release:
types: [released] types: [released]
@@ -18,9 +13,6 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -28,24 +20,7 @@ jobs:
password: ${{secrets.DOCKERHUB_PASSWORD}} password: ${{secrets.DOCKERHUB_PASSWORD}}
- name: Determine version - name: Determine version
run: | run: echo "kimai_version=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
input="${{ github.event.inputs.kimai_tag }}"
# Determine between manual trigger and release event
if [ -z "$input" ]; then
echo "Using release tag: ${{ github.event.release.tag_name }}"
version="${{ github.event.release.tag_name }}"
else
echo "Using tag provided: $input"
version="$input"
fi
if [[ ! $version =~ ^2\.(0|[1-9]*)(0?)\.(0|[0-9]*)(0?)$ ]]; then
echo "Invalid version number: $version"
exit 1
fi
echo "kimai_version=$version" >> $GITHUB_ENV
- name: FPM image - name: FPM image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5

1
.gitignore vendored
View File

@@ -18,7 +18,6 @@
# for keeping empty directories # for keeping empty directories
/config/packages/local.yaml /config/packages/local.yaml
/config/bundles-local.php /config/bundles-local.php
/var/dev/*
/var/data/* /var/data/*
/var/cache/* /var/cache/*
/var/invoices* /var/invoices*

View File

@@ -1,16 +1,15 @@
# Contributing # Contributing
Kimai is an open source project, contributions made by the community are welcome. 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).
Send your ideas, code reviews, pull requests and feature requests to help to improve this project. Send your ideas, code reviews, pull requests and feature requests to help to improve this project.
## Pull request rules ## 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` - Apply our code-style by running `composer codestyle-fix`
- Run the static code analysis with `composer phpstan` - 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 - 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). Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).

View File

@@ -13,8 +13,8 @@
# #
# docker build --no-cache -t kimai-fpm --build-arg BASE=fpm . # docker build --no-cache -t kimai-fpm --build-arg BASE=fpm .
# docker build --no-cache -t kimai-apache --build-arg BASE=apache . # docker build --no-cache -t kimai-apache --build-arg BASE=apache .
# docker run -d --name kimai-apache-app kimai-apache # docker run -d --name kimai-apache
# docker exec -ti kimai-apache-app /bin/bash # docker exec -ti kimai-apache /bin/bash
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Official PHP images: https://hub.docker.com/_/php/ # Official PHP images: https://hub.docker.com/_/php/
# https://github.com/docker-library/docs/blob/master/php/README.md#supported-tags-and-respective-dockerfile-links # https://github.com/docker-library/docs/blob/master/php/README.md#supported-tags-and-respective-dockerfile-links
@@ -78,8 +78,8 @@ RUN apk add --no-cache \
# apache debian php extension base # apache debian php extension base
FROM php:8.3-apache-bookworm AS apache-php-ext-base FROM php:8.3-apache-bookworm AS apache-php-ext-base
RUN apt-get update && \ RUN apt-get update
apt-get install -y \ RUN apt-get install -y \
libldap2-dev \ libldap2-dev \
libicu-dev \ libicu-dev \
libpng-dev \ libpng-dev \
@@ -165,10 +165,12 @@ RUN apt-get update && \
libpng16-16 \ libpng16-16 \
libzip4 \ libzip4 \
libxslt1.1 \ libxslt1.1 \
libfreetype6 \ libfreetype6 && \
unzip && \
echo "Listen 8001" > /etc/apache2/ports.conf && \ echo "Listen 8001" > /etc/apache2/ports.conf && \
a2enmod rewrite && \ a2enmod rewrite && \
a2enmod headers && \
a2enmod remoteip && \
a2enmod macro && \
touch /use_apache touch /use_apache
COPY .docker/000-default.conf /etc/apache2/sites-available/000-default.conf COPY .docker/000-default.conf /etc/apache2/sites-available/000-default.conf
@@ -218,18 +220,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 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 KIMAI
ARG TIMEZONE ARG TIMEZONE
# the convention in the Kimai repository is: tags are always version numbers, branch names always start with a letter RUN apk add --no-cache git && \
# if the KIMAI variable starts with a number (e.g. 2.24.0) we assume its a tag, otherwise its a branch git clone --depth 1 --branch ${KIMAI} https://github.com/kimai/kimai.git /opt/kimai
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" && \ # production kimai source
tar -xpzf /opt/kimai.tar.gz -C /opt/ && \ FROM git-dev AS git-prod
mv /opt/kimai-${KIMAI} /opt/kimai WORKDIR /opt/kimai
RUN rm -r tests
########################### ###########################
# global base build # global base build
@@ -257,12 +261,13 @@ RUN ln -snf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && echo ${TIMEZONE} >
# copy startup script & DB checking script # copy startup script & DB checking script
COPY .docker/dbtest.php /dbtest.php COPY .docker/dbtest.php /dbtest.php
COPY .docker/entrypoint.sh /entrypoint.sh COPY .docker/startup.sh /startup.sh
ENV DATABASE_URL="mysql://kimai:kimai@127.0.0.1:3306/kimai?charset=utf8mb4&serverVersion=8.3" ENV DATABASE_URL="mysql://kimai:kimai@127.0.0.1:3306/kimai?charset=utf8mb4&serverVersion=8.3"
ENV APP_SECRET=change_this_to_something_unique ENV APP_SECRET=change_this_to_something_unique
# The default container name for nginx is nginx # The default container name for nginx is nginx
ENV TRUSTED_PROXIES=nginx,localhost,127.0.0.1 ENV TRUSTED_PROXIES=nginx,localhost,127.0.0.1
ENV TRUSTED_HOSTS=nginx,localhost,127.0.0.1
ENV MAILER_FROM=kimai@example.com ENV MAILER_FROM=kimai@example.com
ENV MAILER_URL=null://localhost ENV MAILER_URL=null://localhost
ENV ADMINPASS= ENV ADMINPASS=
@@ -275,7 +280,7 @@ ENV COMPOSER_ALLOW_SUPERUSER=1
VOLUME [ "/opt/kimai/var" ] VOLUME [ "/opt/kimai/var" ]
CMD [ "/entrypoint.sh" ] CMD [ "/startup.sh" ]
########################### ###########################
# final builds # final builds
@@ -284,9 +289,10 @@ CMD [ "/entrypoint.sh" ]
# development build # development build
FROM base AS dev FROM base AS dev
# copy kimai develop source # 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 COPY .docker /assets
# do the composer deps installation # do the composer deps installation
RUN echo \$PATH
RUN \ RUN \
export COMPOSER_HOME=/composer && \ export COMPOSER_HOME=/composer && \
composer --no-ansi install --working-dir=/opt/kimai --optimize-autoloader && \ composer --no-ansi install --working-dir=/opt/kimai --optimize-autoloader && \
@@ -302,7 +308,7 @@ ENV APP_ENV=dev
ENV DATABASE_URL= ENV DATABASE_URL=
ENV memory_limit=512M 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 FROM base AS prod
# copy kimai production source # copy kimai production source
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
@@ -312,7 +318,7 @@ RUN \
export COMPOSER_HOME=/composer && \ export COMPOSER_HOME=/composer && \
composer --no-ansi install --working-dir=/opt/kimai --no-dev --optimize-autoloader && \ composer --no-ansi install --working-dir=/opt/kimai --no-dev --optimize-autoloader && \
composer --no-ansi clearcache && \ composer --no-ansi clearcache && \
composer --no-ansi require --update-no-dev --working-dir=/opt/kimai laminas/laminas-ldap && \ composer --no-ansi require --working-dir=/opt/kimai laminas/laminas-ldap && \
cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini && \ cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini && \
sed -i "s/expose_php = On/expose_php = Off/g" /usr/local/etc/php/php.ini && \ sed -i "s/expose_php = On/expose_php = Off/g" /usr/local/etc/php/php.ini && \
sed -i "s/;opcache.enable=1/opcache.enable=1/g" /usr/local/etc/php/php.ini && \ sed -i "s/;opcache.enable=1/opcache.enable=1/g" /usr/local/etc/php/php.ini && \

View File

@@ -7,4 +7,5 @@ As announced in the [README](README.md) security fixes will only be added to the
| main branch | :white_check_mark: | | main branch | :white_check_mark: |
| older releases | :x: | | older releases | :x: |
You find all information in our [Bughunter documentation](https://www.kimai.org/documentation/bughunter.html). You find all information in our [Bughunter documentation](https://www.kimai.org/documentation/bughunter.html).

0
assets/.gitignore vendored Normal file
View File

View File

@@ -236,13 +236,8 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
options.push(optGroup); options.push(optGroup);
} }
// log the one with a group name first (e.g. non-global activities)
options.forEach(child => node.appendChild(child)); options.forEach(child => node.appendChild(child));
emptyOpts.forEach(child => node.appendChild(child));
// append the ones with no parent at the end (e.g. global activities)
const optGroupEmpty = this._createOptgroup('');
emptyOpts.forEach(child => optGroupEmpty.appendChild(child));
node.appendChild(optGroupEmpty)
// if available, re-select the previous selected option (mostly usable for global activities) // if available, re-select the previous selected option (mostly usable for global activities)
node.value = selectedValue; node.value = selectedValue;

View File

@@ -42,10 +42,3 @@ fieldset:empty {
border-width: 1px; border-width: 1px;
border-style: solid; border-style: solid;
} }
[data-bs-theme=dark] {
/* fixes https://github.com/tabler/tabler/issues/1974 */
.litepicker .container__days .day-item.is-in-range {
--litepicker-is-in-range-color: var(--tblr-primary-text-emphasis);
}
}

View File

@@ -5,5 +5,7 @@ twig:
- 'form/vertical.html.twig' - 'form/vertical.html.twig'
exception_controller: null exception_controller: null
paths: paths:
# used in templates/emails/layout.html.twig
'%kernel.project_dir%/assets/css': css
'%kernel.project_dir%/templates/bundles/TablerBundle': theme '%kernel.project_dir%/templates/bundles/TablerBundle': theme
'%kernel.project_dir%/vendor/kevinpapst/tabler-bundle/templates': theme '%kernel.project_dir%/vendor/kevinpapst/tabler-bundle/templates': theme

116
kimai.sh
View File

@@ -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 ""

View File

@@ -329,6 +329,16 @@ parameters:
count: 4 count: 4
path: src/Command/InstallCommand.php 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\\.$#" message: "#^Binary operation \"\\.\" between non\\-empty\\-string and non\\-empty\\-list\\<string\\>\\|string results in an error\\.$#"
count: 1 count: 1
@@ -459,6 +469,36 @@ parameters:
count: 1 count: 1
path: src/Command/ResetTestCommand.php path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between '\\$2y\\$13\\$X8…' and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between 'Administrator'\\|'CFO'\\|'Developer'\\|'Developer \\(left…'\\|'Head of Development'\\|'Quality Tester 1'\\|'Quality Tester 2'\\|'Super Administrator' and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between 'anna_admin'\\|'chris_user'\\|'clara_customer'\\|'john_user'\\|'susan_super'\\|'test_user_1'\\|'test_user_2'\\|'tony_teamlead' and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between 'anna_admin@example…'\\|'chris_user@example…'\\|'clara_customer…'\\|'john_user@example…'\\|'susan_super@example…'\\|'test_user_1@example…'\\|'test_user_2@example…'\\|'tony_teamlead…' and null will always evaluate to true\\.$#"
count: 2
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between 0\\|1 and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between array\\{\\}\\|array\\{'ROLE_ADMIN'\\}\\|array\\{'ROLE_CUSTOMER'\\}\\|array\\{'ROLE_SUPER_ADMIN'\\}\\|array\\{'ROLE_TEAMLEAD'\\} and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
- -
message: "#^Access to an undefined property SimpleXMLElement\\|false\\:\\:\\$file\\.$#" message: "#^Access to an undefined property SimpleXMLElement\\|false\\:\\:\\$file\\.$#"
count: 1 count: 1
@@ -544,6 +584,11 @@ parameters:
count: 1 count: 1
path: src/Command/TranslationCommand.php 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\\.$#" message: "#^Method App\\\\Configuration\\\\LdapConfiguration\\:\\:getConnectionParameters\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -1069,6 +1114,16 @@ parameters:
count: 2 count: 2
path: src/DataFixtures/TimesheetFixtures.php path: src/DataFixtures/TimesheetFixtures.php
-
message: "#^Method App\\\\DataFixtures\\\\UserFixtures\\:\\:getUserDefinition\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/DataFixtures/UserFixtures.php
-
message: "#^Method App\\\\DataFixtures\\\\UserFixtures\\:\\:getUserPreferences\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/DataFixtures/UserFixtures.php
- -
message: "#^Method App\\\\DependencyInjection\\\\AppExtension\\:\\:createPermissionParameter\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\DependencyInjection\\\\AppExtension\\:\\:createPermissionParameter\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -3654,6 +3709,11 @@ parameters:
count: 1 count: 1
path: src/Plugin/PluginMetadata.php 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\\.$#" message: "#^Cannot access offset 'duration' on mixed\\.$#"
count: 3 count: 3

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": { "app": {
"js": [ "js": [
"/build/runtime.74179306.js", "/build/runtime.74179306.js",
"/build/app.6cdb49cb.js" "/build/app.3463e2a4.js"
], ],
"css": [ "css": [
"/build/app.1ed18844.css" "/build/app.75450933.css"
] ]
}, },
"app-rtl": { "app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.97153087.js" "/build/app-rtl.97153087.js"
], ],
"css": [ "css": [
"/build/app-rtl.d38ad12c.css" "/build/app-rtl.dbee41f9.css"
] ]
}, },
"export-pdf": { "export-pdf": {
@@ -72,10 +72,10 @@
}, },
"integrity": { "integrity": {
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG", "/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
"/build/app.6cdb49cb.js": "sha384-psXCV8SG68O80ZJHHeSbCJPQ8RoepaMeI1pEypTHu0Q8ySLrhlclhKHIL2dZ0ZQJ", "/build/app.3463e2a4.js": "sha384-8Kq7fZHUdtxWd3CVdPUapwwO8qjw3/N2mP4w14TczsDI3jQ3WlJZeUyL0WPZP8QD",
"/build/app.1ed18844.css": "sha384-fEjS/tjc8m7jrw28/eWZFOTO6bugR9ILfksKvBrF46wtmqlage1s/P4+ZeYuqgpq", "/build/app.75450933.css": "sha384-tW+c6BIV2/3j9k6AiygV7qMVFqcsYG8dNY2CzCcIuMjOf3inAYoZlGvrV8VgfR3+",
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr", "/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
"/build/app-rtl.d38ad12c.css": "sha384-eFvMRueGVAxDxpr3s4y1EhHnVb8FQOr+KQgOuTzbW11ILydpLIvObiiuqNpf4mWO", "/build/app-rtl.dbee41f9.css": "sha384-52I26CWbi5SCx6Qr+s+xsgmaVX2Ybd4TVVVoEMP87laXeLbu6jtJ/4ZGwo7YDUQ0",
"/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF", "/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg", "/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG", "/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG",

View File

@@ -1,7 +1,7 @@
{ {
"build/app.css": "/build/app.1ed18844.css", "build/app.css": "/build/app.75450933.css",
"build/app.js": "/build/app.6cdb49cb.js", "build/app.js": "/build/app.3463e2a4.js",
"build/app-rtl.css": "/build/app-rtl.d38ad12c.css", "build/app-rtl.css": "/build/app-rtl.dbee41f9.css",
"build/app-rtl.js": "/build/app-rtl.97153087.js", "build/app-rtl.js": "/build/app-rtl.97153087.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css", "build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.1442bee7.js", "build/export-pdf.js": "/build/export-pdf.1442bee7.js",

View File

@@ -26,7 +26,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/ */
abstract class AbstractResetCommand extends Command abstract class AbstractResetCommand extends Command
{ {
public function __construct(private readonly string $kernelEnvironment) public function __construct(private string $kernelEnvironment)
{ {
parent::__construct(); parent::__construct();
} }

View File

@@ -11,13 +11,14 @@ namespace App\Command;
use App\Constants; use App\Constants;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
/** /**
@@ -25,10 +26,10 @@ use Symfony\Component\Console\Style\SymfonyStyle;
* *
* @codeCoverageIgnore * @codeCoverageIgnore
*/ */
#[AsCommand(name: 'kimai:install', description: 'Kimai installation command', aliases: ['kimai:update'])] #[AsCommand(name: 'kimai:install')]
final class InstallCommand extends Command final class InstallCommand extends Command
{ {
public function __construct(private readonly Connection $connection) public function __construct(private Connection $connection, private string $kernelEnvironment)
{ {
parent::__construct(); parent::__construct();
} }
@@ -36,7 +37,8 @@ final class InstallCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this $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') ->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 = new SymfonyStyle($input, $output);
$io->text('Start installation ...'); $io->title('Kimai installation running ...');
/** @var Application $application */
$application = $this->getApplication();
$environment = $application->getKernel()->getEnvironment();
// create the database, in case it is not yet existing
try { try {
// creates the database if it is not yet existing $this->createDatabase($io, $input, $output);
$this->createDatabase($io, $output);
} catch (\Exception $ex) { } catch (\Exception $ex) {
$io->error('Failed to create database: ' . $ex->getMessage()); $io->error('Failed to create database: ' . $ex->getMessage());
return Command::FAILURE; return Command::FAILURE;
} }
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
try { try {
// bootstrap database ONLY via doctrine migrations, so all installation will have the same state
$this->importMigrations($io, $output); $this->importMigrations($io, $output);
} catch (\Exception $ex) { } catch (\Exception $ex) {
$io->error('Failed to set migration status: ' . $ex->getMessage()); $io->error('Failed to set migration status: ' . $ex->getMessage());
@@ -70,22 +68,12 @@ final class InstallCommand extends Command
} }
if (!$input->getOption('no-cache')) { if (!$input->getOption('no-cache')) {
// show manual steps in case this fails // flush the cache, just to make sure ... and ignore result
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output); $this->rebuildCaches($this->kernelEnvironment, $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
]
);
}
} }
$io->success( $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; return Command::SUCCESS;
@@ -93,13 +81,11 @@ final class InstallCommand extends Command
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int 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'); $command = $this->getApplication()->find('cache:clear');
try { try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) { $command->run(new ArrayInput(['--env' => $environment]), $output);
throw new \RuntimeException('Invalid file permissions?');
}
} catch (\Exception $ex) { } catch (\Exception $ex) {
$io->error('Failed to clear cache: ' . $ex->getMessage()); $io->error('Failed to clear cache: ' . $ex->getMessage());
@@ -108,9 +94,7 @@ final class InstallCommand extends Command
$command = $this->getApplication()->find('cache:warmup'); $command = $this->getApplication()->find('cache:warmup');
try { try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) { $command->run(new ArrayInput(['--env' => $environment]), $output);
throw new \RuntimeException('Invalid file permissions?');
}
} catch (\Exception $ex) { } catch (\Exception $ex) {
$io->error('Failed to warmup cache: ' . $ex->getMessage()); $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 private function importMigrations(SymfonyStyle $io, OutputInterface $output): void
{ {
$io->text('Creating database ...');
$command = $this->getApplication()->find('doctrine:migrations:migrate'); $command = $this->getApplication()->find('doctrine:migrations:migrate');
$cmdInput = new ArrayInput(['--allow-no-migration' => true]); $cmdInput = new ArrayInput(['--allow-no-migration' => true]);
$cmdInput->setInteractive(false); $cmdInput->setInteractive(false);
$result = $command->run($cmdInput, $output); $command->run($cmdInput, $output);
if (0 !== $result) { $io->writeln('');
throw new \Exception('Failed updating database.');
}
} }
private function createDatabase(SymfonyStyle $io, OutputInterface $output): void private function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output): void
{ {
try { try {
if ($this->connection->isConnected()) { if ($this->connection->isConnected()) {
// database exists: we can skip this step $io->note(\sprintf('Database is existing and connection could be established'));
return; 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'); $command = $this->getApplication()->find('doctrine:database:create');
$cmdInput = new ArrayInput(['--if-not-exists' => true]); $result = $command->run(new ArrayInput($options), $output);
$cmdInput->setInteractive(false);
$result = $command->run($cmdInput, $output);
if (0 !== $result) { 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);
}
} }

View File

@@ -9,130 +9,37 @@
namespace App\Command; namespace App\Command;
use App\Plugin\Package;
use App\Plugin\PackageManager;
use App\Plugin\Plugin;
use App\Plugin\PluginManager; use App\Plugin\PluginManager;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:plugins', description: 'Manage Kimai plugins')] /**
* Command used to fetch plugin information.
*/
#[AsCommand(name: 'kimai:plugins')]
final class PluginCommand extends Command final class PluginCommand extends Command
{ {
public function __construct( public function __construct(private PluginManager $plugins)
private readonly PluginManager $pluginManager,
private readonly PackageManager $packageManager
)
{ {
parent::__construct(); parent::__construct();
} }
protected function configure(): void protected function configure(): void
{ {
$this->setHelp('Shows information about already installed plugins by default.'); $this
$this->addOption('available', null, InputOption::VALUE_NONE, 'Show list of available plugins in ' . PackageManager::PACKAGE_DIR); ->setDescription('Receive plugin information')
$this->addOption('composer', null, InputOption::VALUE_NONE, 'Dump list of available composer packages in ' . PackageManager::PACKAGE_DIR); ->setHelp('This command prints detailed plugin information.')
$this->addOption('install', null, InputOption::VALUE_NONE, 'Run plugins installer, previously installed via ./kimai.sh'); ;
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
if ($input->getOption('available')) { $plugins = $this->plugins->getPlugins();
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;
}
}
$command = $this->getApplication()?->find('doctrine:migrations:migrate');
if ($command === null) {
throw new \RuntimeException('Failed finding doctrine migrations command');
}
$cmdInput = new ArrayInput(['--allow-no-migration' => true, '--configuration' => $config]);
$cmdInput->setInteractive(false);
if (0 !== $command->run($cmdInput, $output)) {
$io->error('Failed to install bundle database: ' . $config);
}
}
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
{
if (empty($plugins)) { if (empty($plugins)) {
$io->warning('No plugins installed'); $io->warning('No plugins installed');

View File

@@ -25,13 +25,12 @@ use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/** /**
* Command used to execute all the basic application bootstrapping AFTER "composer install" was executed. * Command used to execute all the basic application bootstrapping AFTER "composer install" was executed.
* *
* This command is NOT used during runtime and only meant for developers and the CI processes for quality management. * This command is NOT used during runtime and only meant for developers and the CI processes for quality management.
* This is one of the cases where it is necessary to add tests: * This is one of the cases where I don't feel like it is necessary to add tests, so lets "cheat" with:
* @codeCoverageIgnore * @codeCoverageIgnore
*/ */
#[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')] #[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')]
@@ -39,7 +38,6 @@ final class ResetTestCommand extends AbstractResetCommand
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
string $kernelEnvironment string $kernelEnvironment
) )
{ {
@@ -82,127 +80,227 @@ final class ResetTestCommand extends AbstractResetCommand
$project->setCustomer($customer); $project->setCustomer($customer);
$this->entityManager->persist($project); $this->entityManager->persist($project);
$user1 = new User();
$user1->setPreferenceValue(UserPreference::HOURLY_RATE, 53);
$user1->setAlias('Clara Haynes');
$user1->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user1->setTitle('CFO');
$user1->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y');
$user1->setEnabled(true);
$user1->setRoles(['ROLE_CUSTOMER']);
$user1->setUserIdentifier('clara_customer');
$user1->setEmail('clara_customer@example.com');
$token1 = new AccessToken($user1, UserFixtures::DEFAULT_API_TOKEN . '_customer');
$token1->setName('Test fixture');
$user2 = new User();
$user2->setPreferenceValue(UserPreference::HOURLY_RATE, 82);
$user2->setAlias('John Doe');
$user2->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user2->setTitle('Developer');
$user2->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
$user2->setEnabled(true);
$user2->setRoles(['ROLE_USER']);
$user2->setUserIdentifier('john_user');
$user2->setEmail('john_user@example.com');
$token2 = new AccessToken($user2, UserFixtures::DEFAULT_API_TOKEN . '_user');
$token2->setName('Test fixture');
$user3 = new User();
$user3->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
$user3->setAlias('Chris Deactive');
$user3->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user3->setTitle('Developer (left company)');
$user3->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
$user3->setEnabled(false);
$user3->setRoles(['ROLE_USER']);
$user3->setUserIdentifier('chris_user');
$user3->setEmail('chris_user@example.com');
$token3 = new AccessToken($user3, UserFixtures::DEFAULT_API_TOKEN . '_inactive');
$token3->setName('Test fixture');
$user4 = new User();
$user4->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
$user4->setAlias('Tony Maier');
$user4->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user4->setTitle('Head of Development');
$user4->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
$user4->setEnabled(true);
$user4->setRoles(['ROLE_TEAMLEAD']);
$user4->setUserIdentifier('tony_teamlead');
$user4->setEmail('tony_teamlead@example.com');
$token4 = new AccessToken($user4, UserFixtures::DEFAULT_API_TOKEN . '_teamlead');
$token4->setName('Test fixture');
$user5 = new User();
$user5->setPreferenceValue(UserPreference::HOURLY_RATE, 81);
$user5->setAlias('Anna Smith');
$user5->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user5->setTitle('Administrator');
$user5->setEnabled(true);
$user5->setRoles(['ROLE_ADMIN']);
$user5->setUserIdentifier('anna_admin');
$user5->setEmail('anna_admin@example.com');
$token5 = new AccessToken($user5, UserFixtures::DEFAULT_API_TOKEN . '_admin');
$token5->setName('Test fixture');
$user6 = new User();
$user6->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
$user6->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user6->setTitle('Super Administrator');
$user6->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
$user6->setEnabled(true);
$user6->setRoles(['ROLE_SUPER_ADMIN']);
$user6->setUserIdentifier('susan_super');
$user6->setEmail('susan_super@example.com');
$token6 = new AccessToken($user6, UserFixtures::DEFAULT_API_TOKEN . '_super');
$token6->setName('Test fixture');
$user7 = new User();
$user7->setAlias('Test User 1');
$user7->setTitle('Quality Tester 1');
$user7->setEnabled(true);
$user7->setRoles(['ROLE_USER']);
$user7->setUserIdentifier('test_user_1');
$user7->setEmail('test_user_1@example.com');
$token7 = new AccessToken($user7, UserFixtures::DEFAULT_API_TOKEN . '_qa1');
$token7->setName('Test fixture');
$user8 = new User();
$user8->setAlias('Test User 2');
$user8->setTitle('Quality Tester 2');
$user8->setEnabled(true);
$user8->setRoles(['ROLE_USER']);
$user8->setUserIdentifier('test_user_2');
$user8->setEmail('test_user_2@example.com');
$token8 = new AccessToken($user8, UserFixtures::DEFAULT_API_TOKEN . '_qa2');
$token8->setName('Test fixture');
/** @var array<int, array{0: User, 1: AccessToken}> $users */
$users = [ $users = [
[$user1, $token1], // 0=id, 1=hourly rate, 2=Alias, 3=registration date, 4=title, 5=avatar, 6=enabled, 7=password, 8=roles, 9=username, 10=username canonical, 11=email, 12=email canonical, 13=salt, 14=last login, 15=confirmation token, 16=password requested at, 17=api_token
[$user2, $token2], [
[$user3, $token3], 1,
[$user4, $token4], 53,
[$user5, $token5], 'Clara Haynes',
[$user6, $token6], '2018-02-06 23:28:57',
[$user7, $token7], 'CFO',
[$user8, $token8], 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y',
1,
'$2y$04$kKBYJ8sKCOhhakCjm9sCp.TQdwLTS1FPkPiWn2KBmaCA7xFL0NA42',
['ROLE_CUSTOMER'],
'clara_customer',
'clara_customer',
'clara_customer@example.com',
'clara_customer@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_customer',
],
[
2,
82,
'John Doe',
'2018-02-06 23:28:57',
'Developer',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
1,
'$2y$04$36P/xyhP6FbnfFYbXy7V0.ioSe8HjMlJQFYnlIzz2T6Agfi8ob6jK',
[],
'john_user',
'john_user',
'john_user@example.com',
'john_user@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_user',
],
[
3,
35,
'Chris Deactive',
'2018-02-06 23:28:57',
'Developer (left company)',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
0,
'$2y$04$MLtQBZ9JLzWu1Y01QnNjsuoLm8qC9XRkpUywf6DIbpd9OAL1mEcCi',
[],
'chris_user',
'chris_user',
'chris_user@example.com',
'chris_user@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_inactive',
],
[
4,
35,
'Tony Maier',
'2018-02-06 23:28:57',
'Head of Development',
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
1,
'$2y$04$rqxiiExfUVzIYRVL2x4JJumQWNPIG6PazXwrSJm/VQFEesR08Uj5i',
['ROLE_TEAMLEAD'],
'tony_teamlead',
'tony_teamlead',
'tony_teamlead@example.com',
'tony_teamlead@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_teamlead',
],
[
5,
81,
'Anna Smith',
'2018-02-06 23:28:57',
'Administrator',
null,
1,
'$2y$04$ct/rVb.naDzYZECnvfTJ2uns/zPHv8.8KcunhTjYFwWQeg1dywI8G',
['ROLE_ADMIN'],
'anna_admin',
'anna_admin',
'anna_admin@example.com',
'anna_admin@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_admin',
],
[
6,
46,
null,
'2018-02-06 23:28:57',
'Super Administrator',
'/bundles/avanzuadmintheme/img/avatar.png',
1,
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
['ROLE_SUPER_ADMIN'],
'susan_super',
'susan_super',
'susan_super@example.com',
'susan_super@example.com',
null,
'2020-04-14 09:50:38',
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_super',
],
[
7,
null,
'Test User 1',
null,
'Quality Tester 1',
null,
1,
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
[],
'test_user_1',
'test_user_1',
'test_user_1@example.com',
'test_user_1@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_qa1',
],
[
8,
null,
'Test User 2',
null,
'Quality Tester 2',
null,
1,
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
[],
'test_user_2',
'test_user_2',
'test_user_2@example.com',
'test_user_2@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_qa2',
],
]; ];
$userEntities = []; $userEntities = [];
foreach ($users as $items) { foreach ($users as $userConf) {
$user = $items[0]; $user = new User();
$user->setPassword($this->passwordHasher->hashPassword($user, UserFixtures::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, UserFixtures::DEFAULT_API_TOKEN));
foreach (User::WIZARDS as $wizard) { foreach (User::WIZARDS as $wizard) {
$user->setWizardAsSeen($wizard); $user->setWizardAsSeen($wizard);
} }
$this->entityManager->persist($user); if ($userConf[1] !== null) {
$user->setPreferenceValue(UserPreference::HOURLY_RATE, $userConf[1]);
}
if ($userConf[2] !== null) {
$user->setAlias($userConf[2]);
}
if ($userConf[3] !== null) {
$user->setRegisteredAt(new \DateTime($userConf[3]));
}
if ($userConf[4] !== null) {
$user->setTitle($userConf[4]);
}
if ($userConf[5] !== null) {
$user->setAvatar($userConf[5]);
}
if ($userConf[6] !== null) {
$user->setEnabled((bool) $userConf[6]);
}
$user->setPassword($userConf[7]);
if ($userConf[8] !== null && !empty($userConf[8])) {
$user->setRoles($userConf[8]);
} else {
$user->setRoles(['ROLE_USER']);
}
$user->setUserIdentifier($userConf[9]);
if ($userConf[10] !== null) {
// removed field: UsernameCanonical
}
if ($userConf[11] !== null) {
$user->setEmail($userConf[11]);
}
if ($userConf[12] !== null) {
// removed field: EmailCanonical
}
if ($userConf[17] !== null) {
$user->setApiToken($userConf[17]);
}
$accessToken = $items[1]; $accessToken = new AccessToken($user, $userConf[18]);
$accessToken->setName('Test fixture');
$this->entityManager->persist($accessToken); $this->entityManager->persist($accessToken);
$this->entityManager->persist($user);
$userEntities[] = $user; $userEntities[] = $user;
} }

View 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;
}
}

View File

@@ -36,7 +36,6 @@ final class UserLoginLinkCommand extends Command
parent::__construct(); parent::__construct();
$this->addArgument('email', InputArgument::REQUIRED, 'The email of the user'); $this->addArgument('email', InputArgument::REQUIRED, 'The email of the user');
$this->addOption('password-reset', null, InputOption::VALUE_NONE, 'Whether the user needs to reset the password afterwards'); $this->addOption('password-reset', null, InputOption::VALUE_NONE, 'Whether the user needs to reset the password afterwards');
$this->addOption('all-auth', null, InputOption::VALUE_NONE, 'Ignore that the user is using an external authentication system');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
@@ -64,7 +63,7 @@ final class UserLoginLinkCommand extends Command
return Command::FAILURE; return Command::FAILURE;
} }
if (!$user->isInternalUser() && !$input->getOption('all-auth')) { if (!$user->isInternalUser()) {
$io->error('User does not use internal login'); $io->error('User does not use internal login');
return Command::FAILURE; return Command::FAILURE;

View File

@@ -33,7 +33,10 @@ final class SystemConfiguration
} }
/** /**
* Set a new or replace an existing system configuration. * Set an array item to a given value using "dot" notation.
* If no key is given to the method, the entire array will be replaced.
*
* @internal
*/ */
public function set(string $key, mixed $value): void public function set(string $key, mixed $value): void
{ {
@@ -453,11 +456,6 @@ final class SystemConfiguration
return $this->getIncrement('quick_entry.recent_activities', 5, 0); return $this->getIncrement('quick_entry.recent_activities', 5, 0);
} }
public function isBreakTimeEnabled(): bool
{
return (bool) $this->find('timesheet.rules.break_time_active');
}
// ========== Company configurations ========== // ========== Company configurations ==========
public function getFinancialYearStart(): ?string public function getFinancialYearStart(): ?string

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.24.0'; public const VERSION = '2.23.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 22400; public const VERSION_ID = 22300;
/** /**
* The software name * The software name
*/ */

View File

@@ -85,10 +85,6 @@ abstract class TimesheetAbstractController extends AbstractController
$table->addColumn('endtime', ['class' => 'd-none d-sm-table-cell text-center text-nowrap', 'orderBy' => 'end']); $table->addColumn('endtime', ['class' => 'd-none d-sm-table-cell text-center text-nowrap', 'orderBy' => 'end']);
} }
if ($this->configuration->isBreakTimeEnabled()) {
$table->addColumn('break', ['class' => 'text-end text-nowrap']);
}
$table->addColumn('duration', ['class' => 'text-end text-nowrap']); $table->addColumn('duration', ['class' => 'text-end text-nowrap']);
if ($canSeeRate) { if ($canSeeRate) {

View File

@@ -29,8 +29,8 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
*/ */
final class UserFixtures extends Fixture implements FixtureGroupInterface final class UserFixtures extends Fixture implements FixtureGroupInterface
{ {
public const DEFAULT_PASSWORD = 'password'; public const DEFAULT_PASSWORD = 'kitten';
public const DEFAULT_API_TOKEN = 'token'; public const DEFAULT_API_TOKEN = 'api_kitten';
public const DEFAULT_AVATAR = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y'; public const DEFAULT_AVATAR = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y';
public const USERNAME_USER = 'john_user'; public const USERNAME_USER = 'john_user';
@@ -43,7 +43,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
public const MIN_RATE = 30; public const MIN_RATE = 30;
public const MAX_RATE = 120; public const MAX_RATE = 120;
public function __construct(private readonly UserPasswordHasherInterface $passwordHasher) public function __construct(private UserPasswordHasherInterface $passwordHasher)
{ {
} }
@@ -65,8 +65,32 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
{ {
$allUsers = $this->getUserDefinition(); $allUsers = $this->getUserDefinition();
foreach ($allUsers as $userData) { foreach ($allUsers as $userData) {
$manager->persist($userData[0]); $user = new User();
$manager->persist($userData[1]); $user->setAlias($userData[0]);
$user->setTitle($userData[1]);
$user->setUserIdentifier($userData[2]);
$user->setEmail($userData[3]);
$user->setRoles([$userData[4]]);
$user->setAvatar($userData[5]);
$user->setEnabled($userData[6]);
$user->setPassword($this->passwordHasher->hashPassword($user, $userData[8]));
$user->setApiToken($this->passwordHasher->hashPassword($user, $userData[9]));
$manager->persist($user);
$prefs = $this->getUserPreferences($user, $userData[7]);
$user->setPreferences($prefs);
// better to be able to test the wizard in demo installations
/*
foreach (User::WIZARDS as $wizard) {
$user->setWizardAsSeen($wizard);
}
*/
$manager->persist($prefs[0]);
$manager->persist($prefs[1]);
$accessToken = new AccessToken($user, $userData[10]);
$accessToken->setName('Test fixture');
$manager->persist($accessToken);
} }
$manager->flush(); $manager->flush();
@@ -76,7 +100,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
/** /**
* @param User $user * @param User $user
* @param string|null $timezone * @param string|null $timezone
* @return array<UserPreference> * @return array
*/ */
private function getUserPreferences(User $user, string $timezone = null): array private function getUserPreferences(User $user, string $timezone = null): array
{ {
@@ -138,158 +162,141 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
$manager->clear(); $manager->clear();
} }
/**
* Do NOT set wizard as seen here, because they should be visible in the demo installation.
*
* @return array<int, array{0: User, 1: AccessToken}>
*/
private function getUserDefinition(): array private function getUserDefinition(): array
{ {
$all = []; // alias = $userData[0]
// title = $userData[1]
// username = $userData[2]
// email = $userData[3]
// roles = [$userData[4]]
// avatar = $userData[5]
// enabled = $userData[6]
// timezone = $userData[7]
// password = $userData[8]
// api old = $userData[9]
// api new = $userData[10]
$user = new User(); return [
$user->setAlias('John Doe'); [
$user->setTitle('Developer'); 'John Doe',
$user->setUserIdentifier(self::USERNAME_USER); 'Developer',
$user->setEmail('john_user@example.com'); self::USERNAME_USER,
$user->setRoles([User::ROLE_USER]); 'john_user@example.com',
$user->setAvatar(self::DEFAULT_AVATAR); User::ROLE_USER,
$user->setEnabled(true); self::DEFAULT_AVATAR,
$prefs = $this->getUserPreferences($user, 'America/Vancouver'); true,
$user->setPreferences($prefs); 'America/Vancouver',
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD)); self::DEFAULT_PASSWORD,
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN)); self::DEFAULT_API_TOKEN,
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_john'); self::DEFAULT_API_TOKEN . '_john',
$token->setName('User fixture'); ],
$all[] = [$user, $token]; [
'John Doe',
$user = new User(); 'Developer',
$user->setAlias('John Doe'); 'user',
$user->setTitle('Developer'); 'user@example.com',
$user->setUserIdentifier('user'); User::ROLE_USER,
$user->setEmail('user@example.com'); self::DEFAULT_AVATAR,
$user->setRoles([User::ROLE_USER]); true,
$user->setAvatar(self::DEFAULT_AVATAR); 'America/Vancouver',
$user->setEnabled(true); 'password',
$prefs = $this->getUserPreferences($user, 'America/Vancouver'); 'password',
$user->setPreferences($prefs); self::DEFAULT_API_TOKEN . '_user',
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD)); ],
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN)); // inactive user to test login
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_user'); [
$token->setName('User fixture'); 'Chris Deactive',
$all[] = [$user, $token]; 'Developer (left company)',
'chris_user',
// inactive user to test login 'chris_user@example.com',
$user = new User(); User::ROLE_USER,
$user->setAlias('Chris Deactive'); self::DEFAULT_AVATAR,
$user->setTitle('Developer (left company)'); false,
$user->setUserIdentifier('chris_user'); 'Australia/Sydney',
$user->setEmail('chris_user@example.com'); self::DEFAULT_PASSWORD,
$user->setRoles([User::ROLE_USER]); self::DEFAULT_API_TOKEN,
$user->setAvatar(self::DEFAULT_AVATAR); self::DEFAULT_API_TOKEN . '_inactive',
$user->setEnabled(false); ],
$prefs = $this->getUserPreferences($user, 'Australia/Sydney'); [
$user->setPreferences($prefs); 'Tony Maier',
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD)); 'Head of Sales',
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN)); self::USERNAME_TEAMLEAD,
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_inactive'); 'tony_teamlead@example.com',
$token->setName('User fixture'); User::ROLE_TEAMLEAD,
$all[] = [$user, $token]; 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
true,
$user = new User(); 'Asia/Bangkok',
$user->setAlias('Tony Maier'); self::DEFAULT_PASSWORD,
$user->setTitle('Head of Sales'); self::DEFAULT_API_TOKEN,
$user->setUserIdentifier(self::USERNAME_TEAMLEAD); self::DEFAULT_API_TOKEN . '_teamlead',
$user->setEmail('tony_teamlead@example.com'); ],
$user->setRoles([User::ROLE_TEAMLEAD]); [
$user->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg'); 'Tony Maier',
$user->setEnabled(true); 'Head of Sales',
$prefs = $this->getUserPreferences($user, 'Asia/Bangkok'); 'teamlead',
$user->setPreferences($prefs); 'teamlead@example.com',
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD)); User::ROLE_TEAMLEAD,
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN)); 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_teamlead'); true,
$token->setName('User fixture'); 'Asia/Bangkok',
$all[] = [$user, $token]; 'password',
'password',
$user = new User(); self::DEFAULT_API_TOKEN . '_tony',
$user->setAlias('Tony Maier'); ],
$user->setTitle('Head of Sales'); // no avatar to test default image macro
$user->setUserIdentifier('teamlead'); [
$user->setEmail('teamlead@example.com'); 'Anna Smith',
$user->setRoles([User::ROLE_TEAMLEAD]); 'Administrator',
$user->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg'); self::USERNAME_ADMIN,
$user->setEnabled(true); 'anna_admin@example.com',
$prefs = $this->getUserPreferences($user, 'Asia/Bangkok'); User::ROLE_ADMIN,
$user->setPreferences($prefs); null,
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD)); true,
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN)); 'Europe/London',
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_tony'); self::DEFAULT_PASSWORD,
$token->setName('User fixture'); self::DEFAULT_API_TOKEN,
$all[] = [$user, $token]; self::DEFAULT_API_TOKEN . '_anna',
],
// no avatar to test default image macro [
$user = new User(); 'Anna Smith',
$user->setAlias('Anna Smith'); 'Administrator',
$user->setTitle('Administrator'); 'administrator',
$user->setUserIdentifier(self::USERNAME_ADMIN); 'administrator@example.com',
$user->setEmail('anna_admin@example.com'); User::ROLE_ADMIN,
$user->setRoles([User::ROLE_ADMIN]); null,
$user->setEnabled(true); true,
$prefs = $this->getUserPreferences($user, 'Europe/London'); 'Europe/London',
$user->setPreferences($prefs); 'password',
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD)); 'password',
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN)); self::DEFAULT_API_TOKEN . '_admin',
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_anna'); ],
$token->setName('User fixture'); // no alias to test twig username macro
$all[] = [$user, $token]; [
null,
$user = new User(); 'Super Administrator',
$user->setAlias('Anna Smith'); self::USERNAME_SUPER_ADMIN,
$user->setTitle('Administrator'); 'susan_super@example.com',
$user->setUserIdentifier('administrator'); User::ROLE_SUPER_ADMIN,
$user->setEmail('administrator@example.com'); '/touch-icon-192x192.png',
$user->setRoles([User::ROLE_ADMIN]); true,
$user->setEnabled(true); 'Europe/Berlin',
$prefs = $this->getUserPreferences($user, 'Europe/London'); self::DEFAULT_PASSWORD,
$user->setPreferences($prefs); self::DEFAULT_API_TOKEN,
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD)); self::DEFAULT_API_TOKEN . '_susan',
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN)); ],
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_admin'); [
$token->setName('User fixture'); null,
$all[] = [$user, $token]; 'Super Administrator',
'super_admin',
// no alias to test twig username macro 'super_admin@example.com',
$user = new User(); User::ROLE_SUPER_ADMIN,
$user->setTitle('Super Administrator'); '/touch-icon-192x192.png',
$user->setUserIdentifier(self::USERNAME_SUPER_ADMIN); true,
$user->setEmail('susan_super@example.com'); 'Europe/Berlin',
$user->setRoles([User::ROLE_SUPER_ADMIN]); 'password',
$user->setAvatar('/touch-icon-192x192.png'); 'password',
$user->setEnabled(true); self::DEFAULT_API_TOKEN . '_super',
$prefs = $this->getUserPreferences($user, 'Europe/Berlin'); ],
$user->setPreferences($prefs); ];
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_susan');
$token->setName('User fixture');
$all[] = [$user, $token];
$user = new User();
$user->setTitle('Super Administrator');
$user->setUserIdentifier('super_admin');
$user->setEmail('super_admin@example.com');
$user->setRoles([User::ROLE_SUPER_ADMIN]);
$user->setAvatar('/touch-icon-192x192.png');
$user->setEnabled(true);
$prefs = $this->getUserPreferences($user, 'Europe/Berlin');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_super');
$token->setName('User fixture');
$all[] = [$user, $token];
return $all;
} }
} }

View File

@@ -1276,17 +1276,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
public function getWorkStartingDay(): ?\DateTimeInterface public function getWorkStartingDay(): ?\DateTimeInterface
{ {
return $this->getPreferenceDate('work_start_day'); $date = $this->getPreferenceValue(UserPreference::WORK_STARTING_DAY);
}
public function setWorkStartingDay(?\DateTimeInterface $date): void
{
$this->setPreferenceValue('work_start_day', $date?->format('Y-m-d'));
}
private function getPreferenceDate(string $prefName): ?\DateTimeInterface
{
$date = $this->getPreferenceValue($prefName);
if ($date === null) { if ($date === null) {
return null; return null;
@@ -1300,14 +1290,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return ($date instanceof \DateTimeInterface) ? $date : null; return ($date instanceof \DateTimeInterface) ? $date : null;
} }
public function getLastWorkingDay(): ?\DateTimeInterface public function setWorkStartingDay(?\DateTimeInterface $date): void
{ {
return $this->getPreferenceDate('work_last_day'); $this->setPreferenceValue(UserPreference::WORK_STARTING_DAY, $date?->format('Y-m-d'));
}
public function setLastWorkingDay(?\DateTimeInterface $date): void
{
$this->setPreferenceValue('work_last_day', $date?->format('Y-m-d'));
} }
public function getPublicHolidayGroup(): null|string public function getPublicHolidayGroup(): null|string

View File

@@ -47,6 +47,7 @@ class UserPreference
public const WORK_HOURS_SATURDAY = WorkingTimeCalculatorDay::WORK_HOURS_SATURDAY; public const WORK_HOURS_SATURDAY = WorkingTimeCalculatorDay::WORK_HOURS_SATURDAY;
/** @deprecated since 2.22*/ /** @deprecated since 2.22*/
public const WORK_HOURS_SUNDAY = WorkingTimeCalculatorDay::WORK_HOURS_SUNDAY; public const WORK_HOURS_SUNDAY = WorkingTimeCalculatorDay::WORK_HOURS_SUNDAY;
public const WORK_STARTING_DAY = 'work_start_day';
public const PUBLIC_HOLIDAY_GROUP = 'public_holiday_group'; public const PUBLIC_HOLIDAY_GROUP = 'public_holiday_group';
public const HOLIDAYS_PER_YEAR = 'holidays'; public const HOLIDAYS_PER_YEAR = 'holidays';
public const WORK_CONTRACT_TYPE = 'work_contract_type'; public const WORK_CONTRACT_TYPE = 'work_contract_type';

View File

@@ -26,7 +26,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/ */
final class ActivityType extends AbstractType final class ActivityType extends AbstractType
{ {
public function __construct(private readonly ActivityHelper $activityHelper, private readonly ProjectHelper $projectHelper) public function __construct(private ActivityHelper $activityHelper, private ProjectHelper $projectHelper)
{ {
} }
@@ -35,10 +35,10 @@ final class ActivityType extends AbstractType
return $this->activityHelper->getChoiceLabel($activity); return $this->activityHelper->getChoiceLabel($activity);
} }
public function groupBy(Activity $activity, $key, $index): string public function groupBy(Activity $activity, $key, $index): ?string
{ {
if (null === $activity->getProject()) { if (null === $activity->getProject()) {
return ''; return null;
} }
return $this->projectHelper->getChoiceLabel($activity->getProject()); return $this->projectHelper->getChoiceLabel($activity->getProject());

View File

@@ -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)); 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) { 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)); 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'); $routes->import($configDir . '/routes/' . $this->environment . '/*.yaml');
} }
// load application routes
$routes->import($configDir . '/routes.yaml');
foreach ($this->getBundles() as $bundle) { 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/')) { if (is_dir($bundle->getPath() . '/Resources/config/')) {
$routes->import($bundle->getPath() . '/Resources/config/routes' . self::CONFIG_EXTS); $routes->import($bundle->getPath() . '/Resources/config/routes' . self::CONFIG_EXTS);
} elseif (is_dir($bundle->getPath() . '/config/')) { } 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');
} }
} }

View File

@@ -14,6 +14,17 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension;
abstract class AbstractPluginExtension extends 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 protected function registerBundleConfiguration(ContainerBuilder $container, array $configs): void
{ {
$bundleConfig = [$this->getAlias() => $configs]; $bundleConfig = [$this->getAlias() => $configs];

View File

@@ -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;
}
}

View File

@@ -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');
}
}

View File

@@ -20,7 +20,7 @@ final class Plugin
public function getMetadata(): PluginMetadata public function getMetadata(): PluginMetadata
{ {
if ($this->metadata === null) { if ($this->metadata === null) {
$this->metadata = PluginMetadata::createFromPath($this->getPath()); $this->metadata = new PluginMetadata($this->getPath());
} }
return $this->metadata; return $this->metadata;
@@ -33,7 +33,12 @@ final class Plugin
public function getName(): string 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 public function getId(): string

View File

@@ -13,79 +13,57 @@ use App\Constants;
class PluginMetadata class PluginMetadata
{ {
private string $package; private ?string $version = null;
private string $version; private ?int $kimaiVersion = null;
private int $kimaiVersion; private ?string $homepage = null;
private string $homepage; private ?string $description = null;
private string $description; private ?string $name = null;
private string $name;
public static function createFromPath(string $path): self /**
* @throws \Exception
*/
public function __construct(string $path)
{ {
if (!is_dir($path) || !is_readable($path)) { if (!is_dir($path) || !is_readable($path)) {
throw new \Exception(\sprintf('Bundle directory "%s" cannot be accessed.', $path)); throw new \Exception(\sprintf('Bundle directory "%s" cannot be accessed.', $path));
} }
$pluginName = basename($path);
$composer = $path . '/composer.json'; $composer = $path . '/composer.json';
if (!file_exists($composer) || !is_readable($composer)) { 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); $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)) { 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'])) { 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'])) { 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'])) { 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'])) { 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(); $this->description = $json['description'] ?? '';
$this->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$meta->package = $json['name'] ?? ''; $this->name = $json['extra']['kimai']['name'];
$meta->description = $json['description'] ?? ''; $this->kimaiVersion = $json['extra']['kimai']['require'];
$meta->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$meta->name = $json['extra']['kimai']['name'];
$meta->kimaiVersion = $json['extra']['kimai']['require'];
// the version field is required if we use composer to install a plugin via var/packages/ // 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'); $this->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
return $meta;
}
private function __construct() {}
public function getPackage(): string
{
return $this->package;
} }
public function getDescription(): ?string public function getDescription(): ?string
@@ -93,22 +71,22 @@ class PluginMetadata
return $this->description; return $this->description;
} }
public function getVersion(): string public function getVersion(): ?string
{ {
return $this->version; return $this->version;
} }
public function getKimaiVersion(): int public function getKimaiVersion(): ?int
{ {
return $this->kimaiVersion; return $this->kimaiVersion;
} }
public function getHomepage(): string public function getHomepage(): ?string
{ {
return $this->homepage; return $this->homepage;
} }
public function getName(): string public function getName(): ?string
{ {
return $this->name; return $this->name;
} }

View File

@@ -102,7 +102,6 @@ final class WorkingTimeService
$stats = null; $stats = null;
$firstDay = $user->getWorkStartingDay(); $firstDay = $user->getWorkStartingDay();
$lastDay = $user->getLastWorkingDay();
$calculator = $this->getContractMode($user)->getCalculator($user); $calculator = $this->getContractMode($user)->getCalculator($user);
foreach ($year->getMonths() as $month) { foreach ($year->getMonths() as $month) {
@@ -120,7 +119,7 @@ final class WorkingTimeService
$dayDate = $day->getDay(); $dayDate = $day->getDay();
$result = new WorkingTime($user, $dayDate); $result = new WorkingTime($user, $dayDate);
if (($firstDay === null || $firstDay <= $dayDate) && ($lastDay === null || $lastDay >= $dayDate)) { if ($firstDay === null || $firstDay <= $dayDate) {
$result->setExpectedTime($calculator->getWorkHoursForDay($dayDate)); $result->setExpectedTime($calculator->getWorkHoursForDay($dayDate));
} }

View File

@@ -1,4 +1,4 @@
{% apply inky_to_html|inline_css(source('emails/css/emails.css')) %} {% apply inky_to_html|inline_css(source('@css/emails.css')) %}
<spacer size="32"></spacer> <spacer size="32"></spacer>

View File

@@ -269,8 +269,6 @@
.items td.cost, .items td.cost,
.items th.column-rate, .items th.column-rate,
.items th.column-internalRate, .items th.column-internalRate,
.items th.column-hourlyRate,
.items th.column-fixedRate,
.items th.column-duration .items th.column-duration
{ {
text-align: right; text-align: right;
@@ -296,7 +294,7 @@
| |
<label for="date-format"> <label for="date-format">
{{ 'date'|trans }}: {{ 'date'|trans }}:
{% set demo_date = create_date('2024-01-01 11:00:00') %} {% set demo_date = create_date('2020-01-01 20:00:00') %}
<select id="date-format" name="date-format"> <select id="date-format" name="date-format">
<option value="short">{{ demo_date|date_short }}</option> <option value="short">{{ demo_date|date_short }}</option>
<option value="time">{{ demo_date|date_time }}</option> <option value="time">{{ demo_date|date_time }}</option>
@@ -305,7 +303,7 @@
| |
<label for="begin-format"> <label for="begin-format">
{{ 'begin'|trans }}: {{ 'begin'|trans }}:
{% set demo_date = create_date('2024-01-01 11:00:00') %} {% set demo_date = create_date('2020-01-01 11:00:00') %}
<select id="begin-format" name="begin-format"> <select id="begin-format" name="begin-format">
<option value="plain">{{ demo_date|date_format('H:i') }}</option> <option value="plain">{{ demo_date|date_format('H:i') }}</option>
<option value="short">{{ demo_date|date_short }}</option> <option value="short">{{ demo_date|date_short }}</option>
@@ -315,7 +313,7 @@
| |
<label for="end-format"> <label for="end-format">
{{ 'end'|trans }}: {{ 'end'|trans }}:
{% set demo_date = create_date('2024-01-01 18:00:00') %} {% set demo_date = create_date('2020-01-01 18:00:00') %}
<select id="end-format" name="end-format"> <select id="end-format" name="end-format">
<option value="plain">{{ demo_date|date_format('H:i') }}</option> <option value="plain">{{ demo_date|date_format('H:i') }}</option>
<option value="short">{{ demo_date|date_short }}</option> <option value="short">{{ demo_date|date_short }}</option>
@@ -637,7 +635,7 @@
<tr> <tr>
<td class="column-date text-nowrap" {% if not columns.date %}style="display: none"{% endif %}> <td class="column-date text-nowrap" {% if not columns.date %}style="display: none"{% endif %}>
<span class="dateformat" data-short="{{ entry.begin|date_short }}" data-full="{{ entry.begin|date_time }}" data-time="{{ entry.begin|date_time }}"> <span class="dateformat" data-short="{{ entry.begin|date_short }}" data-full="{{ entry.begin|date_time }}" data-time="{{ entry.begin|date_time }}">
{{ entry.begin|date_short }} {{ entry.begin|date_time }}
</span> </span>
</td> </td>
<td class="column-begin text-nowrap" {% if not columns.begin %}style="display: none"{% endif %}> <td class="column-begin text-nowrap" {% if not columns.begin %}style="display: none"{% endif %}>
@@ -710,10 +708,10 @@
{% endif %} {% endif %}
</td> </td>
{% endfor %} {% endfor %}
<td class="cost column-hourlyRate text-nowrap" {% if not columns.hourlyRate %}style="display: none"{% endif %}> <td class="column-hourlyRate text-nowrap" {% if not columns.hourlyRate %}style="display: none"{% endif %}>
{{ entry.hourlyRate|money(entry.project.customer.currency) }} {{ entry.hourlyRate|money(entry.project.customer.currency) }}
</td> </td>
<td class="cost column-fixedRate text-nowrap" {% if not columns.fixedRate %}style="display: none"{% endif %}> <td class="column-fixedRate text-nowrap" {% if not columns.fixedRate %}style="display: none"{% endif %}>
{{ entry.fixedRate|money(entry.project.customer.currency) }} {{ entry.fixedRate|money(entry.project.customer.currency) }}
</td> </td>
<td class="duration column-duration text-nowrap" {% if not columns.duration %}style="display: none"{% endif %}> <td class="duration column-duration text-nowrap" {% if not columns.duration %}style="display: none"{% endif %}>

View File

@@ -110,8 +110,6 @@
{% else %} {% else %}
<i data-since="{{ entry.begin.format(constant('DATE_ISO8601')) }}">{{ entry|duration }}</i> <i data-since="{{ entry.begin.format(constant('DATE_ISO8601')) }}">{{ entry|duration }}</i>
{% endif %} {% endif %}
{% elseif column == 'break' %}
{{ entry.break|duration }}
{% elseif column == 'hourlyRate' %} {% elseif column == 'hourlyRate' %}
{{ entryHourlyRate }} {{ entryHourlyRate }}
{% elseif column == 'rate' %} {% elseif column == 'rate' %}

View File

@@ -7,10 +7,11 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace App\Tests\API; namespace API;
use App\Entity\Invoice; use App\Entity\Invoice;
use App\Entity\User; use App\Entity\User;
use App\Tests\API\APIControllerBaseTest;
use App\Tests\DataFixtures\InvoiceFixtures; use App\Tests\DataFixtures\InvoiceFixtures;
/** /**

View File

@@ -29,7 +29,8 @@ class InstallCommandTest extends KernelTestCase
$container = self::$kernel->getContainer(); $container = self::$kernel->getContainer();
$this->application->add(new InstallCommand( $this->application->add(new InstallCommand(
$container->get('doctrine')->getConnection() $container->get('doctrine')->getConnection(),
$this->application->getKernel()->getEnvironment()
)); ));
} }

View File

@@ -10,7 +10,6 @@
namespace App\Tests\Command; namespace App\Tests\Command;
use App\Command\PluginCommand; use App\Command\PluginCommand;
use App\Plugin\PackageManager;
use App\Plugin\PluginInterface; use App\Plugin\PluginInterface;
use App\Plugin\PluginManager; use App\Plugin\PluginManager;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
@@ -42,7 +41,7 @@ class PluginCommandTest extends KernelTestCase
{ {
$kernel = self::bootKernel(); $kernel = self::bootKernel();
$this->application = new Application($kernel); $this->application = new Application($kernel);
$this->application->add(new PluginCommand(new PluginManager($plugins), new PackageManager(__DIR__ . '/../../'))); $this->application->add(new PluginCommand(new PluginManager($plugins)));
$command = $this->application->find('kimai:plugins'); $command = $this->application->find('kimai:plugins');
$commandTester = new CommandTester($command); $commandTester = new CommandTester($command);

View File

@@ -13,7 +13,6 @@ use App\Command\ResetTestCommand;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/** /**
* @covers \App\Command\ResetTestCommand * @covers \App\Command\ResetTestCommand
@@ -25,11 +24,7 @@ class ResetTestCommandTest extends KernelTestCase
{ {
$kernel = self::bootKernel(); $kernel = self::bootKernel();
$application = new Application($kernel); $application = new Application($kernel);
$application->add(new ResetTestCommand( $application->add(new ResetTestCommand($this->createMock(EntityManagerInterface::class), 'test'));
$this->createMock(EntityManagerInterface::class),
$this->createMock(UserPasswordHasherInterface::class),
'test'
));
self::assertTrue($application->has('kimai:reset:test')); self::assertTrue($application->has('kimai:reset:test'));
$command = $application->find('kimai:reset:test'); $command = $application->find('kimai:reset:test');
@@ -38,11 +33,7 @@ class ResetTestCommandTest extends KernelTestCase
public function testCommandNameIsNotEnabledInProd(): void public function testCommandNameIsNotEnabledInProd(): void
{ {
$sut = new ResetTestCommand( $sut = new ResetTestCommand($this->createMock(EntityManagerInterface::class), 'prod');
$this->createMock(EntityManagerInterface::class),
$this->createMock(UserPasswordHasherInterface::class),
'prod'
);
self::assertFalse($sut->isEnabled()); self::assertFalse($sut->isEnabled());
} }
} }

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Command;
use App\Command\UpdateCommand;
use App\Constants;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @covers \App\Command\UpdateCommand
* @group integration
*/
class UpdateCommandTest extends KernelTestCase
{
private Application $application;
protected function getCommand(): Command
{
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();
$this->application->add(new UpdateCommand(
$container->get('doctrine')->getConnection(),
$this->application->getKernel()->getEnvironment()
));
return $this->application->find('kimai:update');
}
public function testFullRun(): void
{
$command = $this->getCommand();
$commandTester = new CommandTester($command);
$commandTester->setInputs(['no']);
$commandTester->execute([
'command' => $command->getName(),
]);
$result = $commandTester->getDisplay();
self::assertStringContainsString('Kimai updates running', $result);
// make sure migrations run always
self::assertStringContainsString('[OK] Already at the latest version ("DoctrineMigrations\\', $result);
self::assertStringContainsString(
\sprintf('[OK] Congratulations! Successfully updated Kimai to version %s', Constants::VERSION),
$result
);
self::assertEquals(0, $commandTester->getStatusCode());
}
}

View File

@@ -12,7 +12,6 @@ namespace App\Tests\Controller\Security;
use App\Configuration\SamlConfiguration; use App\Configuration\SamlConfiguration;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Controller\Security\SecurityController; use App\Controller\Security\SecurityController;
use App\DataFixtures\UserFixtures;
use App\Entity\User; use App\Entity\User;
use App\Tests\Configuration\TestConfigLoader; use App\Tests\Configuration\TestConfigLoader;
use App\Tests\Controller\ControllerBaseTest; use App\Tests\Controller\ControllerBaseTest;
@@ -64,8 +63,8 @@ class SecurityControllerTest extends ControllerBaseTest
$form = $client->getCrawler()->filter('body form')->form(); $form = $client->getCrawler()->filter('body form')->form();
$client->submit($form, [ $client->submit($form, [
'_username' => UserFixtures::USERNAME_SUPER_ADMIN, '_username' => 'susan_super',
'_password' => UserFixtures::DEFAULT_PASSWORD '_password' => 'kitten'
]); ]);
$this->assertIsRedirect($client); // redirect to root URL $this->assertIsRedirect($client); // redirect to root URL

View File

@@ -20,8 +20,8 @@ class PluginMetadataTest extends TestCase
public function testNonExistingDirectoryThrowsException(): void public function testNonExistingDirectoryThrowsException(): void
{ {
$this->expectException(\Exception::class); $this->expectException(\Exception::class);
$this->expectExceptionMessage('Bundle does not ship composer.json, which is required since 2.0.'); $this->expectExceptionMessage('Bundle "Plugin" does not ship composer.json, which is required since 2.0.');
PluginMetadata::createFromPath(__DIR__); new PluginMetadata(__DIR__);
} }
} }

View File

@@ -729,6 +729,11 @@ parameters:
count: 1 count: 1
path: Command/PromoteUserCommandTest.php path: Command/PromoteUserCommandTest.php
-
message: "#^Cannot call method getConnection\\(\\) on object\\|null\\.$#"
count: 1
path: Command/UpdateCommandTest.php
- -
message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1 count: 1

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target state="translated">لقد قمت بالتسجيل في متتبع الوقت Kimai بالبريد الإلكتروني%email%. يرجى تفعيل حسابك في الساعات القليلة القادمة ، قبل انتهاء صلاحية الرابط.</target> <target state="translated">لقد قمت بالتسجيل في متتبع الوقت Kimai بالبريد الإلكتروني%email%. يرجى تفعيل حسابك في الساعات القليلة القادمة ، قبل انتهاء صلاحية الرابط.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">إعادة تعيين كلمة المرور</target> <target state="translated">إعادة تعيين كلمة المرور</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">يمكن أن يحدث ذلك …</target> <target state="needs-translation">يمكن أن يحدث ذلك …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">إعادة تعيين كلمة المرور</target> <target state="translated">إعادة تعيين كلمة المرور</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,21 +22,21 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Zaregistrovali jste se do Kimai time-tracker s e-mailovou adresou %email%. Nyní je potřeba váš účet aktivovat v několika nejbližších hodinách, jinak odkaz expiruje.</target> <target>Zaregistrovali jste se do Kimai time-tracker s e-mailovou adresou %email%. Nyní je potřeba váš účet aktivovat v několika nejbližších hodinách, jinak odkaz expiruje.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="translated">Přihlaste se ke službě Kimai</target> <target>Reset hesla</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="translated">Přihlaste se ke svému účtu</target> <target state="translated">Zapomněli jste heslo?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="translated">Přihlásit se</target> <target>Reset hesla</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="translated">Tento odkaz bude platný pouze po dobu následujících 15 minut.</target> <target state="translated">Nebojte se, Kimai vám pomůže vytvořit nové!</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -14,17 +14,17 @@
<source>registration.button</source> <source>registration.button</source>
<target>Aktiver din konto</target> <target>Aktiver din konto</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Nulstil dit kodeord</target> <target>Nulstil dit kodeord</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Det kan ske …</target> <target state="needs-translation">Det kan ske …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Nulstil dit kodeord</target> <target>Nulstil dit kodeord</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,17 +22,17 @@
<source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source> <source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source>
<target state="translated">Sie haben sich mit der E-Mail-Adresse %email% bei der Kimai-Zeiterfassung angemeldet. Bitte aktivieren Sie Ihr Konto in den nächsten Stunden, bevor der Link abläuft.</target> <target state="translated">Sie haben sich mit der E-Mail-Adresse %email% bei der Kimai-Zeiterfassung angemeldet. Bitte aktivieren Sie Ihr Konto in den nächsten Stunden, bevor der Link abläuft.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Passwort zurücksetzen</target> <target state="translated">Passwort zurücksetzen</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Das kann passieren…</target> <target state="needs-translation">Das kann passieren…</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Passwort zurücksetzen</target> <target state="translated">Passwort zurücksetzen</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Εγγραφήκατε στο λογισμικό παρακολούθησης χρόνου Kimai με το e-mail %email%. Παρακαλούμε ενεργοποιήσετε τον λογαριασμό σας εντός μερικών ωρών, προτού λήξει ο σύνδεσμος.</target> <target>Εγγραφήκατε στο λογισμικό παρακολούθησης χρόνου Kimai με το e-mail %email%. Παρακαλούμε ενεργοποιήσετε τον λογαριασμό σας εντός μερικών ωρών, προτού λήξει ο σύνδεσμος.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Επαναφορά του κωδικού σας</target> <target>Επαναφορά του κωδικού σας</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Αυτό μπορεί να συμβεί …</target> <target state="needs-translation">Αυτό μπορεί να συμβεί …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Επαναφορά κωδικού πρόσβασης</target> <target>Επαναφορά κωδικού πρόσβασης</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Vi aliĝis al la temporegistrilo Kimai per la retadreso %email%. Bonvolu aktivigi vian konton dum la sekvaj kelkaj horoj, antaŭ ol la ligilo senvalidiĝos.</target> <target>Vi aliĝis al la temporegistrilo Kimai per la retadreso %email%. Bonvolu aktivigi vian konton dum la sekvaj kelkaj horoj, antaŭ ol la ligilo senvalidiĝos.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Restarigi vian pasvorton</target> <target>Restarigi vian pasvorton</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Tio povas okazi …</target> <target state="needs-translation">Tio povas okazi …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Restarigi vian pasvorton</target> <target>Restarigi vian pasvorton</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,21 +22,21 @@
<source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source> <source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source>
<target state="translated">Te has registrado en el cronómetro de Kimai con la dirección de correo electrónico %email%. Por favor, activa tu cuenta en las próximas horas, antes de que caduque el enlace.</target> <target state="translated">Te has registrado en el cronómetro de Kimai con la dirección de correo electrónico %email%. Por favor, activa tu cuenta en las próximas horas, antes de que caduque el enlace.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="translated">Iniciar sesión en Kimai</target> <target state="translated">Restablecer su contraseña</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="translated">Inicia sesión en tu cuenta</target> <target state="translated">¿Olvidaste tu contraseña?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="translated">Iniciar sesión</target> <target state="translated">Restablecer su contraseña</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="translated">Este enlace solo será válido durante los próximos 15 minutos.</target> <target state="translated">¡No te preocupes, Kimai te ayudará a crear uno nuevo!</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target state="translated">شما در رهگیر زمان Kimai با ایمیل %email% ثبت نام کرده اید. اکنون باید ایمیل خود را در همین زمان، قبل از باطل شدن لینک فعال کنید.</target> <target state="translated">شما در رهگیر زمان Kimai با ایمیل %email% ثبت نام کرده اید. اکنون باید ایمیل خود را در همین زمان، قبل از باطل شدن لینک فعال کنید.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">پسورد خود را ریست کنید</target> <target>پسورد خود را ریست کنید</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">آن می تواند اتفاق بیفتد …</target> <target state="needs-translation">آن می تواند اتفاق بیفتد …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">پسورد خود را ریست کنید</target> <target>پسورد خود را ریست کنید</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target state="translated">Olet rekisteröitynyt Kimai työajan-seurantaan sähköpostiosoitteella %email%. Nyt sinun tulee aktivoida tunnuksesi seuraavien tuntien aikana, ennen kuin linkki vanhenee.</target> <target state="translated">Olet rekisteröitynyt Kimai työajan-seurantaan sähköpostiosoitteella %email%. Nyt sinun tulee aktivoida tunnuksesi seuraavien tuntien aikana, ennen kuin linkki vanhenee.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Resetoi salasanasi</target> <target state="translated">Resetoi salasanasi</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Näin voi käydä …</target> <target state="needs-translation">Näin voi käydä …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Resetoi salasanasi</target> <target state="translated">Resetoi salasanasi</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,21 +22,21 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Vous vous êtes inscrit·e sur Kimai avec le courriel %email%. Veuillez activer votre compte dans les prochaines heures, avant que le lien expire.</target> <target>Vous vous êtes inscrit·e sur Kimai avec le courriel %email%. Veuillez activer votre compte dans les prochaines heures, avant que le lien expire.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Réinitialiser le mot de passe</target> <target>Réinitialiser le mot de passe</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Vous avez oublié votre mot de passe ?</target> <target state="translated">Vous avez oublié votre mot de passe ?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Réinitialiser le mot de passe</target> <target>Réinitialiser le mot de passe</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">Ne vous inquiétez pas, Kimai vous aidera à en créer un nouveau !</target> <target state="translated">Ne vous inquiétez pas, Kimai vous aidera à en créer un nouveau !</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -22,21 +22,21 @@
<source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source> <source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source>
<target state="translated">נרשמת למערכת דיווח השעות Kimai עם כתובת הדוא״ל %email%. נא להפעיל את החשבון בשעות הקרובות לפני שיפוג תוקף הקישור.</target> <target state="translated">נרשמת למערכת דיווח השעות Kimai עם כתובת הדוא״ל %email%. נא להפעיל את החשבון בשעות הקרובות לפני שיפוג תוקף הקישור.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve" approved="yes"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="final">כניסה ל־Kimai</target> <target state="translated">איפוס הסיסמה שלך</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve" approved="yes"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve" approved="yes">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="final">כניסה לחשבון שלך</target> <target state="final">שכחת את הסיסמה שלך?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve" approved="yes"> <trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve" approved="yes">
<source>reset.button</source> <source>reset.button</source>
<target state="final">כניסה</target> <target state="final">איפוס הסיסמה שלך</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve" approved="yes"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve" approved="yes">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="final">הקישור הזה יהיה תקף למשך 15 הדקות הקרובות בלבד.</target> <target state="final">אל דאגה, Kimai יעזור לך ליצור סיסמה חדשה!</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve" approved="yes"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve" approved="yes">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -22,21 +22,21 @@
<source>registration.intro</source> <source>registration.intro</source>
<target state="translated">Prijavio/prijavila si se na Kimaijevo evidentiranje vremena s e-mail adresom %email%. Aktiviraj svoj račun u idućih par sati prije nego što poveznica istekne.</target> <target state="translated">Prijavio/prijavila si se na Kimaijevo evidentiranje vremena s e-mail adresom %email%. Aktiviraj svoj račun u idućih par sati prije nego što poveznica istekne.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Obnovi lozinku</target> <target>Obnovi lozinku</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Ne sjećaš se više kako glasi lozinka?</target> <target state="translated">Ne sjećaš se više kako glasi lozinka?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Obnovi lozinku</target> <target>Obnovi lozinku</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">Ne brini, Kimai će ti pomoći stvoriti novu!</target> <target state="translated">Ne brini, Kimai će ti pomoći stvoriti novu!</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Ezzel az e-mail címmel regisztrált a Kimai időrögzítőbe: %email%. Aktiválja a fiókját pár órán belül, mielőtt a link lejár.</target> <target>Ezzel az e-mail címmel regisztrált a Kimai időrögzítőbe: %email%. Aktiválja a fiókját pár órán belül, mielőtt a link lejár.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Jelszó visszaállítása</target> <target>Jelszó visszaállítása</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Ez megtörténhet…</target> <target state="needs-translation">Ez megtörténhet…</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Jelszó visszaállítása</target> <target>Jelszó visszaállítása</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -2,17 +2,17 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2"> <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="id" datatype="plaintext" original="email.en.xlf"> <file source-language="en" target-language="id" datatype="plaintext" original="email.en.xlf">
<body> <body>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Reset kata sandi Anda</target> <target state="translated">Reset kata sandi Anda</target>
</trans-unit> </trans-unit>
<trans-unit id="pjr3FDv" resname="registration.subject"> <trans-unit id="pjr3FDv" resname="registration.subject">
<source>registration.subject</source> <source>registration.subject</source>
<target state="translated">Aktifkan akun Anda</target> <target state="translated">Aktifkan akun Anda</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Reset kata sandi Anda</target> <target state="translated">Reset kata sandi Anda</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>

View File

@@ -22,21 +22,21 @@
<source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source> <source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source>
<target state="final">Ti sei registrato al time-tracker Kimai con l'indirizzo e-mail %email%. Attiva il tuo account nelle prossime ore, prima che il collegamento scada.</target> <target state="final">Ti sei registrato al time-tracker Kimai con l'indirizzo e-mail %email%. Attiva il tuo account nelle prossime ore, prima che il collegamento scada.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" approved="yes" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject" approved="yes">
<source>reset.subject</source> <source>reset.subject</source>
<target state="final">Accedi a Kimai</target> <target state="final">Reimposta password</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" approved="yes" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" approved="yes" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="final">Accedi al tuo account</target> <target state="final">Hai dimenticato la password?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" approved="yes" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button" approved="yes">
<source>reset.button</source> <source>reset.button</source>
<target state="final">Registrazione</target> <target state="final">Reimposta password</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" approved="yes" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" approved="yes" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="final">Questo link sarà valido solo per i prossimi 15 minuti.</target> <target state="final">Non preoccuparti, Kimai ti aiuterà a crearne una nuova!</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" approved="yes"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" approved="yes">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>이메일 %email%로 Kimai 시간 트래커에 등록했습니다. 이제 링크가 만료되기 전에 사용자 계정을 활성화해야합니다.</target> <target>이메일 %email%로 Kimai 시간 트래커에 등록했습니다. 이제 링크가 만료되기 전에 사용자 계정을 활성화해야합니다.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">사용자 비밀번호 재설정</target> <target>사용자 비밀번호 재설정</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">그럴 수 있습니다…</target> <target state="needs-translation">그럴 수 있습니다…</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">사용자 비밀번호 재설정</target> <target>사용자 비밀번호 재설정</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Du har registrert deg på Kimai-tidssporer med e-posten %email%. Aktiver kontoen din i løpet av de neste timene før lenken utløper.</target> <target>Du har registrert deg på Kimai-tidssporer med e-posten %email%. Aktiver kontoen din i løpet av de neste timene før lenken utløper.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Tilbakestill passordet</target> <target>Tilbakestill passordet</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Det kan skje …</target> <target state="needs-translation">Det kan skje …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Tilbakestill passordet</target> <target>Tilbakestill passordet</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,21 +22,21 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>U hebt zich aangemeld bij de Kimai tijdregistratie met de e-mail %email%. Activeer uw account alstublieft in de komende uren, voordat de link afloopt.</target> <target>U hebt zich aangemeld bij de Kimai tijdregistratie met de e-mail %email%. Activeer uw account alstublieft in de komende uren, voordat de link afloopt.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Reset uw wachtwoord</target> <target>Reset uw wachtwoord</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Paswoord vergeten?</target> <target state="translated">Paswoord vergeten?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Reset uw wachtwoord</target> <target>Reset uw wachtwoord</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">Maakt u zich geen zorgen, Kimai zal u helpen om er een nieuw te maken!</target> <target state="translated">Maakt u zich geen zorgen, Kimai zal u helpen om er een nieuw te maken!</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target state="translated">Zarejestrowałeś się w w rejestratorze czasu Kimai za pomocą e-maila %email%. Aktywuj konto w ciągu kilku następnych godzin, zanim link wygaśnie.</target> <target state="translated">Zarejestrowałeś się w w rejestratorze czasu Kimai za pomocą e-maila %email%. Aktywuj konto w ciągu kilku następnych godzin, zanim link wygaśnie.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Zresetuj swoje hasło</target> <target>Zresetuj swoje hasło</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">To może się zdarzyć…</target> <target state="needs-translation">To może się zdarzyć…</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Zresetuj swoje hasło</target> <target>Zresetuj swoje hasło</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -16,19 +16,19 @@
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">Não se preocupe, o Kimai vai ajudá-lo a criar uma nova!</target> <target state="translated">Não se preocupe, o Kimai vai ajudá-lo a criar uma nova!</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Redefinir a palavra-passe</target> <target>Redefinir a palavra-passe</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Esqueceu-se da palavra-passe?</target> <target state="translated">Esqueceu-se da palavra-passe?</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Redefinir a palavra-passe</target> <target>Redefinir a palavra-passe</target>
</trans-unit> </trans-unit>
<trans-unit id="IkjnHoP" resname="registration.intro" xml:space="preserve"> <trans-unit id="IkjnHoP" resname="registration.intro" xml:space="preserve">
<source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source> <source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source>

View File

@@ -22,21 +22,21 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Você se inscreveu no rastreador de tempo Kimai com o e-mail %email%. ative a sua conta nas próximas horas, antes que o link expire.</target> <target>Você se inscreveu no rastreador de tempo Kimai com o e-mail %email%. ative a sua conta nas próximas horas, antes que o link expire.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="translated">Faça login no Kimai</target> <target>Redefina a sua senha</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="translated">Faça login em sua conta</target> <target state="translated">Esqueceu a sua senha?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="translated">Entrar</target> <target>Redefina a sua senha</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="translated">Esse link tem validade para os próximos 15 minutos.</target> <target state="translated">Não se preocupe, o Kimai vai ajudá-lo a criar uma nova!</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Te-ai înscris la Kimai time-tracker cu adresa de e-mail %email%. Vă rugăm să vă activați contul în următoarele câteva ore, înainte ca link-ul să expire.</target> <target>Te-ai înscris la Kimai time-tracker cu adresa de e-mail %email%. Vă rugăm să vă activați contul în următoarele câteva ore, înainte ca link-ul să expire.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Resetează-ți parola</target> <target>Resetează-ți parola</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Asta se poate întâmpla …</target> <target state="needs-translation">Asta se poate întâmpla …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Resetează-ți parola</target> <target>Resetează-ți parola</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Вы зарегистрировались в Kimai time-tracker с электронным адресом %email%. Пожалуйста, активируйте свою учетную запись в течение нескольких часов, пока ссылка не истекла.</target> <target>Вы зарегистрировались в Kimai time-tracker с электронным адресом %email%. Пожалуйста, активируйте свою учетную запись в течение нескольких часов, пока ссылка не истекла.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Сбросить пароль</target> <target>Сбросить пароль</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Это может произойти …</target> <target state="needs-translation">Это может произойти …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Сбросить пароль</target> <target>Сбросить пароль</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Prihlásili ste sa do Kimai sledovanie času s emailom %email%. Teraz si musíte Váš účet aktivovať v priebeh niekoľkých hodín predtým než vyprší platnosť linku.</target> <target>Prihlásili ste sa do Kimai sledovanie času s emailom %email%. Teraz si musíte Váš účet aktivovať v priebeh niekoľkých hodín predtým než vyprší platnosť linku.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Resetujte si heslo</target> <target>Resetujte si heslo</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">To sa môže stať…</target> <target state="needs-translation">To sa môže stať…</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Resetovať heslo</target> <target>Resetovať heslo</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,17 +22,17 @@
<source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source> <source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source>
<target state="translated">Du har registrerat dig hos Kimai tidrapportering med e-postadressen %email%. Vänligen aktivera ditt konto inom de närmaste timmarna, innan länken går ut och blir ogiltig.</target> <target state="translated">Du har registrerat dig hos Kimai tidrapportering med e-postadressen %email%. Vänligen aktivera ditt konto inom de närmaste timmarna, innan länken går ut och blir ogiltig.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Återställ ditt lösenord</target> <target state="translated">Återställ ditt lösenord</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Det kan hända …</target> <target state="needs-translation">Det kan hända …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Återställ ditt lösenord</target> <target state="translated">Återställ ditt lösenord</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,21 +22,21 @@
<source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source> <source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source>
<target state="translated">Kimai zaman izleyicisine %email% e-posta adresiyle kaydoldunuz. Lütfen bağlantının süresi dolmadan birkaç saat içinde hesabınızı etkinleştirin.</target> <target state="translated">Kimai zaman izleyicisine %email% e-posta adresiyle kaydoldunuz. Lütfen bağlantının süresi dolmadan birkaç saat içinde hesabınızı etkinleştirin.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="translated">Kimai'de oturum açın</target> <target>Parolanızı sıfırlayın</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="translated">Hesabınızda oturum açın</target> <target state="translated">Parolanızı mı unuttunuz?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="translated">Oturum aç</target> <target>Parolanızı sıfırlayın</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="translated">Bu bağlantı yalnızca önümüzdeki 15 dakika boyunca geçerli olacaktır.</target> <target state="translated">Endişelenmeyin, Kimai yeni bir tane oluşturmanıza yardım edecek!</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -22,17 +22,17 @@
<source>registration.intro</source> <source>registration.intro</source>
<target state="translated">Ви зареєструвалися у реєстраторі часу Kimai з електронною поштою %email%. Будь ласка, активуйте свій обліковий запис у найближчі кілька годин, доки не сплив термін дії посилання.</target> <target state="translated">Ви зареєструвалися у реєстраторі часу Kimai з електронною поштою %email%. Будь ласка, активуйте свій обліковий запис у найближчі кілька годин, доки не сплив термін дії посилання.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Скинути пароль</target> <target state="translated">Скинути пароль</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Таке може статися …</target> <target state="needs-translation">Таке може статися …</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Скинути пароль</target> <target state="translated">Скинути пароль</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -22,21 +22,21 @@
<source>registration.intro</source> <source>registration.intro</source>
<target>Bạn đã đăng ký theo dõi thời gian Kimai với email %email%. Bây giờ bạn phải kích hoạt tài khoản của mình trong vài giờ tới, trước khi liên kết hết hạn.</target> <target>Bạn đã đăng ký theo dõi thời gian Kimai với email %email%. Bây giờ bạn phải kích hoạt tài khoản của mình trong vài giờ tới, trước khi liên kết hết hạn.</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">Đặt lại mật khẩu của bạn</target> <target>Đặt lại mật khẩu của bạn</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">Quên mật khẩu?</target> <target state="translated">Quên mật khẩu?</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">Đặt lại mật khẩu của bạn</target> <target>Đặt lại mật khẩu của bạn</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">Đừng lo, Kimai sẽ giúp bạn tạo mật khẩu mới!</target> <target state="translated">Đừng lo, Kimai sẽ giúp bạn tạo mật khẩu mới!</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve"> <trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve">
<source>absence_created_supervisor_subject</source> <source>absence_created_supervisor_subject</source>

View File

@@ -10,9 +10,9 @@
<source>registration.title</source> <source>registration.title</source>
<target>欢迎 %username%</target> <target>欢迎 %username%</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve"> <trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">重置您的密码</target> <target>重置您的密码</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
@@ -30,9 +30,9 @@
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">这可能发生…</target> <target state="needs-translation">这可能发生…</target>
</trans-unit> </trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve"> <trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">重置您的密码</target> <target state="translated">重置您的密码</target>
</trans-unit> </trans-unit>
<trans-unit id="IkjnHoP" resname="registration.intro"> <trans-unit id="IkjnHoP" resname="registration.intro">
<source>registration.intro</source> <source>registration.intro</source>

View File

@@ -2,21 +2,21 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2"> <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="zh-Hant" datatype="plaintext" original="email.en.xlf"> <file source-language="en" target-language="zh-Hant" datatype="plaintext" original="email.en.xlf">
<body> <body>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve" approved="no"> <trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve" approved="yes">
<source>reset.button</source> <source>reset.button</source>
<target state="needs-translation">重設您的密碼</target> <target state="final">重設您的密碼</target>
</trans-unit> </trans-unit>
<trans-unit id="pjr3FDv" resname="registration.subject" xml:space="preserve" approved="yes"> <trans-unit id="pjr3FDv" resname="registration.subject" xml:space="preserve" approved="yes">
<source>registration.subject</source> <source>registration.subject</source>
<target state="final">啟用您的帳號</target> <target state="final">啟用您的帳號</target>
</trans-unit> </trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve" approved="no"> <trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve" approved="yes">
<source>reset.subject</source> <source>reset.subject</source>
<target state="needs-translation">重設您的密碼</target> <target state="final">重設您的密碼</target>
</trans-unit> </trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve" approved="no"> <trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve" approved="yes">
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target state="needs-translation">忘記密碼了嗎?</target> <target state="final">忘記密碼了嗎?</target>
</trans-unit> </trans-unit>
<trans-unit id="YMLel3P" resname="registration.button" xml:space="preserve" approved="yes"> <trans-unit id="YMLel3P" resname="registration.button" xml:space="preserve" approved="yes">
<source>registration.button</source> <source>registration.button</source>
@@ -30,9 +30,9 @@
<source>registration.title</source> <source>registration.title</source>
<target state="final">歡迎 %username%</target> <target state="final">歡迎 %username%</target>
</trans-unit> </trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve" approved="no"> <trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve" approved="yes">
<source>Don't worry, Kimai will help you create a new one!</source> <source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">別擔心,Kimai 將協助您設定新的密碼!</target> <target state="final">別擔心,Kimai 將協助您設定新的密碼!</target>
</trans-unit> </trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer" xml:space="preserve" approved="yes"> <trans-unit id="8jDJmrK" resname="automated_email_dont_answer" xml:space="preserve" approved="yes">
<source>automated_email_dont_answer</source> <source>automated_email_dont_answer</source>

View File

@@ -594,9 +594,9 @@
<source>tag</source> <source>tag</source>
<target>الوسوم</target> <target>الوسوم</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">الميزانية الزمنية</target> <target>الميزانية الزمنية</target>
</trans-unit> </trans-unit>
<trans-unit id="hlu4iX5" resname="error.no_comments_found"> <trans-unit id="hlu4iX5" resname="error.no_comments_found">
<source>error.no_comments_found</source> <source>error.no_comments_found</source>

View File

@@ -178,9 +178,9 @@
<source>budget</source> <source>budget</source>
<target>Rozpočet</target> <target>Rozpočet</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="translated">Hodinová kvóta</target> <target>Hodinový rozpočet</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>
@@ -1790,14 +1790,6 @@
<source>Updated</source> <source>Updated</source>
<target state="translated">Aktualizováno</target> <target state="translated">Aktualizováno</target>
</trans-unit> </trans-unit>
<trans-unit id="0jvo0RQ" resname="profit" xml:space="preserve">
<source>profit</source>
<target state="translated">Zisk</target>
</trans-unit>
<trans-unit id="vN5pKOU" resname="costs" xml:space="preserve">
<source>costs</source>
<target state="translated">Náklady</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -198,9 +198,9 @@
<source>budget</source> <source>budget</source>
<target>Budget</target> <target>Budget</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">Tidsbudget</target> <target>Tidsbudget</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>

View File

@@ -1798,10 +1798,6 @@
<source>costs</source> <source>costs</source>
<target>Kosten</target> <target>Kosten</target>
</trans-unit> </trans-unit>
<trans-unit id="FOvlalA" resname="break">
<source>break</source>
<target>Pause</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -734,9 +734,9 @@
<source>activity</source> <source>activity</source>
<target>Tätigkeit</target> <target>Tätigkeit</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">Zeit-Budget</target> <target>Zeit-Budget</target>
</trans-unit> </trans-unit>
<trans-unit id="CvlqjtY" resname="budget"> <trans-unit id="CvlqjtY" resname="budget">
<source>budget</source> <source>budget</source>

View File

@@ -258,9 +258,9 @@
<source>budget</source> <source>budget</source>
<target>Προϋπολογισμός</target> <target>Προϋπολογισμός</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">Χρονικός Προϋπολογισμός</target> <target>Χρονικός Προϋπολογισμός</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>

View File

@@ -1798,10 +1798,6 @@
<source>costs</source> <source>costs</source>
<target>Costs</target> <target>Costs</target>
</trans-unit> </trans-unit>
<trans-unit id="FOvlalA" resname="break">
<source>break</source>
<target>Break</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -218,9 +218,9 @@
<source>budget</source> <source>budget</source>
<target>Buĝeto</target> <target>Buĝeto</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">Tempobuĝeto</target> <target>Tempobuĝeto</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>

View File

@@ -196,7 +196,7 @@
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve">
<source>timeBudget</source> <source>timeBudget</source>
<target state="translated">Precio por hora</target> <target state="translated">Presupuesto de tiempo</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>
@@ -1790,18 +1790,6 @@
<source>hours_per_day</source> <source>hours_per_day</source>
<target state="translated">Horas diarias</target> <target state="translated">Horas diarias</target>
</trans-unit> </trans-unit>
<trans-unit id="0jvo0RQ" resname="profit" xml:space="preserve">
<source>profit</source>
<target state="translated">Ganancia</target>
</trans-unit>
<trans-unit id="vN5pKOU" resname="costs" xml:space="preserve">
<source>costs</source>
<target state="translated">Costes</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break" xml:space="preserve">
<source>break</source>
<target state="translated">Pausa</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -206,9 +206,9 @@
<source>budget</source> <source>budget</source>
<target>Aurrekontua</target> <target>Aurrekontua</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">Ordu-poltsa</target> <target>Ordu-poltsa</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>

View File

@@ -246,9 +246,9 @@
<source>activity</source> <source>activity</source>
<target>فعالیت</target> <target>فعالیت</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">بودجه زمان</target> <target>بودجه زمان</target>
</trans-unit> </trans-unit>
<trans-unit id="CvlqjtY" resname="budget"> <trans-unit id="CvlqjtY" resname="budget">
<source>budget</source> <source>budget</source>

View File

@@ -222,9 +222,9 @@
<source>budget</source> <source>budget</source>
<target state="translated">Budjetti</target> <target state="translated">Budjetti</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">Aika budjetti</target> <target state="translated">Aika budjetti</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>

View File

@@ -130,9 +130,9 @@
<source>budget</source> <source>budget</source>
<target>Fíggjarætlan</target> <target>Fíggjarætlan</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">Tíðarætlan</target> <target>Tíðarætlan</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>

View File

@@ -174,9 +174,9 @@
<source>budget</source> <source>budget</source>
<target>Budget</target> <target>Budget</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">Budget temps</target> <target>Budget temps</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>

View File

@@ -218,9 +218,9 @@
<source>budget</source> <source>budget</source>
<target>תקציב</target> <target>תקציב</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve" approved="yes"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="final">מכסה שעתית</target> <target>תקציב זמן</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>
@@ -1790,18 +1790,6 @@
<source>work_hours_mode</source> <source>work_hours_mode</source>
<target state="final">חישוב זמן עבודה</target> <target state="final">חישוב זמן עבודה</target>
</trans-unit> </trans-unit>
<trans-unit id="vN5pKOU" resname="costs" xml:space="preserve" approved="yes">
<source>costs</source>
<target state="final">עלויות</target>
</trans-unit>
<trans-unit id="0jvo0RQ" resname="profit" xml:space="preserve" approved="yes">
<source>profit</source>
<target state="final">רווח</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break" xml:space="preserve" approved="yes">
<source>break</source>
<target state="final">הפסקה</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -270,9 +270,9 @@
<source>budget</source> <source>budget</source>
<target>Budžet</target> <target>Budžet</target>
</trans-unit> </trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve"> <trans-unit id="qbD0dHa" resname="timeBudget">
<source>timeBudget</source> <source>timeBudget</source>
<target state="needs-translation">Vremenski budžet</target> <target>Vremenski budžet</target>
</trans-unit> </trans-unit>
<trans-unit id="BlGGO.X" resname="activity"> <trans-unit id="BlGGO.X" resname="activity">
<source>activity</source> <source>activity</source>

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