Locales: use http-accept-language or cookie (#976)

* Locales: use http-accept-language or "language"-cookie

* Add user-setting "locale"

Rename CULTURE to DEFAULT_LOCALE

* Use LocaleMiddleware also in dev mode

* CORS: don't require authentication on OPTIONS

* Use a standard user-settings-control and start a new generic user settings page, not a separate page for the locale setting

* Fixed (broken by myself) link-return handling

* Clarify language settings

* Removed unneeded files

* Better user settings icon

* Added localization hints

Co-authored-by: Bernd Bestel <bernd@berrnd.de>
This commit is contained in:
fipwmaqzufheoxq92ebc 2020-08-31 19:11:51 +02:00 committed by GitHub
parent 4a030b7ffc
commit 6f8ad9b76e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 383 additions and 20 deletions

View File

@ -7,4 +7,5 @@ copy /Y localization\en\component_translations.po localization\en_GB\component_t
copy /Y localization\en\chore_period_types.po localization\en_GB\chore_period_types.po
copy /Y localization\en\chore_assignment_types.po localization\en_GB\chore_assignment_types.po
copy /Y localization\en\permissions.po localization\en_GB\permissions.po
copy /Y localization\en\locales.po localization\en_GB\locales.po
popd

View File

@ -48,3 +48,9 @@ file_filter = localization/<lang>/permissions.po
source_file = localization/permissions.pot
source_lang = en
type = PO
[grocy.locales]
file_filter = localization/<lang>/locales.po
source_file = localization/locales.pot
source_lang = en
type = PO

View File

@ -43,7 +43,7 @@ If you run grocy on Linux, there is also `update.sh` (remember to make the scrip
## Localization
grocy is fully localizable - the default language is English (integrated into code), a German localization is always maintained by me.
You can easily help translating grocy at https://www.transifex.com/grocy/grocy, if your language is incomplete or not available yet.
(Language can be changed in `data/config.php`, e. g. `Setting('CULTURE', 'it');`)
(The default language can be set in `data/config.php`, e. g. `Setting('DEFAULT_LOCALE', 'it');` and there is also a user setting (see the user settings page) to set a different language per user)
The [pre-release demo](https://demo-prerelease.grocy.info) is available for any translation which is at least 80 % complete and will pull the translations from Transifex 10 minutes past every hour, so you can have a kind of instant preview of your contributed translations. Thank you!

12
app.php
View File

@ -1,5 +1,6 @@
<?php
use Grocy\Middleware\CorsMiddleware;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Container\ContainerInterface as Container;
@ -63,6 +64,16 @@ if (!empty(GROCY_BASE_PATH))
$app->setBasePath(GROCY_BASE_PATH);
}
if (GROCY_MODE === 'production' || GROCY_MODE === 'dev')
{
$app->add(new \Grocy\Middleware\LocaleMiddleware($container));
}
else {
define('GROCY_LOCALE', GROCY_DEFAULT_LOCALE);
}
$authMiddlewareClass = GROCY_AUTH_CLASS;
$app->add(new $authMiddlewareClass($container, $app->getResponseFactory()));
// Add default middleware
$app->addRoutingMiddleware();
$errorMiddleware = $app->addErrorMiddleware(true, false, false);
@ -70,4 +81,5 @@ $errorMiddleware->setDefaultErrorHandler(
new \Grocy\Controllers\ExceptionController($app, $container)
);
$app->add(new CorsMiddleware($app->getResponseFactory()));
$app->run();

View File

@ -21,7 +21,7 @@ Setting('MODE', 'production');
# Either "en" or "de" or the directory name of
# one of the other available localization folders in the "/localization" directory
Setting('CULTURE', 'en');
Setting('DEFAULT_LOCALE', 'en');
# This is used to define the first day of a week for calendar views in the frontend,
# leave empty to use the locale default

View File

@ -110,7 +110,7 @@ class BaseController
protected function getLocalizationService()
{
return LocalizationService::getInstance(GROCY_CULTURE);
return LocalizationService::getInstance(GROCY_LOCALE);
}
protected function getApplicationservice()

View File

@ -44,4 +44,16 @@ class UsersController extends BaseController
->where('parent IS NULL')->where('user_id', $args['userId']),
]);
}
public function UserSettings(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
return $this->renderPage($response, 'usersettings', [
'languages' => array_filter(scandir(__DIR__.'/../localization'), function ($item){
if($item == "." || $item == "..")
return false;
return is_dir(__DIR__.'/../localization/'.$item);
})
]);
}
}

View File

