Skip to content

Commit 5397155

Browse files
elzodybackportbot[bot]
authored andcommitted
refactor(l10n): move language tag logic to backend
Moving the Nextcloud language + locale to BCP 47 language tag logic to the backend makes sense because it exists in two places when it should really exist in one. This keeps it maintainable. The backend is the natural place because requests to Collabora happen there, and the logic can be injected into the frontend via initial state. Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Elizabeth Danzberger <elizabeth@elzody.dev>
1 parent 03aefdd commit 5397155

9 files changed

Lines changed: 136 additions & 63 deletions

File tree

lib/Conversion/ConversionProvider.php

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace OCA\Richdocuments\Conversion;
1111

12+
use OCA\Richdocuments\Service\LanguageService;
1213
use OCA\Richdocuments\Service\RemoteService;
1314
use OCA\Richdocuments\Service\SecureViewService;
1415
use OCP\Files\Conversion\ConversionMimeProvider;
@@ -56,6 +57,7 @@ public function __construct(
5657
private LoggerInterface $logger,
5758
IFactory $l10nFactory,
5859
private SecureViewService $secureViewService,
60+
private LanguageService $languageService,
5961
) {
6062
$this->l10n = $l10nFactory->get('richdocuments');
6163
}
@@ -168,17 +170,10 @@ public function convertFile(File $file, string $targetMimeType): mixed {
168170
return $this->remoteService->convertFileTo(
169171
$file,
170172
$targetFileExtension,
171-
conversionOptions: ['lang' => $this->getConversionLanguage()]
173+
conversionOptions: ['lang' => $this->languageService->getBCP47LanguageTag()]
172174
);
173175
}
174176

