Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null> {
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,
Expand Down
9 changes: 3 additions & 6 deletions cypress/e2e/24-anki-live.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>('ankiConnectVersion', anki).then((version) => {
ankiUp = version === 6;
});
});

beforeEach(function () {
Expand Down
14 changes: 14 additions & 0 deletions docs-src/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/Modules/Language/Http/LanguageApiHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
}

Expand Down Expand Up @@ -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];
}

Expand Down
3 changes: 3 additions & 0 deletions src/Modules/Review/Http/ReviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/Modules/Text/Http/TextReadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down
59 changes: 59 additions & 0 deletions src/Shared/I18n/UserLocale.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/**
* Applies the signed-in user's interface language.
*
* PHP version 8.2
*
* @category Lwt
* @package Lwt\Shared\I18n
* @author HugoFara <git@hugofara.net>
* @license Unlicense <http://unlicense.org/>
* @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);
}
}
}
24 changes: 24 additions & 0 deletions src/Shared/Infrastructure/Database/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
4 changes: 2 additions & 2 deletions src/Shared/Infrastructure/Http/InputValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
33 changes: 27 additions & 6 deletions src/Shared/Infrastructure/Language/CurrentLanguage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,6 +91,7 @@ public function handle(): bool
return false;
}

UserLocale::apply();
return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions src/frontend/js/modules/user/pages/preferences_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ProfileApi } from '../api/profile_api';
/** Alpine state for the preferences page. */
export interface PreferencesAppData {
settings: Record<string, string>;
loadedLocale: string | undefined;
isLoading: boolean;
isSaving: boolean;
error: string;
Expand All @@ -41,6 +42,7 @@ export interface PreferencesAppData {
export function preferencesAppData(): PreferencesAppData {
return {
settings: {},
loadedLocale: undefined,
isLoading: false,
isSaving: false,
error: '',
Expand All @@ -63,6 +65,7 @@ export function preferencesAppData(): PreferencesAppData {
}

this.settings = response.data.settings;
this.loadedLocale = this.settings.app_language;
},

async save(): Promise<void> {
Expand All @@ -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');
},

Expand Down
Loading
Loading