diff --git a/app/Access/Notifications/UserInviteNotification.php b/app/Access/Notifications/UserInviteNotification.php index b453fc95d..dacce574f 100644 --- a/app/Access/Notifications/UserInviteNotification.php +++ b/app/Access/Notifications/UserInviteNotification.php @@ -16,12 +16,12 @@ class UserInviteNotification extends MailNotification public function toMail(User $notifiable): MailMessage { $appName = ['appName' => setting('app-name')]; - $language = $notifiable->getLanguage(); + $locale = $notifiable->getLocale(); - return $this->newMailMessage($language) - ->subject(trans('auth.user_invite_email_subject', $appName, $language)) - ->greeting(trans('auth.user_invite_email_greeting', $appName, $language)) - ->line(trans('auth.user_invite_email_text', [], $language)) - ->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token)); + return $this->newMailMessage($locale) + ->subject($locale->trans('auth.user_invite_email_subject', $appName)) + ->greeting($locale->trans('auth.user_invite_email_greeting', $appName)) + ->line($locale->trans('auth.user_invite_email_text')) + ->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token)); } } diff --git a/app/Activity/Notifications/Messages/BaseActivityNotification.php b/app/Activity/Notifications/Messages/BaseActivityNotification.php index 414859091..322df5d94 100644 --- a/app/Activity/Notifications/Messages/BaseActivityNotification.php +++ b/app/Activity/Notifications/Messages/BaseActivityNotification.php @@ -5,6 +5,7 @@ namespace BookStack\Activity\Notifications\Messages; use BookStack\Activity\Models\Loggable; use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine; use BookStack\App\MailNotification; +use BookStack\Translation\LocaleDefinition; use BookStack\Users\Models\User; use Illuminate\Bus\Queueable; @@ -35,12 +36,12 @@ abstract class BaseActivityNotification extends MailNotification /** * Build the common reason footer line used in mail messages. */ - protected function buildReasonFooterLine(string $language): LinkedMailMessageLine + protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine { return new LinkedMailMessageLine( url('/preferences/notifications'), - trans('notifications.footer_reason', [], $language), - trans('notifications.footer_reason_link', [], $language), + $locale->trans('notifications.footer_reason'), + $locale->trans('notifications.footer_reason_link'), ); } } diff --git a/app/Activity/Notifications/Messages/CommentCreationNotification.php b/app/Activity/Notifications/Messages/CommentCreationNotification.php index 5db61b04b..094ab30b7 100644 --- a/app/Activity/Notifications/Messages/CommentCreationNotification.php +++ b/app/Activity/Notifications/Messages/CommentCreationNotification.php @@ -17,17 +17,17 @@ class CommentCreationNotification extends BaseActivityNotification /** @var Page $page */ $page = $comment->entity; - $language = $notifiable->getLanguage(); + $locale = $notifiable->getLocale(); - return $this->newMailMessage($language) - ->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()], $language)) - ->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')], $language)) + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')])) ->line(new ListMessageLine([ - trans('notifications.detail_page_name', [], $language) => $page->name, - trans('notifications.detail_commenter', [], $language) => $this->user->name, - trans('notifications.detail_comment', [], $language) => strip_tags($comment->html), + $locale->trans('notifications.detail_page_name') => $page->name, + $locale->trans('notifications.detail_commenter') => $this->user->name, + $locale->trans('notifications.detail_comment') => strip_tags($comment->html), ])) - ->action(trans('notifications.action_view_comment', [], $language), $page->getUrl('#comment' . $comment->local_id)) - ->line($this->buildReasonFooterLine($language)); + ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id)) + ->line($this->buildReasonFooterLine($locale)); } } diff --git a/app/Activity/Notifications/Messages/PageCreationNotification.php b/app/Activity/Notifications/Messages/PageCreationNotification.php index 110829469..da028aa8c 100644 --- a/app/Activity/Notifications/Messages/PageCreationNotification.php +++ b/app/Activity/Notifications/Messages/PageCreationNotification.php @@ -14,16 +14,16 @@ class PageCreationNotification extends BaseActivityNotification /** @var Page $page */ $page = $this->detail; - $language = $notifiable->getLanguage(); + $locale = $notifiable->getLocale(); - return $this->newMailMessage($language) - ->subject(trans('notifications.new_page_subject', ['pageName' => $page->getShortName()], $language)) - ->line(trans('notifications.new_page_intro', ['appName' => setting('app-name')], $language)) + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')], $locale)) ->line(new ListMessageLine([ - trans('notifications.detail_page_name', [], $language) => $page->name, - trans('notifications.detail_created_by', [], $language) => $this->user->name, + $locale->trans('notifications.detail_page_name') => $page->name, + $locale->trans('notifications.detail_created_by') => $this->user->name, ])) - ->action(trans('notifications.action_view_page', [], $language), $page->getUrl()) - ->line($this->buildReasonFooterLine($language)); + ->action($locale->trans('notifications.action_view_page'), $page->getUrl()) + ->line($this->buildReasonFooterLine($locale)); } } diff --git a/app/Activity/Notifications/Messages/PageUpdateNotification.php b/app/Activity/Notifications/Messages/PageUpdateNotification.php index 8e2d27fa4..1c8155d29 100644 --- a/app/Activity/Notifications/Messages/PageUpdateNotification.php +++ b/app/Activity/Notifications/Messages/PageUpdateNotification.php @@ -14,17 +14,17 @@ class PageUpdateNotification extends BaseActivityNotification /** @var Page $page */ $page = $this->detail; - $language = $notifiable->getLanguage(); + $locale = $notifiable->getLocale(); - return $this->newMailMessage($language) - ->subject(trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()], $language)) - ->line(trans('notifications.updated_page_intro', ['appName' => setting('app-name')], $language)) + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')])) ->line(new ListMessageLine([ - trans('notifications.detail_page_name', [], $language) => $page->name, - trans('notifications.detail_updated_by', [], $language) => $this->user->name, + $locale->trans('notifications.detail_page_name') => $page->name, + $locale->trans('notifications.detail_updated_by') => $this->user->name, ])) - ->line(trans('notifications.updated_page_debounce', [], $language)) - ->action(trans('notifications.action_view_page', [], $language), $page->getUrl()) - ->line($this->buildReasonFooterLine($language)); + ->line($locale->trans('notifications.updated_page_debounce')) + ->action($locale->trans('notifications.action_view_page'), $page->getUrl()) + ->line($this->buildReasonFooterLine($locale)); } } diff --git a/app/App/MailNotification.php b/app/App/MailNotification.php index 8c57b5621..50b7f69a7 100644 --- a/app/App/MailNotification.php +++ b/app/App/MailNotification.php @@ -2,6 +2,7 @@ namespace BookStack\App; +use BookStack\Translation\LocaleDefinition; use BookStack\Users\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -32,9 +33,9 @@ abstract class MailNotification extends Notification implements ShouldQueue /** * Create a new mail message. */ - protected function newMailMessage(string $language = ''): MailMessage + protected function newMailMessage(?LocaleDefinition $locale = null): MailMessage { - $data = ['language' => $language ?: null]; + $data = ['locale' => $locale ?? user()->getLocale()]; return (new MailMessage())->view([ 'html' => 'vendor.notifications.email', diff --git a/app/Config/app.php b/app/Config/app.php index 3a843c512..dcd3ffc31 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -83,10 +83,10 @@ return [ 'timezone' => env('APP_TIMEZONE', 'UTC'), // Default locale to use + // A default variant is also stored since Laravel can overwrite + // app.locale when dynamically setting the locale in-app. 'locale' => env('APP_LANG', 'en'), - - // Locales available - 'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'], + 'default_locale' => env('APP_LANG', 'en'), // Application Fallback Locale 'fallback_locale' => 'en', @@ -94,9 +94,6 @@ return [ // Faker Locale 'faker_locale' => 'en_GB', - // Enable right-to-left text control. - 'rtl' => false, - // Auto-detect the locale for public users // For public users their locale can be guessed by headers sent by their // browser. This is usually set by users in their browser settings. diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php index 9e4d63cf7..6779797d1 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -16,18 +16,11 @@ use Throwable; class ExportFormatter { - protected ImageService $imageService; - protected PdfGenerator $pdfGenerator; - protected CspService $cspService; - - /** - * ExportService constructor. - */ - public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService) - { - $this->imageService = $imageService; - $this->pdfGenerator = $pdfGenerator; - $this->cspService = $cspService; + public function __construct( + protected ImageService $imageService, + protected PdfGenerator $pdfGenerator, + protected CspService $cspService + ) { } /** @@ -36,13 +29,14 @@ class ExportFormatter * * @throws Throwable */ - public function pageToContainedHtml(Page $page) + public function pageToContainedHtml(Page $page): string { $page->html = (new PageContent($page))->render(); $pageHtml = view('exports.page', [ 'page' => $page, 'format' => 'html', 'cspContent' => $this->cspService->getCspMetaTagValue(), + 'locale' => user()->getLocale(), ])->render(); return $this->containHtml($pageHtml); @@ -53,7 +47,7 @@ class ExportFormatter * * @throws Throwable */ - public function chapterToContainedHtml(Chapter $chapter) + public function chapterToContainedHtml(Chapter $chapter): string { $pages = $chapter->getVisiblePages(); $pages->each(function ($page) { @@ -64,6 +58,7 @@ class ExportFormatter 'pages' => $pages, 'format' => 'html', 'cspContent' => $this->cspService->getCspMetaTagValue(), + 'locale' => user()->getLocale(), ])->render(); return $this->containHtml($html); @@ -74,7 +69,7 @@ class ExportFormatter * * @throws Throwable */ - public function bookToContainedHtml(Book $book) + public function bookToContainedHtml(Book $book): string { $bookTree = (new BookContents($book))->getTree(false, true); $html = view('exports.book', [ @@ -82,6 +77,7 @@ class ExportFormatter 'bookChildren' => $bookTree, 'format' => 'html', 'cspContent' => $this->cspService->getCspMetaTagValue(), + 'locale' => user()->getLocale(), ])->render(); return $this->containHtml($html); @@ -92,13 +88,14 @@ class ExportFormatter * * @throws Throwable */ - public function pageToPdf(Page $page) + public function pageToPdf(Page $page): string { $page->html = (new PageContent($page))->render(); $html = view('exports.page', [ 'page' => $page, 'format' => 'pdf', 'engine' => $this->pdfGenerator->getActiveEngine(), + 'locale' => user()->getLocale(), ])->render(); return $this->htmlToPdf($html); @@ -109,7 +106,7 @@ class ExportFormatter * * @throws Throwable */ - public function chapterToPdf(Chapter $chapter) + public function chapterToPdf(Chapter $chapter): string { $pages = $chapter->getVisiblePages(); $pages->each(function ($page) { @@ -121,6 +118,7 @@ class ExportFormatter 'pages' => $pages, 'format' => 'pdf', 'engine' => $this->pdfGenerator->getActiveEngine(), + 'locale' => user()->getLocale(), ])->render(); return $this->htmlToPdf($html); @@ -131,7 +129,7 @@ class ExportFormatter * * @throws Throwable */ - public function bookToPdf(Book $book) + public function bookToPdf(Book $book): string { $bookTree = (new BookContents($book))->getTree(false, true); $html = view('exports.book', [ @@ -139,6 +137,7 @@ class ExportFormatter 'bookChildren' => $bookTree, 'format' => 'pdf', 'engine' => $this->pdfGenerator->getActiveEngine(), + 'locale' => user()->getLocale(), ])->render(); return $this->htmlToPdf($html); @@ -194,7 +193,7 @@ class ExportFormatter /** @var DOMElement $iframe */ foreach ($iframes as $iframe) { $link = $iframe->getAttribute('src'); - if (strpos($link, '//') === 0) { + if (str_starts_with($link, '//')) { $link = 'https:' . $link; } @@ -240,7 +239,7 @@ class ExportFormatter foreach ($linksOutput[0] as $index => $linkMatch) { $oldLinkString = $linkMatch; $srcString = $linksOutput[2][$index]; - if (strpos(trim($srcString), 'http') !== 0) { + if (!str_starts_with(trim($srcString), 'http')) { $newSrcString = url($srcString); $newLinkString = str_replace($srcString, $newSrcString, $oldLinkString); $htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent); diff --git a/app/Http/Middleware/Localization.php b/app/Http/Middleware/Localization.php index 47723e242..94d06a627 100644 --- a/app/Http/Middleware/Localization.php +++ b/app/Http/Middleware/Localization.php @@ -2,17 +2,14 @@ namespace BookStack\Http\Middleware; -use BookStack\Translation\LanguageManager; -use Carbon\Carbon; +use BookStack\Translation\LocaleManager; use Closure; class Localization { - protected LanguageManager $languageManager; - - public function __construct(LanguageManager $languageManager) - { - $this->languageManager = $languageManager; + public function __construct( + protected LocaleManager $localeManager + ) { } /** @@ -25,22 +22,12 @@ class Localization */ public function handle($request, Closure $next) { - // Get and record the default language in the config - $defaultLang = config('app.locale'); - config()->set('app.default_locale', $defaultLang); + // Share details of the user's locale for use in views + $userLocale = $this->localeManager->getForUser(user()); + view()->share('locale', $userLocale); - // Get the user's language and record that in the config for use in views - $userLang = $this->languageManager->getUserLanguage($request, $defaultLang); - config()->set('app.lang', str_replace('_', '-', $this->languageManager->getIsoName($userLang))); - - // Set text direction - if ($this->languageManager->isRTL($userLang)) { - config()->set('app.rtl', true); - } - - app()->setLocale($userLang); - Carbon::setLocale($userLang); - $this->languageManager->setPhpDateTimeLocale($userLang); + // Set locale for system components + app()->setLocale($userLocale->appLocale()); return $next($request); } diff --git a/app/Translation/LanguageManager.php b/app/Translation/LanguageManager.php deleted file mode 100644 index f3432d038..000000000 --- a/app/Translation/LanguageManager.php +++ /dev/null @@ -1,148 +0,0 @@ -<?php - -namespace BookStack\Translation; - -use BookStack\Users\Models\User; -use Illuminate\Http\Request; - -class LanguageManager -{ - /** - * Array of right-to-left language options. - */ - protected array $rtlLanguages = ['ar', 'fa', 'he']; - - /** - * Map of BookStack language names to best-estimate ISO and windows locale names. - * Locales can often be found by running `locale -a` on a linux system. - * Windows locales can be found at: - * https://docs.microsoft.com/en-us/cpp/c-runtime-library/language-strings?view=msvc-170. - * - * @var array<string, array{iso: string, windows: string}> - */ - protected array $localeMap = [ - 'ar' => ['iso' => 'ar', 'windows' => 'Arabic'], - 'bg' => ['iso' => 'bg_BG', 'windows' => 'Bulgarian'], - 'bs' => ['iso' => 'bs_BA', 'windows' => 'Bosnian (Latin)'], - 'ca' => ['iso' => 'ca', 'windows' => 'Catalan'], - 'cs' => ['iso' => 'cs_CZ', 'windows' => 'Czech'], - 'da' => ['iso' => 'da_DK', 'windows' => 'Danish'], - 'de' => ['iso' => 'de_DE', 'windows' => 'German'], - 'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'], - 'en' => ['iso' => 'en_GB', 'windows' => 'English'], - 'el' => ['iso' => 'el_GR', 'windows' => 'Greek'], - 'es' => ['iso' => 'es_ES', 'windows' => 'Spanish'], - 'es_AR' => ['iso' => 'es_AR', 'windows' => 'Spanish'], - 'et' => ['iso' => 'et_EE', 'windows' => 'Estonian'], - 'eu' => ['iso' => 'eu_ES', 'windows' => 'Basque'], - 'fa' => ['iso' => 'fa_IR', 'windows' => 'Persian'], - 'fr' => ['iso' => 'fr_FR', 'windows' => 'French'], - 'he' => ['iso' => 'he_IL', 'windows' => 'Hebrew'], - 'hr' => ['iso' => 'hr_HR', 'windows' => 'Croatian'], - 'hu' => ['iso' => 'hu_HU', 'windows' => 'Hungarian'], - 'id' => ['iso' => 'id_ID', 'windows' => 'Indonesian'], - 'it' => ['iso' => 'it_IT', 'windows' => 'Italian'], - 'ja' => ['iso' => 'ja', 'windows' => 'Japanese'], - 'ko' => ['iso' => 'ko_KR', 'windows' => 'Korean'], - 'lt' => ['iso' => 'lt_LT', 'windows' => 'Lithuanian'], - 'lv' => ['iso' => 'lv_LV', 'windows' => 'Latvian'], - 'nl' => ['iso' => 'nl_NL', 'windows' => 'Dutch'], - 'nb' => ['iso' => 'nb_NO', 'windows' => 'Norwegian (Bokmal)'], - 'pl' => ['iso' => 'pl_PL', 'windows' => 'Polish'], - 'pt' => ['iso' => 'pt_PT', 'windows' => 'Portuguese'], - 'pt_BR' => ['iso' => 'pt_BR', 'windows' => 'Portuguese'], - 'ro' => ['iso' => 'ro_RO', 'windows' => 'Romanian'], - 'ru' => ['iso' => 'ru', 'windows' => 'Russian'], - 'sk' => ['iso' => 'sk_SK', 'windows' => 'Slovak'], - 'sl' => ['iso' => 'sl_SI', 'windows' => 'Slovenian'], - 'sv' => ['iso' => 'sv_SE', 'windows' => 'Swedish'], - 'uk' => ['iso' => 'uk_UA', 'windows' => 'Ukrainian'], - 'uz' => ['iso' => 'uz_UZ', 'windows' => 'Uzbek'], - 'vi' => ['iso' => 'vi_VN', 'windows' => 'Vietnamese'], - 'zh_CN' => ['iso' => 'zh_CN', 'windows' => 'Chinese (Simplified)'], - 'zh_TW' => ['iso' => 'zh_TW', 'windows' => 'Chinese (Traditional)'], - 'tr' => ['iso' => 'tr_TR', 'windows' => 'Turkish'], - ]; - - /** - * Get the language specifically for the currently logged-in user if available. - */ - public function getUserLanguage(Request $request, string $default): string - { - try { - $user = user(); - } catch (\Exception $exception) { - return $default; - } - - if ($user->isGuest() && config('app.auto_detect_locale')) { - return $this->autoDetectLocale($request, $default); - } - - return setting()->getUser($user, 'language', $default); - } - - /** - * Get the language for the given user. - */ - public function getLanguageForUser(User $user): string - { - $default = config('app.locale'); - return setting()->getUser($user, 'language', $default); - } - - /** - * Check if the given BookStack language value is a right-to-left language. - */ - public function isRTL(string $language): bool - { - return in_array($language, $this->rtlLanguages); - } - - /** - * Autodetect the visitors locale by matching locales in their headers - * against the locales supported by BookStack. - */ - protected function autoDetectLocale(Request $request, string $default): string - { - $availableLocales = config('app.locales'); - foreach ($request->getLanguages() as $lang) { - if (in_array($lang, $availableLocales)) { - return $lang; - } - } - - return $default; - } - - /** - * Get the ISO version of a BookStack language name. - */ - public function getIsoName(string $language): string - { - return $this->localeMap[$language]['iso'] ?? $language; - } - - /** - * Set the system date locale for localized date formatting. - * Will try both the standard locale name and the UTF8 variant. - */ - public function setPhpDateTimeLocale(string $language): void - { - $isoLang = $this->localeMap[$language]['iso'] ?? ''; - $isoLangPrefix = explode('_', $isoLang)[0]; - - $locales = array_values(array_filter([ - $isoLang ? $isoLang . '.utf8' : false, - $isoLang ?: false, - $isoLang ? str_replace('_', '-', $isoLang) : false, - $isoLang ? $isoLangPrefix . '.UTF-8' : false, - $this->localeMap[$language]['windows'] ?? false, - $language, - ])); - - if (!empty($locales)) { - setlocale(LC_TIME, $locales[0], ...array_slice($locales, 1)); - } - } -} diff --git a/app/Translation/LocaleDefinition.php b/app/Translation/LocaleDefinition.php new file mode 100644 index 000000000..85d36afa0 --- /dev/null +++ b/app/Translation/LocaleDefinition.php @@ -0,0 +1,53 @@ +<?php + +namespace BookStack\Translation; + +class LocaleDefinition +{ + public function __construct( + protected string $appName, + protected string $isoName, + protected bool $isRtl + ) { + } + + /** + * Provide the BookStack-specific locale name. + */ + public function appLocale(): string + { + return $this->appName; + } + + /** + * Provide the ISO-aligned locale name. + */ + public function isoLocale(): string + { + return $this->isoName; + } + + /** + * Returns a string suitable for the HTML "lang" attribute. + */ + public function htmlLang(): string + { + return str_replace('_', '-', $this->isoName); + } + + /** + * Returns a string suitable for the HTML "dir" attribute. + */ + public function htmlDirection(): string + { + return $this->isRtl ? 'rtl' : 'ltr'; + } + + /** + * Translate using this locate. + */ + public function trans(string $key, array $replace = []): string + { + return trans($key, $replace, $this->appLocale()); + } +} diff --git a/app/Translation/LocaleManager.php b/app/Translation/LocaleManager.php new file mode 100644 index 000000000..825eae7a4 --- /dev/null +++ b/app/Translation/LocaleManager.php @@ -0,0 +1,119 @@ +<?php + +namespace BookStack\Translation; + +use BookStack\Users\Models\User; +use Illuminate\Http\Request; + +class LocaleManager +{ + /** + * Array of right-to-left locale options. + */ + protected array $rtlLocales = ['ar', 'fa', 'he']; + + /** + * Map of BookStack locale names to best-estimate ISO locale names. + * Locales can often be found by running `locale -a` on a linux system. + * + * @var array<string, string> + */ + protected array $localeMap = [ + 'ar' => 'ar', + 'bg' => 'bg_BG', + 'bs' => 'bs_BA', + 'ca' => 'ca', + 'cs' => 'cs_CZ', + 'cy' => 'cy_GB', + 'da' => 'da_DK', + 'de' => 'de_DE', + 'de_informal' => 'de_DE', + 'el' => 'el_GR', + 'en' => 'en_GB', + 'es' => 'es_ES', + 'es_AR' => 'es_AR', + 'et' => 'et_EE', + 'eu' => 'eu_ES', + 'fa' => 'fa_IR', + 'fr' => 'fr_FR', + 'he' => 'he_IL', + 'hr' => 'hr_HR', + 'hu' => 'hu_HU', + 'id' => 'id_ID', + 'it' => 'it_IT', + 'ja' => 'ja', + 'ka' => 'ka_GE', + 'ko' => 'ko_KR', + 'lt' => 'lt_LT', + 'lv' => 'lv_LV', + 'nb' => 'nb_NO', + 'nl' => 'nl_NL', + 'pl' => 'pl_PL', + 'pt' => 'pt_PT', + 'pt_BR' => 'pt_BR', + 'ro' => 'ro_RO', + 'ru' => 'ru', + 'sk' => 'sk_SK', + 'sl' => 'sl_SI', + 'sv' => 'sv_SE', + 'tr' => 'tr_TR', + 'uk' => 'uk_UA', + 'uz' => 'uz_UZ', + 'vi' => 'vi_VN', + 'zh_CN' => 'zh_CN', + 'zh_TW' => 'zh_TW', + ]; + + /** + * Get the BookStack locale string for the given user. + */ + protected function getLocaleForUser(User $user): string + { + $default = config('app.default_locale'); + + if ($user->isGuest() && config('app.auto_detect_locale')) { + return $this->autoDetectLocale(request(), $default); + } + + return setting()->getUser($user, 'language', $default); + } + + /** + * Get a locale definition for the current user. + */ + public function getForUser(User $user): LocaleDefinition + { + $localeString = $this->getLocaleForUser($user); + + return new LocaleDefinition( + $localeString, + $this->localeMap[$localeString] ?? $localeString, + in_array($localeString, $this->rtlLocales), + ); + } + + /** + * Autodetect the visitors locale by matching locales in their headers + * against the locales supported by BookStack. + */ + protected function autoDetectLocale(Request $request, string $default): string + { + $availableLocales = $this->getAllAppLocales(); + + foreach ($request->getLanguages() as $lang) { + if (in_array($lang, $availableLocales)) { + return $lang; + } + } + + return $default; + } + + /** + * Get all the available app-specific level locale strings. + */ + public function getAllAppLocales(): array + { + return array_keys($this->localeMap); + } +} diff --git a/app/Users/Models/User.php b/app/Users/Models/User.php index 232ea8832..39236c7e4 100644 --- a/app/Users/Models/User.php +++ b/app/Users/Models/User.php @@ -12,7 +12,8 @@ use BookStack\Api\ApiToken; use BookStack\App\Model; use BookStack\App\Sluggable; use BookStack\Entities\Tools\SlugGenerator; -use BookStack\Translation\LanguageManager; +use BookStack\Translation\LocaleDefinition; +use BookStack\Translation\LocaleManager; use BookStack\Uploads\Image; use Carbon\Carbon; use Exception; @@ -342,11 +343,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon } /** - * Get the system language for this user. + * Get the locale for this user. */ - public function getLanguage(): string + public function getLocale(): LocaleDefinition { - return app()->make(LanguageManager::class)->getLanguageForUser($this); + return app()->make(LocaleManager::class)->getForUser($this); } /** diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 8875788a6..f303aff26 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -1,6 +1,6 @@ <!DOCTYPE html> -<html lang="{{ config('app.lang') }}" - dir="{{ config('app.rtl') ? 'rtl' : 'ltr' }}" +<html lang="{{ isset($locale) ? $locale->htmlLang() : config('app.default_locale') }}" + dir="{{ isset($locale) ? $locale->htmlDirection() : 'auto' }}" class="{{ setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '' }}"> <head> <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}</title> diff --git a/resources/views/layouts/export.blade.php b/resources/views/layouts/export.blade.php index e041d8dea..eb2397a75 100644 --- a/resources/views/layouts/export.blade.php +++ b/resources/views/layouts/export.blade.php @@ -1,5 +1,5 @@ <!doctype html> -<html lang="{{ config('app.lang') }}"> +<html lang="{{ $locale->htmlLang() }}"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <title>@yield('title')</title> diff --git a/resources/views/layouts/plain.blade.php b/resources/views/layouts/plain.blade.php index 043d8aa48..a3ee74143 100644 --- a/resources/views/layouts/plain.blade.php +++ b/resources/views/layouts/plain.blade.php @@ -1,6 +1,6 @@ <!DOCTYPE html> -<html lang="{{ config('app.lang') }}" - dir="{{ config('app.rtl') ? 'rtl' : 'ltr' }}" +<html lang="{{ isset($locale) ? $locale->htmlLang() : config('app.default_locale') }}" + dir="{{ isset($locale) ? $locale->htmlDirection() : 'auto' }}" class="@yield('document-class')"> <head> <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}</title> diff --git a/resources/views/pages/parts/markdown-editor.blade.php b/resources/views/pages/parts/markdown-editor.blade.php index c488f0e11..18a418f10 100644 --- a/resources/views/pages/parts/markdown-editor.blade.php +++ b/resources/views/pages/parts/markdown-editor.blade.php @@ -1,6 +1,6 @@ <div id="markdown-editor" component="markdown-editor" option:markdown-editor:page-id="{{ $model->id ?? 0 }}" - option:markdown-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}" + option:markdown-editor:text-direction="{{ $locale->htmlDirection() }}" option:markdown-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}" option:markdown-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}" class="flex-fill flex code-fill"> diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index b7cd1bdaa..ca6b6da8a 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -3,9 +3,9 @@ @endpush <div component="wysiwyg-editor" - option:wysiwyg-editor:language="{{ config('app.lang') }}" + option:wysiwyg-editor:language="{{ $locale->htmlLang() }}" option:wysiwyg-editor:page-id="{{ $model->id ?? 0 }}" - option:wysiwyg-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}" + option:wysiwyg-editor:text-direction="{{ $locale->htmlDirection() }}" option:wysiwyg-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}" option:wysiwyg-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}" class="flex-fill flex"> diff --git a/resources/views/users/create.blade.php b/resources/views/users/create.blade.php index 540d7bd6a..dafc623e1 100644 --- a/resources/views/users/create.blade.php +++ b/resources/views/users/create.blade.php @@ -14,7 +14,7 @@ <div class="setting-list"> @include('users.parts.form') - @include('users.parts.language-option-row', ['value' => old('setting.language') ?? config('app.default_locale')]) + @include('users.parts.language-option-row', ['value' => old('language') ?? config('app.default_locale')]) </div> <div class="form-group text-right"> diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 4e31e785d..832186930 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -16,7 +16,8 @@ <div class="grid half gap-xl"> <div> - <label for="user-avatar" class="setting-list-label">{{ trans('settings.users_avatar') }}</label> + <label for="user-avatar" + class="setting-list-label">{{ trans('settings.users_avatar') }}</label> <p class="small">{{ trans('settings.users_avatar_desc') }}</p> </div> <div> @@ -33,13 +34,15 @@ </div> </div> - @include('users.parts.language-option-row', ['value' => setting()->getUser($user, 'language', config('app.default_locale'))]) + @include('users.parts.language-option-row', ['value' => old('language') ?? $user->getLocale()->appLocale()]) </div> <div class="text-right"> - <a href="{{ url(userCan('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a> + <a href="{{ url(userCan('users-manage') ? "/settings/users" : "/") }}" + class="button outline">{{ trans('common.cancel') }}</a> @if($authMethod !== 'system') - <a href="{{ url("/settings/users/{$user->id}/delete") }}" class="button outline">{{ trans('settings.users_delete') }}</a> + <a href="{{ url("/settings/users/{$user->id}/delete") }}" + class="button outline">{{ trans('settings.users_delete') }}</a> @endif <button class="button" type="submit">{{ trans('common.save') }}</button> </div> @@ -60,7 +63,8 @@ </div> <div class="text-m-right"> @if($user->id === user()->id) - <a href="{{ url('/mfa/setup') }}" class="button outline">{{ trans('settings.users_mfa_configure') }}</a> + <a href="{{ url('/mfa/setup') }}" + class="button outline">{{ trans('settings.users_mfa_configure') }}</a> @endif </div> </div> @@ -84,7 +88,8 @@ class="button small outline">{{ trans('settings.users_social_disconnect') }}</button> </form> @else - <a href="{{ url("/login/service/{$driver}") }}" aria-label="{{ trans('settings.users_social_connect') }} - {{ $driver }}" + <a href="{{ url("/login/service/{$driver}") }}" + aria-label="{{ trans('settings.users_social_connect') }} - {{ $driver }}" class="button small outline">{{ trans('settings.users_social_connect') }}</a> @endif </div> diff --git a/resources/views/vendor/notifications/email.blade.php b/resources/views/vendor/notifications/email.blade.php index f5d9c328d..8e922fba5 100644 --- a/resources/views/vendor/notifications/email.blade.php +++ b/resources/views/vendor/notifications/email.blade.php @@ -1,5 +1,5 @@ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> -<html lang="{{ config('app.lang') }}"> +<html lang="{{ $locale->htmlLang() }}"> <head> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> @@ -159,7 +159,7 @@ $style = [ <tr> <td style="{{ $fontFamily }}"> <p style="{{ $style['paragraph-sub'] }}"> - {{ trans('common.email_action_help', ['actionText' => $actionText], $language) }} + {{ $locale->trans('common.email_action_help', ['actionText' => $actionText]) }} </p> <p style="{{ $style['paragraph-sub'] }}"> @@ -187,7 +187,7 @@ $style = [ <p style="{{ $style['paragraph-sub'] }}"> © {{ date('Y') }} <a style="{{ $style['anchor'] }}" href="{{ url('/') }}" target="_blank">{{ setting('app-name') }}</a>. - {{ trans('common.email_rights', [], $language) }} + {{ $locale->trans('common.email_rights') }} </p> </td> </tr> diff --git a/tests/LanguageTest.php b/tests/LanguageTest.php index a66227ff2..6b6856184 100644 --- a/tests/LanguageTest.php +++ b/tests/LanguageTest.php @@ -3,6 +3,7 @@ namespace Tests; use BookStack\Activity\ActivityType; +use BookStack\Translation\LocaleManager; class LanguageTest extends TestCase { @@ -17,12 +18,12 @@ class LanguageTest extends TestCase $this->langs = array_diff(scandir(lang_path('')), ['..', '.']); } - public function test_locales_config_key_set_properly() + public function test_locales_list_set_properly() { - $configLocales = config('app.locales'); - sort($configLocales); + $appLocales = $this->app->make(LocaleManager::class)->getAllAppLocales(); + sort($appLocales); sort($this->langs); - $this->assertEquals(implode(':', $configLocales), implode(':', $this->langs), 'app.locales configuration variable does not match those found in lang files'); + $this->assertEquals(implode(':', $this->langs), implode(':', $appLocales), 'app.locales configuration variable does not match those found in lang files'); } // Not part of standard phpunit test runs since we sometimes expect non-added langs. @@ -75,13 +76,13 @@ class LanguageTest extends TestCase } } - public function test_rtl_config_set_if_lang_is_rtl() + public function test_views_use_rtl_if_rtl_language_is_set() { - $this->asEditor(); - $this->assertFalse(config('app.rtl'), 'App RTL config should be false by default'); + $this->asEditor()->withHtml($this->get('/'))->assertElementExists('html[dir="ltr"]'); + setting()->putUser($this->users->editor(), 'language', 'ar'); - $this->get('/'); - $this->assertTrue(config('app.rtl'), 'App RTL config should have been set to true by middleware'); + + $this->withHtml($this->get('/'))->assertElementExists('html[dir="rtl"]'); } public function test_unknown_lang_does_not_break_app() diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index a6d869b2f..93d35f5d0 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -215,7 +215,7 @@ class UserManagementTest extends TestCase { $langs = ['en', 'fr', 'hr']; foreach ($langs as $lang) { - config()->set('app.locale', $lang); + config()->set('app.default_locale', $lang); $resp = $this->asAdmin()->get('/settings/users/create'); $this->withHtml($resp)->assertElementExists('select[name="language"] option[value="' . $lang . '"][selected]'); }