@ -0,0 +1,79 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: http://www.transifex.com/grocy/grocy/language/en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2019-05-01T17:59:17+00:00\n"
"PO-Revision-Date: 2019-05-01T17:59:17+00:00\n"
"Language: en\n"
"X-Domain: grocy/locales\n"
msgid "cs"
msgstr "Czech"
msgid "da"
msgstr "Danish"
msgid "de"
msgstr "German"
msgid "el_GR"
msgstr "Greek"
msgid "en"
msgstr "English"
msgid "en_GB"
msgstr "English (Great Britain)"
msgid "es"
msgstr "Spanish"
msgid "fr"
msgstr "French"
msgid "hu"
msgstr "Hungarian"
msgid "it"
msgstr "Italian"
msgid "ja"
msgstr "Japanese"
msgid "ko_KR"
msgstr "Korean"
msgid "nl"
msgstr "Dutch"
msgid "no"
msgstr "Norwegian"
msgid "pl"
msgstr "Polish"
msgid "pt_BR"
msgstr "Portuguese (Brazil)"
msgid "pt_PT"
msgstr "Portuguese (Portugal)"
msgid "ru"
msgstr "Russian"
msgid "sk_SK"
msgstr "Slovak"
msgid "sv_SE"
msgstr "Swedish"
msgid "tr"
msgstr "Turkish"
msgid "zh_TW"
msgstr "Chinese (Taiwan)"

101
localization/locales.pot Normal file
View File

@ -0,0 +1,101 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: http://www.transifex.com/grocy/grocy/language/en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2019-05-01T17:59:17+00:00\n"
"PO-Revision-Date: 2019-05-01T17:59:17+00:00\n"
"Language: en\n"
"X-Domain: grocy/locales\n"
# Czech
msgid "cs"
msgstr ""
# Danish
msgid "da"
msgstr ""
# German
msgid "de"
msgstr ""
# Greek
msgid "el_GR"
msgstr ""
# English
msgid "en"
msgstr ""
# English (Great Britain)
msgid "en_GB"
msgstr ""
# Spanish
msgid "es"
msgstr ""
# French
msgid "fr"
msgstr ""
# Hungarian
msgid "hu"
msgstr ""
# Italian
msgid "it"
msgstr ""
# Japanese
msgid "ja"
msgstr ""
# Korean
msgid "ko_KR"
msgstr ""
# Dutch
msgid "nl"
msgstr ""
# Norwegian
msgid "no"
msgstr ""
# Polish
msgid "pl"
msgstr ""
# Portuguese (Brazil)
msgid "pt_BR"
msgstr ""
# Portuguese (Portugal)
msgid "pt_PT"
msgstr ""
# Russian
msgid "ru"
msgstr ""
# Slovak
msgid "sk_SK"
msgstr ""
# Swedish
msgid "sv_SE"
msgstr ""
# Turkish
msgid "tr"
msgstr ""
# Chinese (Taiwan)
msgid "zh_TW"
msgstr ""

View File

@ -1891,3 +1891,12 @@ msgstr ""
msgid "If you think this is a bug, please report it"
msgstr ""
msgid "Language"
msgstr ""
msgid "User settings"
msgstr ""
msgid "Default"
msgstr ""

View File