175-
private function getConversionLanguage(): string {
176-
$locale = $this->l10n->getLocaleCode();
177-
$language = $locale !== '' ? $locale : $this->l10n->getLanguageCode();
178-
179-
return str_replace('_', '-', $language);
180-
}
181-
182177
private function getMimeProvidersFor(array $inputMimeTypes, string $outputMimeType): array {
183178
$outputMimeInfo = $this->getMimeInfoFor($outputMimeType);
184179
if ($outputMimeInfo === null) {

lib/Service/InitialStateService.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public function __construct(
2828
private TemplateManager $templateManager,
2929
private CapabilitiesService $capabilitiesService,
3030
private IConfig $config,
31+
private LanguageService $languageService,
3132
private ?string $userId,
3233
) {
3334
}
@@ -42,6 +43,7 @@ public function provideCapabilities(): void {
4243
$this->initialState->provideInitialState('hasNextcloudBranding', $this->capabilitiesService->hasNextcloudBranding());
4344
$this->initialState->provideInitialState('instanceId', $this->config->getSystemValue('instanceid'));
4445
$this->initialState->provideInitialState('wopi_callback_url', $this->appConfig->getNextcloudUrl());
46+
$this->initialState->provideInitialState('bcp47Language', $this->languageService->getBCP47LanguageTag());
4547
$this->provideOptions();
4648

4749
$this->hasProvidedCapabilities = true;

lib/Service/LanguageService.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Richdocuments\Service;
11+
12+
use OCA\Richdocuments\AppInfo\Application;
13+
use OCP\L10N\IFactory;
14+
15+
class LanguageService {
16+
private const LOCALE_OVERRIDES = [
17+
'de' => [
18+
'de_CH' => 'de-CH',
19+
'gsw' => 'de-CH',
20+
'gsw_CH' => 'de-CH',
21+
],
22+
'fr' => [
23+
'fr_CH' => 'fr-CH',
24+
],
25+
'it' => [
26+
'it_CH' => 'it-CH',
27+
],
28+
];
29+
30+
public function __construct(
31+
private IFactory $l10nFactory,
32+
) {
33+
}
34+
35+
/**
36+
* Converts the current user's Nextcloud language/locale settings into the
37+
* BCP 47 language tag Collabora Online expects.
38+
*/
39+
public function getBCP47LanguageTag(): string {
40+
$l10n = $this->l10nFactory->get(Application::APPNAME);
41+
42+
// getLanguageCode()/getLocaleCode() mirror @nextcloud/l10n's getLanguage()/getLocale()
43+
$language = str_replace('_', '-', $l10n->getLanguageCode());
44+
$locale = $l10n->getLocaleCode();
45+
46+
$language = match ($language) {
47+
'de-DE' => 'de', // German formal should just be treated as 'de'
48+
'es-419' => 'es-MX', // not a valid locale string in COOL
49+
default => $language,
50+
};
51+
52+
if ($language === 'en-GB' && $locale === 'en_AU') {
53+
$language = 'en-AU';
54+
}
55+
56+
return self::LOCALE_OVERRIDES[$language][$locale] ?? $language;
57+
}
58+
}

src/components/CoolFrame.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
<script>
2929
3030
import { generateCSSVarTokens, getCollaboraTheme, getUITheme } from '../helpers/coolParameters.js'
31-
import { languageToBCP47 } from '../helpers/index.js'
31+
import { loadState } from '@nextcloud/initial-state'
3232
import PostMessageService from '../services/postMessage.tsx'
3333
3434
export default {
@@ -74,7 +74,7 @@ export default {
7474
window.addEventListener('message', this.handlePostMessage)
7575
7676
if (this.iframeUrl.length > 0) {
77-
this.formAction = this.iframeUrl + '?lang=' + languageToBCP47()
77+
this.formAction = this.iframeUrl + '?lang=' + loadState('richdocuments', 'bcp47Language', '')
7878
this.isIframeLoaded = true
7979
} else {
8080
return

src/helpers/index.js

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,6 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import { getLanguage, getLocale } from '@nextcloud/l10n'
7-
8-
const languageToBCP47 = () => {
9-
let language = getLanguage().replace(/_/g, '-')
10-
const locale = getLocale()
11-
12-
// German formal should just be treated as 'de'
13-
if (language === 'de-DE') {
14-
language = 'de'
15-
}
16-
17-
// es-419 should be mapped as this is not considered a valid locale string in COOL
18-
if (language === 'es-419') {
19-
language = 'es-MX'
20-
}
21-
22-
// Australia
23-
if (language === 'en-GB' && locale === 'en_AU') {
24-
language = 'en-AU'
25-
}
26-
27-
// special case where setting the bc47 region depending on the locale setting makes sense
28-
const whitelist = {
29-
de: {
30-
de_CH: 'de-CH',
31-
gsw: 'de-CH',
32-
gsw_CH: 'de-CH',
33-
},
34-
fr: {
35-
fr_CH: 'fr-CH',
36-
},
37-
it: {
38-
it_CH: 'it-CH',
39-
},
40-
}
41-
const matchingWhitelist = whitelist[language]
42-
if (typeof matchingWhitelist !== 'undefined' && typeof matchingWhitelist[locale] !== 'undefined') {
43-
return matchingWhitelist[locale]
44-
}
45-
46-
// Collabora Online expects BCP47 language tag syntax.
47-
// When the Nextcloud language consists of two parts, we send both,
48-
// as the region is then provided by the language setting.
49-
return language
50-
}
51-
526
const getNextcloudVersion = () => {
537
return parseInt(OC.config.version.split('.')[0])
548
}
@@ -68,7 +22,6 @@ const getRandomId = (length = 5) => {
6822
}
6923

7024
export {
71-
languageToBCP47,
7225
getNextcloudVersion,
7326
splitPath,
7427
getRandomId,

src/helpers/url.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import { getRootUrl, generateUrl } from '@nextcloud/router'
77
import { getSharingToken } from '@nextcloud/sharing/public'
8-
import { languageToBCP47 } from './index.js'
98
import Config from './../services/config.tsx'
109

1110
const getSearchParam = (name) => {
@@ -39,7 +38,7 @@ const getWopiUrl = ({ fileId, readOnly, closeButton, revisionHistory, target = u
3938
// https://<loolwsd-server>:9980/hosting/discovery
4039
return Config.get('urlsrc')
4140
+ 'WOPISrc=' + encodeURIComponent(getWopiSrc(fileId))
42-
+ '&lang=' + languageToBCP47()
41+
+ '&lang=' + Config.get('bcp47Language')
4342
+ (closeButton ? '&closebutton=1' : '')
4443
+ (revisionHistory ? '&revisionhistory=1' : '')
4544
+ (readOnly ? '&permission=readonly' : '')

src/services/config.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class ConfigService {
1111
constructor() {
1212
this.values = {
1313
wopi_callback_url: loadState('richdocuments', 'wopi_callback_url', ''),
14+
bcp47Language: loadState('richdocuments', 'bcp47Language', ''),
1415
...loadState('richdocuments', 'document', {}),
1516
}
1617
}

tests/lib/Conversion/ConversionProviderTest.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace Tests\Richdocuments\Conversion;
1111

1212
use OCA\Richdocuments\Conversion\ConversionProvider;
13+
use OCA\Richdocuments\Service\LanguageService;
1314
use OCA\Richdocuments\Service\RemoteOptionsService;
1415
use OCA\Richdocuments\Service\RemoteService;
1516
use OCA\Richdocuments\Service\SecureViewService;
@@ -26,6 +27,7 @@ class ConversionProviderTest extends TestCase {
2627
private IFactory&MockObject $l10nFactory;
2728
private IL10N&MockObject $l10n;
2829
private SecureViewService&MockObject $secureViewService;
30+
private LanguageService&MockObject $languageService;
2931
private ConversionProvider $provider;
3032

3133
protected function setUp(): void {
@@ -36,6 +38,7 @@ protected function setUp(): void {
3638
$this->l10nFactory = $this->createMock(IFactory::class);
3739
$this->l10n = $this->createMock(IL10N::class);
3840
$this->secureViewService = $this->createMock(SecureViewService::class);
41+
$this->languageService = $this->createMock(LanguageService::class);
3942

4043
$this->l10n->method('t')->willReturnCallback(static fn (string $text): string => $text);
4144
$this->l10nFactory->method('get')
@@ -47,20 +50,21 @@ protected function setUp(): void {
4750
$this->logger,
4851
$this->l10nFactory,
4952
$this->secureViewService,
53+
$this->languageService,
5054
);
5155
}
5256

5357
public function testConvertFilePassesCurrentLocaleToCollabora(): void {
5458
$file = $this->createMock(File::class);
5559

56-
$this->l10n->expects($this->once())
57-
->method('getLocaleCode')
58-
->willReturn('de_DE');
60+
$this->languageService->expects($this->once())
61+
->method('getBCP47LanguageTag')
62+
->willReturn('de');
5963
$this->secureViewService->method('isEnabled')
6064
->willReturn(false);
6165
$this->remoteService->expects($this->once())
6266
->method('convertFileTo')
63-
->with($file, 'pdf', RemoteOptionsService::REMOTE_TIMEOUT_DEFAULT, ['lang' => 'de-DE'])
67+
->with($file, 'pdf', RemoteOptionsService::REMOTE_TIMEOUT_DEFAULT, ['lang' => 'de'])
6468
->willReturn('pdf-content');
6569

6670
$result = $this->provider->convertFile($file, 'application/pdf');
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace Tests\Richdocuments\Service;
11+
12+
use OCA\Richdocuments\AppInfo\Application;
13+
use OCA\Richdocuments\Service\LanguageService;
14+
use OCP\IL10N;
15+
use OCP\L10N\IFactory;
16+
use PHPUnit\Framework\MockObject\MockObject;
17+
use PHPUnit\Framework\TestCase;
18+
19+
class LanguageServiceTest extends TestCase {
20+
private IFactory&MockObject $l10nFactory;
21+
private IL10N&MockObject $l10n;
22+
private LanguageService $service;
23+
24+
protected function setUp(): void {
25+
parent::setUp();
26+
27+
$this->l10nFactory = $this->createMock(IFactory::class);
28+
$this->l10n = $this->createMock(IL10N::class);
29+
$this->l10nFactory->method('get')
30+
->with(Application::APPNAME)
31+
->willReturn($this->l10n);
32+
33+
$this->service = new LanguageService($this->l10nFactory);
34+
}
35+
36+
public static function languageProvider(): array {
37+
return [
38+
'plain pass-through' => ['en', 'en_US', 'en'],
39+
'German formal is treated as plain German' => ['de_DE', 'de_DE', 'de'],
40+
'es-419 is mapped to es-MX for COOL' => ['es_419', 'es_419', 'es-MX'],
41+
'Australian English' => ['en_GB', 'en_AU', 'en-AU'],
42+
'British English without Australian locale stays en-GB' => ['en_GB', 'en_GB', 'en-GB'],
43+
'Swiss German locale' => ['de', 'de_CH', 'de-CH'],
44+
'Swiss German (gsw locale)' => ['de', 'gsw', 'de-CH'],
45+
'Swiss German (gsw_CH locale)' => ['de', 'gsw_CH', 'de-CH'],
46+
'Swiss French locale' => ['fr', 'fr_CH', 'fr-CH'],
47+
'Swiss Italian locale' => ['it', 'it_CH', 'it-CH'],
48+
'German without Swiss locale stays de' => ['de', 'de_DE', 'de'],
49+
];
50+
}
51+
52+
/**
53+
* @dataProvider languageProvider
54+
*/
55+
public function testGetBCP47LanguageTag(string $languageCode, string $localeCode, string $expected): void {
56+
$this->l10n->method('getLanguageCode')->willReturn($languageCode);
57+
$this->l10n->method('getLocaleCode')->willReturn($localeCode);
58+
59+
$this->assertSame($expected, $this->service->getBCP47LanguageTag());
60+
}
61+
}

0 commit comments

Comments
 (0)