diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c19de42..a421f9514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ ones are marked like "v1.0.0-fork". ## [Unreleased] +### Fixed + +* **The interface language setting had no effect on multi-user installs.** The + language was read before the signed-in user was known, so everyone got the + default. It now applies to every signed-in page, and the preferences page + reloads to show it as soon as it is saved. + +* **The language picker fell out of step with the page.** On multi-user installs + `?filterlang=` links never changed the current language, and creating a + language, opening a text or reviewing a language left the picker on the old + one. All of these now update it. Remembered sort orders had the same fault + and now persist per user, and none of these values reach the shared defaults + any more. + ## [3.8.1-fork] - 2026-11-04 ### Changed in 3.8.1-fork diff --git a/cypress.config.ts b/cypress.config.ts index e99f9392b..7544ff858 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -3,6 +3,21 @@ import { defineConfig } from 'cypress'; export default defineConfig({ e2e: { baseUrl: 'http://localhost:8000', + setupNodeEvents(on) { + on('task', { + // In Node, because cy.request() fails the test on a refused connection. + ankiConnectVersion(url: string): Promise { + return fetch(url, { + method: 'POST', + body: JSON.stringify({ action: 'version', version: 6 }), + signal: AbortSignal.timeout(3000) + }) + .then((response) => response.json()) + .then((body: { result?: number }) => body.result ?? null) + .catch(() => null); + } + }); + }, supportFile: 'cypress/support/e2e.ts', specPattern: 'cypress/e2e/**/*.cy.ts', viewportWidth: 1280, diff --git a/cypress/e2e/24-anki-live.cy.ts b/cypress/e2e/24-anki-live.cy.ts index 8c4d0590d..e8948d109 100644 --- a/cypress/e2e/24-anki-live.cy.ts +++ b/cypress/e2e/24-anki-live.cy.ts @@ -51,12 +51,9 @@ describe('Sync reviews with a real Anki', () => { const extraDecks: string[] = []; before(() => { - cy.request({ method: 'POST', url: anki, body: { action: 'version', version: 6 }, failOnStatusCode: false, timeout: 3000 }) - .then((response) => { - ankiUp = response.status === 200 && (response.body as { result?: number }).result === 6; - }, () => { - ankiUp = false; - }); + cy.task('ankiConnectVersion', anki).then((version) => { + ankiUp = version === 6; + }); }); beforeEach(function () { diff --git a/docs-src/changelog.md b/docs-src/changelog.md index 8733f25cf..faadda95b 100644 --- a/docs-src/changelog.md +++ b/docs-src/changelog.md @@ -12,6 +12,20 @@ ones are marked like "v1.0.0-fork". ## [Unreleased] +### Fixed + +* **The interface language setting had no effect on multi-user installs.** The + language was read before the signed-in user was known, so everyone got the + default. It now applies to every signed-in page, and the preferences page + reloads to show it as soon as it is saved. + +* **The language picker fell out of step with the page.** On multi-user installs + `?filterlang=` links never changed the current language, and creating a + language, opening a text or reviewing a language left the picker on the old + one. All of these now update it. Remembered sort orders had the same fault + and now persist per user, and none of these values reach the shared defaults + any more. + ## [3.8.1-fork] - 2026-11-04 ### Changed in 3.8.1-fork diff --git a/src/Modules/Language/Http/LanguageApiHandler.php b/src/Modules/Language/Http/LanguageApiHandler.php index 678a4ea27..692571657 100644 --- a/src/Modules/Language/Http/LanguageApiHandler.php +++ b/src/Modules/Language/Http/LanguageApiHandler.php @@ -26,6 +26,7 @@ use Lwt\Shared\Infrastructure\Http\JsonResponse; use Lwt\Api\V1\Response; use Lwt\Modules\Language\Application\LanguageFacade; +use Lwt\Shared\Infrastructure\Language\CurrentLanguage; use Lwt\Shared\Infrastructure\Language\LanguagePresets; use Lwt\Modules\Text\Application\Services\SentenceService; use Lwt\Modules\Vocabulary\Application\UseCases\FindSimilarTerms; @@ -368,6 +369,7 @@ public function formatCreate(array $data): array $id = $this->languageFacade->createFromData($data); if ($id > 0) { + CurrentLanguage::follow($id); return ['success' => true, 'id' => $id]; } @@ -501,7 +503,7 @@ public function formatGetDefinitions(): array */ public function formatSetDefault(int $id): array { - Settings::save('currentlanguage', (string)$id); + Settings::savePerUser('currentlanguage', (string)$id); return ['success' => true]; } diff --git a/src/Modules/Review/Http/ReviewController.php b/src/Modules/Review/Http/ReviewController.php index 0c7a2ca26..f4eecbb05 100644 --- a/src/Modules/Review/Http/ReviewController.php +++ b/src/Modules/Review/Http/ReviewController.php @@ -23,6 +23,7 @@ use Lwt\Modules\Review\Application\ReviewFacade; use Lwt\Modules\Review\Infrastructure\SessionStateManager; use Lwt\Modules\Language\Application\LanguageFacade; +use Lwt\Shared\Infrastructure\Language\CurrentLanguage; use Lwt\Shared\Infrastructure\Language\LanguagePresets; use Lwt\Shared\UI\Helpers\PageLayoutHelper; @@ -177,6 +178,7 @@ private function renderReviewPage(): void // Get language settings $langIdFromSql = $this->reviewFacade->getLanguageIdFromReviewSql($reviewsql, $reviewParams); if ($langIdFromSql === null) { + CurrentLanguage::follow($langId ?? 0); PageLayoutHelper::renderPageStartNobody(__('review.page_title'), 'full-width'); include __DIR__ . '/../Views/no_terms.php'; PageLayoutHelper::renderPageEnd(); @@ -235,6 +237,7 @@ private function renderReviewPage(): void 'property' => $testData['property'] ]; + CurrentLanguage::follow($langIdFromSql); PageLayoutHelper::renderPageStartNobody(__('review.page_title'), 'full-width'); include __DIR__ . '/../Views/review_desktop.php'; PageLayoutHelper::renderPageEnd(); diff --git a/src/Modules/Text/Http/TextReadController.php b/src/Modules/Text/Http/TextReadController.php index 8a9c38415..7e0cb596a 100644 --- a/src/Modules/Text/Http/TextReadController.php +++ b/src/Modules/Text/Http/TextReadController.php @@ -22,6 +22,7 @@ use Lwt\Modules\Text\Application\Services\TextDisplayService; use Lwt\Modules\Text\Application\Services\TextNavigationService; use Lwt\Shared\Infrastructure\Database\Settings; +use Lwt\Shared\Infrastructure\Language\CurrentLanguage; use Lwt\Shared\UI\Helpers\PageLayoutHelper; use Lwt\Shared\Infrastructure\Http\RedirectResponse; use Lwt\Modules\Activity\Infrastructure\MySqlActivityRepository; @@ -148,6 +149,7 @@ public function renderReadPage(int $textId): ?RedirectResponse // neither is rendered server-side here. Settings::savePerUser('currenttext', $textId); + CurrentLanguage::follow($langId); PageLayoutHelper::renderPageStartNobody(__('text.titles.read'), 'full-width'); include self::MODULE_VIEWS . '/read_desktop.php'; diff --git a/src/Shared/I18n/UserLocale.php b/src/Shared/I18n/UserLocale.php new file mode 100644 index 000000000..3771b78a8 --- /dev/null +++ b/src/Shared/I18n/UserLocale.php @@ -0,0 +1,59 @@ + + * @license Unlicense + * @link https://hugofara.github.io/lwt/developer/api + * @since 3.8.2 + */ + +declare(strict_types=1); + +namespace Lwt\Shared\I18n; + +use Lwt\Shared\Infrastructure\Container\Container; +use Lwt\Shared\Infrastructure\Database\Settings; + +/** + * Sets the translator to the language the signed-in user chose. + * + * TranslatorServiceProvider::boot() runs before the middleware restores the + * user, so on a multi-user install it can only see the global default. The + * middleware calls this once the user is known. The user's own choice also + * beats a leftover `lwt_lang` cookie, which only exists to serve guests. + * + * A user who never chose keeps whatever boot() settled on, so a language picked + * on the sign-up page lasts until they pick one in their preferences. + */ +final class UserLocale +{ + /** + * Apply the current user's stored interface language, if they have one. + * + * @return void + */ + public static function apply(): void + { + $container = Container::getInstance(); + if (!$container->has(Translator::class)) { + return; + } + + try { + $locale = Settings::getOwnValue('app_language'); + } catch (\RuntimeException $e) { + return; + } + + $translator = $container->getTyped(Translator::class); + if ($locale !== '' && in_array($locale, $translator->getAvailableLocales(), true)) { + $translator->setLocale($locale); + } + } +} diff --git a/src/Shared/Infrastructure/Database/Settings.php b/src/Shared/Infrastructure/Database/Settings.php index 8b6a5f263..84b265c28 100644 --- a/src/Shared/Infrastructure/Database/Settings.php +++ b/src/Shared/Infrastructure/Database/Settings.php @@ -140,6 +140,30 @@ public static function getWithDefault(string $key): string return SettingDefinitions::getDefault($key) ?? ''; } + /** + * Get the value the current user saved themselves. + * + * Unlike getWithDefault(), this tells "chose the default" from "never + * chose", which matters when a choice has to beat another source. + * + * @param string $key Setting key + * + * @return string The user's own value, '' if they have none or nobody is signed in + */ + public static function getOwnValue(string $key): string + { + $userId = Globals::getCurrentUserId(); + if ($key === '' || $userId === null) { + return ''; + } + + return trim((string) Connection::preparedFetchValue( + "SELECT StValue FROM settings WHERE StKey = ? AND StUsID = ?", + [$key, $userId], + 'StValue' + )); + } + /** * Save the setting identified by a key with a specific value. * diff --git a/src/Shared/Infrastructure/Http/InputValidator.php b/src/Shared/Infrastructure/Http/InputValidator.php index e45c770ec..05b4a3cee 100644 --- a/src/Shared/Infrastructure/Http/InputValidator.php +++ b/src/Shared/Infrastructure/Http/InputValidator.php @@ -874,7 +874,7 @@ public static function getStringWithDb( if (self::has($reqKey)) { $value = self::getString($reqKey); - \Lwt\Shared\Infrastructure\Database\Settings::save($dbKey, $value); + \Lwt\Shared\Infrastructure\Database\Settings::savePerUser($dbKey, $value); return $value; } @@ -906,7 +906,7 @@ public static function getIntWithDb( if (self::has($reqKey)) { $value = self::getInt($reqKey, $default); - \Lwt\Shared\Infrastructure\Database\Settings::save($dbKey, (string) ($value ?? $default)); + \Lwt\Shared\Infrastructure\Database\Settings::savePerUser($dbKey, (string) ($value ?? $default)); return $value ?? $default; } diff --git a/src/Shared/Infrastructure/Language/CurrentLanguage.php b/src/Shared/Infrastructure/Language/CurrentLanguage.php index 55dc28021..c9fd3dcea 100644 --- a/src/Shared/Infrastructure/Language/CurrentLanguage.php +++ b/src/Shared/Infrastructure/Language/CurrentLanguage.php @@ -23,10 +23,10 @@ /** * Resolves the language the user is currently working in. * - * The `currentlanguage` setting is only written when the user actively picks a - * language from the navbar dropdown. Two situations leave it unset or stale, and - * both used to surface as "no language selected" even though the navbar showed - * one: + * The `currentlanguage` setting is written when the user picks a language from + * the navbar dropdown, or when they start working in one (see follow()). Two + * situations can still leave it unset or stale, and both used to surface as + * "no language selected" even though the navbar showed one: * * - A user who has just created their first language has never fired the * dropdown's `change` event. With exactly one language there is no other @@ -39,8 +39,8 @@ * browser falls back to displaying the first option. The display and the stored * state disagreed, and every server-side consumer read 0. * - * This resolver is the single answer to "which language is active?". It is - * read-only on purpose: it never writes the setting, so a plain GET has no side + * resolveId() is the single answer to "which language is active?". It is + * read-only on purpose: it never writes the setting, so resolving has no side * effects and the same request always resolves the same way. * * Callers that need the id to agree with what the navbar displays must use this @@ -72,6 +72,27 @@ public static function resolveId(): int } } + /** + * Make a language current because the user started working in it. + * + * Keeps the navbar in step with the page: without this, opening a text in + * another language leaves the navbar and the next filtered page on the old + * one. Only the user's own languages count, since the lookup is user-scoped. + * + * @param int $languageId Language the user is now working in + * + * @return void + */ + public static function follow(int $languageId): void + { + if ($languageId <= 0 || (int) Settings::get('currentlanguage') === $languageId) { + return; + } + if (self::exists($languageId)) { + Settings::savePerUser('currentlanguage', (string) $languageId); + } + } + /** * Check whether a language ID still exists. * diff --git a/src/Shared/Infrastructure/Routing/Middleware/AdminMiddleware.php b/src/Shared/Infrastructure/Routing/Middleware/AdminMiddleware.php index 784f0f451..2e3ae0885 100644 --- a/src/Shared/Infrastructure/Routing/Middleware/AdminMiddleware.php +++ b/src/Shared/Infrastructure/Routing/Middleware/AdminMiddleware.php @@ -18,6 +18,7 @@ namespace Lwt\Shared\Infrastructure\Routing\Middleware; use Lwt\Modules\User\Domain\User; +use Lwt\Shared\I18n\UserLocale; use Lwt\Shared\Infrastructure\Globals; use Lwt\Modules\User\Application\UserFacade; use Lwt\Shared\Infrastructure\Container\Container; @@ -90,6 +91,7 @@ public function handle(): bool return false; } + UserLocale::apply(); return true; } diff --git a/src/Shared/Infrastructure/Routing/Middleware/AuthMiddleware.php b/src/Shared/Infrastructure/Routing/Middleware/AuthMiddleware.php index 722e32e08..01d32ef0b 100644 --- a/src/Shared/Infrastructure/Routing/Middleware/AuthMiddleware.php +++ b/src/Shared/Infrastructure/Routing/Middleware/AuthMiddleware.php @@ -17,6 +17,7 @@ namespace Lwt\Shared\Infrastructure\Routing\Middleware; +use Lwt\Shared\I18n\UserLocale; use Lwt\Shared\Infrastructure\Globals; use Lwt\Modules\User\Application\UserFacade; use Lwt\Shared\Infrastructure\Container\Container; @@ -84,11 +85,13 @@ public function handle(): bool // Try session authentication if ($this->validateSession()) { + UserLocale::apply(); return true; } // Try API token authentication if ($this->validateApiToken()) { + UserLocale::apply(); return true; } diff --git a/src/frontend/js/modules/user/pages/preferences_app.ts b/src/frontend/js/modules/user/pages/preferences_app.ts index 73ff857b6..9c872d8b9 100644 --- a/src/frontend/js/modules/user/pages/preferences_app.ts +++ b/src/frontend/js/modules/user/pages/preferences_app.ts @@ -19,6 +19,7 @@ import { ProfileApi } from '../api/profile_api'; /** Alpine state for the preferences page. */ export interface PreferencesAppData { settings: Record; + loadedLocale: string | undefined; isLoading: boolean; isSaving: boolean; error: string; @@ -41,6 +42,7 @@ export interface PreferencesAppData { export function preferencesAppData(): PreferencesAppData { return { settings: {}, + loadedLocale: undefined, isLoading: false, isSaving: false, error: '', @@ -63,6 +65,7 @@ export function preferencesAppData(): PreferencesAppData { } this.settings = response.data.settings; + this.loadedLocale = this.settings.app_language; }, async save(): Promise { @@ -81,6 +84,12 @@ export function preferencesAppData(): PreferencesAppData { return; } + if (this.settings.app_language !== this.loadedLocale) { + // The page is already rendered in the old language. + window.location.reload(); + return; + } + this.success = t('user.flash.preferences_saved'); }, diff --git a/tests/backend/Shared/Integration/MultiUserCurrentLanguageIntegrationTest.php b/tests/backend/Shared/Integration/MultiUserCurrentLanguageIntegrationTest.php new file mode 100644 index 000000000..86c12902d --- /dev/null +++ b/tests/backend/Shared/Integration/MultiUserCurrentLanguageIntegrationTest.php @@ -0,0 +1,244 @@ +markTestSkipped('Database connection required'); + } + Globals::setMultiUserEnabled(true); + Globals::setCurrentUserId(self::$userId); + Connection::preparedExecute( + 'DELETE FROM settings WHERE StUsID = ?', + [self::$userId] + ); + Settings::savePerUser('currentlanguage', (string) self::$languageId); + } + + protected function tearDown(): void + { + unset($_GET['filterlang'], $_REQUEST['filterlang'], $_GET['sort'], $_REQUEST['sort']); + Globals::setCurrentUserId(null); + Globals::setMultiUserEnabled(false); + Container::setInstance(null); + } + + private function globalValue(string $key): ?string + { + $value = Connection::preparedFetchValue( + 'SELECT StValue FROM settings WHERE StKey = ? AND StUsID = 0', + [$key], + 'StValue' + ); + return $value === null ? null : (string) $value; + } + + private function translatorIn(string $locale): Translator + { + $translator = new Translator(getcwd() . '/locale', 'en'); + $translator->setLocale($locale); + $container = new Container(); + $container->instance(Translator::class, $translator); + Container::setInstance($container); + return $translator; + } + + public function testFilterParameterReachesTheNavbarAndNotTheGlobalRow(): void + { + $globalBefore = $this->globalValue('currentlanguage'); + $_GET['filterlang'] = $_REQUEST['filterlang'] = (string) self::$otherLanguageId; + + InputValidator::getStringWithDb('filterlang', 'currentlanguage'); + + $this->assertSame(self::$otherLanguageId, CurrentLanguage::resolveId()); + $this->assertSame($globalBefore, $this->globalValue('currentlanguage')); + } + + public function testRememberedSortOrderIsPerUser(): void + { + $globalBefore = $this->globalValue('currentwordsort'); + $_GET['sort'] = $_REQUEST['sort'] = '3'; + + InputValidator::getIntWithDb('sort', 'currentwordsort', 1); + + $this->assertSame('3', Settings::get('currentwordsort')); + $this->assertSame($globalBefore, $this->globalValue('currentwordsort')); + } + + public function testFollowingALanguageMakesItCurrent(): void + { + CurrentLanguage::follow(self::$otherLanguageId); + + $this->assertSame(self::$otherLanguageId, CurrentLanguage::resolveId()); + } + + public function testAnotherUsersLanguageCannotBecomeCurrent(): void + { + CurrentLanguage::follow(self::$foreignLanguageId); + + // The stored row, since resolveId() would hide a bad write behind its fallback. + $this->assertSame((string) self::$languageId, Settings::getOwnValue('currentlanguage')); + } + + public function testCreatingALanguageMakesItCurrent(): void + { + $result = (new LanguageApiHandler())->formatCreate(['name' => 'CurrentCreated']); + + $this->assertTrue($result['success']); + $this->assertSame($result['id'], CurrentLanguage::resolveId()); + } + + public function testSettingTheDefaultLanguageIsPerUser(): void + { + $globalBefore = $this->globalValue('currentlanguage'); + + (new LanguageApiHandler())->formatSetDefault(self::$otherLanguageId); + + $this->assertSame(self::$otherLanguageId, CurrentLanguage::resolveId()); + $this->assertSame($globalBefore, $this->globalValue('currentlanguage')); + } + + public function testUsersOwnInterfaceLanguageIsApplied(): void + { + $translator = $this->translatorIn('en'); + Settings::savePerUser('app_language', 'de'); + + UserLocale::apply(); + + $this->assertSame('de', $translator->getLocale()); + } + + public function testUsersChoiceBeatsWhatBootSettledOn(): void + { + // Boot applies the lwt_lang cookie, so this stands for a stale one. + $translator = $this->translatorIn('fr'); + Settings::savePerUser('app_language', 'en'); + + UserLocale::apply(); + + $this->assertSame('en', $translator->getLocale()); + } + + public function testNoChoiceKeepsTheLocaleFromBoot(): void + { + $translator = $this->translatorIn('fr'); + + UserLocale::apply(); + + $this->assertSame('fr', $translator->getLocale()); + } + + public function testAnotherUsersChoiceIsNotApplied(): void + { + $translator = $this->translatorIn('en'); + Settings::saveForUser('app_language', 'de', self::$otherUserId); + + UserLocale::apply(); + + $this->assertSame('en', $translator->getLocale()); + } + + public function testUnknownLocaleIsIgnored(): void + { + $translator = $this->translatorIn('en'); + Settings::savePerUser('app_language', 'zz'); + + UserLocale::apply(); + + $this->assertSame('en', $translator->getLocale()); + } +} diff --git a/tests/frontend/user/preferences_app.test.ts b/tests/frontend/user/preferences_app.test.ts new file mode 100644 index 000000000..1f6da98dc --- /dev/null +++ b/tests/frontend/user/preferences_app.test.ts @@ -0,0 +1,65 @@ +/** + * Tests for preferences_app.ts - saving a new interface language + */ +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { preferencesAppData } from '../../../src/frontend/js/modules/user/pages/preferences_app'; +import { ProfileApi } from '../../../src/frontend/js/modules/user/api/profile_api'; + +describe('preferences_app.ts', () => { + const reload = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('location', { ...window.location, reload }); + vi.spyOn(ProfileApi, 'getPreferences').mockResolvedValue({ + data: { settings: { app_language: 'en', 'set-tts': '0' } } + } as never); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + reload.mockClear(); + }); + + async function loaded(): Promise> { + const app = preferencesAppData(); + await app.load(); + return app; + } + + function saveAnswers(data: unknown): void { + vi.spyOn(ProfileApi, 'savePreferences').mockResolvedValue(data as never); + } + + it('reloads after a new interface language is saved', async () => { + saveAnswers({ data: { success: true } }); + const app = await loaded(); + app.setValue('app_language', 'de'); + + await app.save(); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('stays on the page when the interface language is unchanged', async () => { + saveAnswers({ data: { success: true } }); + const app = await loaded(); + app.setValue('set-tts', '1'); + + await app.save(); + + expect(reload).not.toHaveBeenCalled(); + expect(app.success).not.toBe(''); + }); + + it('does not reload when the save fails', async () => { + saveAnswers({ error: 'nope' }); + const app = await loaded(); + app.setValue('app_language', 'de'); + + await app.save(); + + expect(reload).not.toHaveBeenCalled(); + expect(app.error).toBe('nope'); + }); +});