@ -2,28 +2,40 @@
namespace Grocy\Middleware;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Routing\RouteContext;
class CorsMiddleware extends BaseMiddleware
class CorsMiddleware
{
/**
* @var ResponseFactoryInterface
*/
private $responseFactory;
public function __construct(ResponseFactoryInterface $responseFactory)
{
$this->responseFactory = $responseFactory;
}
public function __invoke(Request $request, RequestHandler $handler): Response
{
if ($request->getMethod() == "OPTIONS")
$response = $this->responseFactory->createResponse(200);
else {
$response = $handler->handle($request);
}
//$routeContext = RouteContext::fromRequest($request);
//$routingResults = $routeContext->getRoutingResults();
//$methods = $routingResults->getAllowedMethods();
//$requestHeaders = $request->getHeaderLine('Access-Control-Request-Headers');
$response = $handler->handle($request);
$response = $response->withHeader('Access-Control-Allow-Origin', '*');
$response = $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response = $response->withHeader('Access-Control-Allow-Headers', '*');
$response = $response->withStatus(204);
return $response;
}

View File

@ -0,0 +1,67 @@
<?php
namespace Grocy\Middleware;
use Grocy\Services\UsersService;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
class LocaleMiddleware extends BaseMiddleware
{
public function __invoke(Request $request, RequestHandler $handler): Response
{
$locale = $this->getLocale($request);
define('GROCY_LOCALE', $locale);
return $handler->handle($request);
}
protected function getLocale(Request $request)
{
if(GROCY_AUTHENTICATED)
{
$locale = UsersService::getInstance()->GetUserSetting(GROCY_USER_ID, 'locale');
if (isset($locale) && !empty($locale)) {
if (in_array($locale, scandir(__DIR__ . '/../localization'))) {
return $locale;
}
}
}
$langs = join(',', $request->getHeader('Accept-Language'));
// src: https://gist.github.com/spolischook/0cde9c6286415cddc088
$prefLocales = array_reduce(
explode(',', $langs),
function ($res, $el) {
list($l, $q) = array_merge(explode(';q=', $el), [1]);
$res[$l] = (float) $q;
return $res;
}, []);
arsort($prefLocales);
$availableLocales = scandir(__DIR__ . '/../localization');
foreach ($prefLocales as $locale => $q) {
if(in_array($locale, $availableLocales))
{
return $locale;
}
// e.g. en_GB
if(in_array(substr($locale, 0, 5), $availableLocales))
{
return substr($locale, 0, 5);
}
// e.g: cs
// or en
// or de
if(in_array(substr($locale, 0, 2), $availableLocales))
{
return substr($locale, 0, 2);
}
}
return GROCY_DEFAULT_LOCALE;
}
}

View File

@ -670,3 +670,29 @@ $(Grocy.UserPermissions).each(function(index, item)
$('.permission-' + item.permission_name).addClass('disabled').addClass('not-allowed');
}
});
$('a.link-return').not(".btn").each(function () {
var base = $(this).data('href');
if(base.contains('?'))
{
$(this).attr('href', base + '&returnto' + encodeURIComponent(location.pathname));
}
else{
$(this).attr('href', base + '?returnto=' + encodeURIComponent(location.pathname));
}
})
$(document).on("click", "a.btn.link-return", function(e)
{
e.preventDefault();
var link = GetUriParam("returnto");
if (!link || !link.length > 0)
{
location.href = $(e.currentTarget).attr("href");
}
else
{
location.href = U(link);
}
});

View File

@ -0,0 +1 @@
$("#locale").val(Grocy.UserSettings.locale);

View File

@ -6,9 +6,7 @@ use Psr\Http\Message\ResponseInterface as Response;
use Slim\Routing\RouteCollectorProxy;
use Grocy\Middleware\JsonMiddleware;
use Grocy\Middleware\CorsMiddleware;
$authMiddlewareClass = GROCY_AUTH_CLASS;
$app->group('', function(RouteCollectorProxy $group)
{
@ -34,6 +32,7 @@ $app->group('', function(RouteCollectorProxy $group)
$group->get('/users', '\Grocy\Controllers\UsersController:UsersList');
$group->get('/user/{userId}', '\Grocy\Controllers\UsersController:UserEditForm');
$group->get('/user/{userId}/permissions', '\Grocy\Controllers\UsersController:PermissionList');
$group->get('/usersettings', '\Grocy\Controllers\UsersController:UserSettings');
// Stock routes
if (GROCY_FEATURE_FLAG_STOCK)
@ -136,7 +135,7 @@ $app->group('', function(RouteCollectorProxy $group)
$group->get('/api', '\Grocy\Controllers\OpenApiController:DocumentationUi');
$group->get('/manageapikeys', '\Grocy\Controllers\OpenApiController:ApiKeysList');
$group->get('/manageapikeys/new', '\Grocy\Controllers\OpenApiController:CreateNewApiKey');
})->add(new $authMiddlewareClass($container, $app->getResponseFactory()));
});
$app->group('/api', function(RouteCollectorProxy $group)
{
@ -259,11 +258,10 @@ $app->group('/api', function(RouteCollectorProxy $group)
$group->get('/calendar/ical', '\Grocy\Controllers\CalendarApiController:Ical')->setName('calendar-ical');
$group->get('/calendar/ical/sharing-link', '\Grocy\Controllers\CalendarApiController:IcalSharingLink');
}
})->add(JsonMiddleware::class)
->add(new $authMiddlewareClass($container, $app->getResponseFactory()));
})->add(JsonMiddleware::class);
// Handle CORS preflight OPTIONS requests
$app->options('/api/{routes:.+}', function(Request $request, Response $response): Response
{
return $response;
})->add(CorsMiddleware::class);
return $response->withStatus(204);
});

View File

@ -35,7 +35,7 @@ class BaseService
protected function getLocalizationService()
{
return LocalizationService::getInstance(GROCY_CULTURE);
return LocalizationService::getInstance(GROCY_LOCALE);
}
protected function getStockservice()

View File

@ -22,7 +22,7 @@ class DatabaseService
{
if (GROCY_MODE === 'demo' || GROCY_MODE === 'prerelease')
{
return GROCY_DATAPATH . '/grocy_' . GROCY_CULTURE . '.db';
return GROCY_DATAPATH . '/grocy_' . GROCY_DEFAULT_LOCALE . '.db';
}
return GROCY_DATAPATH . '/grocy.db';

View File

@ -9,7 +9,7 @@ class DemoDataGeneratorService extends BaseService
public function __construct()
{
parent::__construct();
$this->LocalizationService = new LocalizationService(GROCY_CULTURE);
$this->LocalizationService = new LocalizationService(GROCY_DEFAULT_LOCALE);
}
protected $LocalizationService;

View File

@ -61,6 +61,8 @@ class LocalizationService
$this->Pot = $this->Pot->mergeWith(Translations::fromPoFile(__DIR__ . '/../localization/strings.pot'));
$this->Pot = $this->Pot->mergeWith(Translations::fromPoFile(__DIR__ . '/../localization/userfield_types.pot'));
$this->Pot = $this->Pot->mergeWith(Translations::fromPoFile(__DIR__ . '/../localization/permissions.pot'));
$this->Pot = $this->Pot->mergeWith(Translations::fromPoFile(__DIR__ . '/../localization/locales.pot'));
if (GROCY_MODE !== 'production')
{
@ -96,6 +98,10 @@ class LocalizationService
{
$this->Po = $this->Po->mergeWith(Translations::fromPoFile(__DIR__ . "/../localization/$culture/permissions.po"));
}
if (file_exists(__DIR__ . "/../localization/$culture/locales.po"))
{
$this->Po = $this->Po->mergeWith(Translations::fromPoFile(__DIR__ . "/../localization/$culture/locales.po"));
}
if (GROCY_MODE !== 'production' && file_exists(__DIR__ . "/../localization/$culture/demo_data.po"))
{
$this->Po = $this->Po->mergeWith(Translations::fromPoFile(__DIR__ . "/../localization/$culture/demo_data.po"));

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ GROCY_CULTURE }}">
<html lang="{{ GROCY_LOCALE }}">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
@ -52,7 +52,7 @@
Grocy.BaseUrl = '{{ $U('/') }}';
Grocy.CurrentUrlRelative = "/" + window.location.href.split('?')[0].replace(Grocy.BaseUrl, "");
Grocy.ActiveNav = '@yield('activeNav', '')';
Grocy.Culture = '{{ GROCY_CULTURE }}';
Grocy.Culture = '{{ GROCY_LOCALE }}';
Grocy.Currency = '{{ GROCY_CURRENCY }}';
Grocy.CalendarFirstDayOfWeek = '{{ GROCY_CALENDAR_FIRST_DAY_OF_WEEK }}';
Grocy.CalendarShowWeekNumbers = {{ BoolToString(GROCY_CALENDAR_SHOW_WEEK_OF_YEAR) }};
@ -416,6 +416,9 @@
@if(GROCY_FEATURE_FLAG_TASKS)
<a class="dropdown-item discrete-link permission-TASKS" href="{{ $U('/taskssettings') }}"><i class="fas fa-tasks"></i>&nbsp;{{ $__t('Tasks settings') }}</a>
@endif
<a data-href="{{ $U('/usersettings') }}" class="dropdown-item discrete-link link-return">
<i class="fas fa-user-cog"></i> {{ $__t('User settings') }}
</a>
<div class="dropdown-divider"></div>
@if(GROCY_SHOW_AUTH_VIEWS)
<a class="dropdown-item discrete-link permission-USERS_READ" href="{{ $U('/users') }}"><i class="fas fa-users"></i>&nbsp;{{ $__t('Manage users') }}</a>

View File

@ -0,0 +1,30 @@
@extends('layout.default')
@section('title', $__t('User settings'))
@section('activeNav', '')
@section('viewJsName', 'usersettings')
@section('content')
<div class="row">
<div class="col">
<h2 class="title">@yield('title')</h2>
<hr>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-xs-12">
<div class="form-group">
<label for="locale">{{ $__t('Language') }}</label>
<select class="form-control user-setting-control" id="locale" data-setting-key="locale">
<option value="">{{ $__t('Default') }}</option>
@foreach($languages as $lang)
<option value="{{ $lang }}" @if(GROCY_LOCALE == $lang) checked @endif>{{ $__t($lang) }}</option>
@endforeach
</select>
</div>
<a href="{{ $U('/') }}" class="btn btn-success link-return">{{ $__t('OK') }}</a>
</div>
</div>
@stop