mirror of
https://github.com/OutlineFoundation/outline-server.git
synced 2026-08-04 14:37:34 +00:00
Clean up language logic
- Creates a i18n library with tests - Moves logic out of ui component (it shouldn't be there) - Removes another entry point from index.html
This commit is contained in:
parent
85a0d54823
commit
ede3057adf
6 changed files with 205 additions and 71 deletions
|
|
@ -23,7 +23,6 @@
|
|||
"iron-collapse": "PolymerElements/iron-collapse#^2.2.1",
|
||||
"iron-fit-behavior": "PolymerElements/iron-fit-behavior#^2.1.0",
|
||||
"iron-pages": "PolymerElements/iron-pages#^2.0.1",
|
||||
"outline-i18n": "Jigsaw-Code/outline-i18n#^0.0.7",
|
||||
"paper-button": "PolymerElements/paper-button#^2.0.0",
|
||||
"paper-checkbox": "PolymerElements/paper-checkbox#^2.0.4",
|
||||
"paper-dialog": "PolymerElements/paper-dialog#^2.0.1",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@
|
|||
|
||||
<link rel="stylesheet" href="/ui_components/style.css" />
|
||||
<script src="/bower_components/webcomponentsjs/webcomponents-loader.js"></script>
|
||||
<script src="/bower_components/outline-i18n/index.js"></script>
|
||||
<link rel="import" href="/ui_components/app-root.html" />
|
||||
<script src="server_manager/web_app/main.js"></script>
|
||||
|
||||
|
|
@ -37,8 +36,5 @@
|
|||
|
||||
<title>Outline Manager</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root id="appRoot"></app-root>
|
||||
</body>
|
||||
<body></body>
|
||||
</html>
|
||||
|
|
|
|||
49
src/server_manager/infrastructure/i18n.spec.ts
Normal file
49
src/server_manager/infrastructure/i18n.spec.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Copyright 2020 The Outline Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as i18n from './i18n';
|
||||
|
||||
describe('LanguageMatcher', () => {
|
||||
it('returns supported language on match', () => {
|
||||
const SUPPORTED_LANGUAGES = i18n.languageList(["es", "pt-BR", "ru"]);
|
||||
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES, undefined);
|
||||
const supportedLanguage = matcher.getBestSupportedLanguage(i18n.languageList(["pt-PT"]));
|
||||
expect(supportedLanguage.string()).toEqual('pt-BR');
|
||||
});
|
||||
it('returns the right variant', () => {
|
||||
const SUPPORTED_LANGUAGES = i18n.languageList(['en-GB', 'en-IN', 'en-US']);
|
||||
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES, undefined);
|
||||
const supportedLanguage = matcher.getBestSupportedLanguage(i18n.languageList(['en-IN']));
|
||||
expect(supportedLanguage.string()).toEqual('en-IN');
|
||||
});
|
||||
it('prefers first matched user language', () => {
|
||||
const SUPPORTED_LANGUAGES = i18n.languageList(['en-US', 'pt-BR']);
|
||||
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES, undefined);
|
||||
const supportedLanguage =
|
||||
matcher.getBestSupportedLanguage(i18n.languageList(['cn', 'en-GB', 'pt-BR']));
|
||||
expect(supportedLanguage.string()).toEqual('en-US');
|
||||
});
|
||||
it('returns default on no match', () => {
|
||||
const SUPPORTED_LANGUAGES = i18n.languageList(['es', 'pt-BR', 'ru']);
|
||||
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES, new i18n.LanguageCode('fr'));
|
||||
const supportedLanguage = matcher.getBestSupportedLanguage(i18n.languageList(['cn']));
|
||||
expect(supportedLanguage.string()).toEqual('fr');
|
||||
});
|
||||
it('returns undefined on no match and no default', () => {
|
||||
const SUPPORTED_LANGUAGES = i18n.languageList(["es", "pt-BR", "ru"]);
|
||||
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES);
|
||||
const supportedLanguage = matcher.getBestSupportedLanguage(i18n.languageList(["cn"]));
|
||||
expect(supportedLanguage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
87
src/server_manager/infrastructure/i18n.ts
Normal file
87
src/server_manager/infrastructure/i18n.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Copyright 2018 The Outline Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export class LanguageCode {
|
||||
private language: string;
|
||||
private normalized: string;
|
||||
|
||||
constructor(languageCodeStr: string) {
|
||||
this.language = languageCodeStr;
|
||||
this.normalized = languageCodeStr.toLowerCase();
|
||||
}
|
||||
matches(other: LanguageCode): boolean {
|
||||
return this.normalized === other.normalized;
|
||||
}
|
||||
string(): string {
|
||||
return this.language;
|
||||
}
|
||||
split(): string[] {
|
||||
return this.language.split('-');
|
||||
}
|
||||
}
|
||||
|
||||
export class LanguageMatcher {
|
||||
constructor(
|
||||
private supportedLanguages: LanguageCode[],
|
||||
private defaultLanguage: LanguageCode = undefined) {}
|
||||
|
||||
// Goes over each user language, trying to find the supported language that matches
|
||||
// the best. We'll trim variants of the user and supported languages in order to find
|
||||
// a match, but the language base is guaranteed to match.
|
||||
getBestSupportedLanguage(userLanguages: LanguageCode[]): LanguageCode | undefined {
|
||||
for (const userLanguage of userLanguages) {
|
||||
const parts = userLanguage.split();
|
||||
while (parts.length > 0) {
|
||||
const trimmedUserLanguage = new LanguageCode(parts.join('-'));
|
||||
const supportedLanguage = this.getSupportedLanguage(trimmedUserLanguage);
|
||||
if (supportedLanguage) {
|
||||
return supportedLanguage;
|
||||
}
|
||||
parts.pop();
|
||||
}
|
||||
}
|
||||
return this.defaultLanguage;
|
||||
}
|
||||
|
||||
// Returns the closest supported language that matches the user language.
|
||||
// We make sure the language matches, but the variant may differ.
|
||||
private getSupportedLanguage(userLanguage: LanguageCode): LanguageCode | undefined {
|
||||
for (const supportedLanguage of this.supportedLanguages) {
|
||||
const parts = supportedLanguage.split();
|
||||
while (parts.length > 0) {
|
||||
const trimmedSupportedLanguage = new LanguageCode(parts.join('-'));
|
||||
if (userLanguage.matches(trimmedSupportedLanguage)) {
|
||||
return supportedLanguage;
|
||||
}
|
||||
parts.pop();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function languageList(languagesAsStr: string[]): LanguageCode[] {
|
||||
return languagesAsStr.map(l => new LanguageCode(l));
|
||||
}
|
||||
|
||||
// Returns the languages supported by the browser.
|
||||
export function getBrowserLanguages(): LanguageCode[] {
|
||||
// Ensure that navigator.languages is defined and not empty, as can be the case with some browsers
|
||||
// (i.e. Chrome 59 on Electron).
|
||||
let languages = navigator.languages as string[];
|
||||
if (!languages || languages.length === 0) {
|
||||
languages = [navigator.language];
|
||||
}
|
||||
return languageList(languages);
|
||||
}
|
||||
|
|
@ -540,70 +540,15 @@
|
|||
|
||||
static get properties() {
|
||||
return {
|
||||
LANGUAGES_AVAILABLE: {
|
||||
type: Object,
|
||||
readonly: true,
|
||||
value: {
|
||||
am: {id: "am", dir: "ltr"},
|
||||
ar: {id: "ar", dir: "rtl"},
|
||||
bg: {id: "bg", dir: "ltr"},
|
||||
ca: {id: "ca", dir: "ltr"},
|
||||
cs: {id: "cs", dir: "ltr"},
|
||||
da: {id: "da", dir: "ltr"},
|
||||
de: {id: "de", dir: "ltr"},
|
||||
el: {id: "el", dir: "ltr"},
|
||||
en: {id: "en", dir: "ltr"},
|
||||
"es-419": {id: "es-419", dir: "ltr"},
|
||||
fa: {id: "fa", dir: "rtl"},
|
||||
fi: {id: "fi", dir: "ltr"},
|
||||
fil: {id: "fil", dir: "ltr"},
|
||||
fr: {id: "fr", dir: "ltr"},
|
||||
he: {id: "he", dir: "rtl"},
|
||||
hi: {id: "hi", dir: "ltr"},
|
||||
hr: {id: "hr", dir: "ltr"},
|
||||
hu: {id: "hu", dir: "ltr"},
|
||||
id: {id: "id", dir: "ltr"},
|
||||
it: {id: "it", dir: "ltr"},
|
||||
ja: {id: "ja", dir: "ltr"},
|
||||
ko: {id: "ko", dir: "ltr"},
|
||||
km: {id: "km", dir: "ltr"},
|
||||
lt: {id: "lt", dir: "ltr"},
|
||||
lv: {id: "lv", dir: "ltr"},
|
||||
nl: {id: "nl", dir: "ltr"},
|
||||
no: {id: "no", dir: "ltr"},
|
||||
pl: {id: "pl", dir: "ltr"},
|
||||
"pt-BR": {id: "pt-BR", dir: "ltr"},
|
||||
ro: {id: "ro", dir: "ltr"},
|
||||
ru: {id: "ru", dir: "ltr"},
|
||||
sk: {id: "sk", dir: "ltr"},
|
||||
sl: {id: "sl", dir: "ltr"},
|
||||
sr: {id: "sr", dir: "ltr"},
|
||||
"sr-Latn": {id: "sr-Latn", dir: "ltr"},
|
||||
sv: {id: "sv", dir: "ltr"},
|
||||
th: {id: "th", dir: "ltr"},
|
||||
tr: {id: "tr", dir: "ltr"},
|
||||
uk: {id: "uk", dir: "ltr"},
|
||||
ur: {id: "ur", dir: "rtl"},
|
||||
vi: {id: "vi", dir: "ltr"},
|
||||
zh: {id: "zh", dir: "ltr"},
|
||||
"zh-CN": {id: "zh-CN", dir: "ltr"},
|
||||
"zh-TW": {id: "zh-TW", dir: "ltr"},
|
||||
},
|
||||
},
|
||||
DEFAULT_LANGUAGE: {
|
||||
// Properties language and useKeyIfMissing are used by Polymer.AppLocalizeBehavior.
|
||||
language: {
|
||||
type: String,
|
||||
readonly: true,
|
||||
value: "en",
|
||||
},
|
||||
useKeyIfMissing: {
|
||||
type: Boolean,
|
||||
value: true,
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
readonly: true,
|
||||
computed: "_computeLanguage(LANGUAGES_AVAILABLE, DEFAULT_LANGUAGE)",
|
||||
},
|
||||
serverList: {
|
||||
type: Array,
|
||||
value: [],
|
||||
|
|
@ -653,9 +598,8 @@
|
|||
super.ready();
|
||||
const messagesUrl = `/messages/${this.language}.json`;
|
||||
this.loadResources(messagesUrl, this.language);
|
||||
const languageProperties = this.LANGUAGES_AVAILABLE[this.language];
|
||||
if (languageProperties && languageProperties.dir === "rtl") {
|
||||
document.documentElement.setAttribute("dir", "rtl");
|
||||
const langDir = this.getAttribute("dir");
|
||||
if (langDir === "rtl") {
|
||||
this.$.appDrawer.align = "right";
|
||||
this.$.sideBar.align = "right";
|
||||
}
|
||||
|
|
@ -976,10 +920,6 @@
|
|||
this.maybeCloseDrawer();
|
||||
}
|
||||
|
||||
_computeLanguage(LANGUAGES_AVAILABLE, DEFAULT_LANGUAGE) {
|
||||
return OutlineI18n.getBestMatchingLanguage(Object.keys(LANGUAGES_AVAILABLE)) || DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
// Wrapper to encode a string in base64. This is necessary to set the server view IDs to
|
||||
// the display server IDs, which are URLs, so they can be used with selector methods. The IDs
|
||||
// are never decoded.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import * as url from 'url';
|
||||
|
||||
import * as digitalocean_api from '../cloud/digitalocean_api';
|
||||
import * as i18n from '../infrastructure/i18n';
|
||||
|
||||
import {App, DATA_LIMITS_AVAILABILITY_DATE} from './app';
|
||||
import {DigitalOceanTokenManager} from './digitalocean_oauth';
|
||||
|
|
@ -23,6 +24,61 @@ import {DisplayServerRepository} from './display_server';
|
|||
import {ManualServerRepository} from './manual_server';
|
||||
import {DEFAULT_PROMPT_IMPRESSION_DELAY_MS, OutlineSurveys} from './survey';
|
||||
|
||||
const SUPPORTED_LANGUAGES: {[key: string]: {id: string, dir: string}} = {
|
||||
'am': {id: 'am', dir: 'ltr'},
|
||||
'ar': {id: 'ar', dir: 'rtl'},
|
||||
'bg': {id: 'bg', dir: 'ltr'},
|
||||
'ca': {id: 'ca', dir: 'ltr'},
|
||||
'cs': {id: 'cs', dir: 'ltr'},
|
||||
'da': {id: 'da', dir: 'ltr'},
|
||||
'de': {id: 'de', dir: 'ltr'},
|
||||
'el': {id: 'el', dir: 'ltr'},
|
||||
'en': {id: 'en', dir: 'ltr'},
|
||||
'es-419': {id: 'es-419', dir: 'ltr'},
|
||||
'fa': {id: 'fa', dir: 'rtl'},
|
||||
'fi': {id: 'fi', dir: 'ltr'},
|
||||
'fil': {id: 'fil', dir: 'ltr'},
|
||||
'fr': {id: 'fr', dir: 'ltr'},
|
||||
'he': {id: 'he', dir: 'rtl'},
|
||||
'hi': {id: 'hi', dir: 'ltr'},
|
||||
'hr': {id: 'hr', dir: 'ltr'},
|
||||
'hu': {id: 'hu', dir: 'ltr'},
|
||||
'id': {id: 'id', dir: 'ltr'},
|
||||
'it': {id: 'it', dir: 'ltr'},
|
||||
'ja': {id: 'ja', dir: 'ltr'},
|
||||
'ko': {id: 'ko', dir: 'ltr'},
|
||||
'km': {id: 'km', dir: 'ltr'},
|
||||
'lt': {id: 'lt', dir: 'ltr'},
|
||||
'lv': {id: 'lv', dir: 'ltr'},
|
||||
'nl': {id: 'nl', dir: 'ltr'},
|
||||
'no': {id: 'no', dir: 'ltr'},
|
||||
'pl': {id: 'pl', dir: 'ltr'},
|
||||
'pt-BR': {id: 'pt-BR', dir: 'ltr'},
|
||||
'ro': {id: 'ro', dir: 'ltr'},
|
||||
'ru': {id: 'ru', dir: 'ltr'},
|
||||
'sk': {id: 'sk', dir: 'ltr'},
|
||||
'sl': {id: 'sl', dir: 'ltr'},
|
||||
'sr': {id: 'sr', dir: 'ltr'},
|
||||
'sr-Latn': {id: 'sr-Latn', dir: 'ltr'},
|
||||
'sv': {id: 'sv', dir: 'ltr'},
|
||||
'th': {id: 'th', dir: 'ltr'},
|
||||
'tr': {id: 'tr', dir: 'ltr'},
|
||||
'uk': {id: 'uk', dir: 'ltr'},
|
||||
'ur': {id: 'ur', dir: 'rtl'},
|
||||
'vi': {id: 'vi', dir: 'ltr'},
|
||||
'zh': {id: 'zh', dir: 'ltr'},
|
||||
'zh-CN': {id: 'zh-CN', dir: 'ltr'},
|
||||
'zh-TW': {id: 'zh-TW', dir: 'ltr'},
|
||||
};
|
||||
|
||||
function languageToUse(): i18n.LanguageCode {
|
||||
const supportedLanguages = i18n.languageList(Object.keys(SUPPORTED_LANGUAGES));
|
||||
const defaultLanguage = new i18n.LanguageCode('en');
|
||||
const userLanguages = i18n.getBrowserLanguages();
|
||||
return new i18n.LanguageMatcher(supportedLanguages, defaultLanguage)
|
||||
.getBestSupportedLanguage(userLanguages);
|
||||
}
|
||||
|
||||
function ensureString(queryParam: string|string[]): string {
|
||||
if (Array.isArray(queryParam)) {
|
||||
// We pick the last one if the parameter appears multiple times.
|
||||
|
|
@ -48,10 +104,17 @@ document.addEventListener('WebComponentsReady', () => {
|
|||
};
|
||||
|
||||
// Create and start the app.
|
||||
const language = languageToUse();
|
||||
const languageDirection = SUPPORTED_LANGUAGES[language.string()].dir;
|
||||
document.documentElement.setAttribute('dir', languageDirection);
|
||||
const appRootEl = document.createElement('app-root');
|
||||
appRootEl.setAttribute('language', language.string());
|
||||
appRootEl.setAttribute('dir', languageDirection);
|
||||
document.body.appendChild(appRootEl);
|
||||
// NOTE: this cast is safe and allows us to leverage Polymer typings since we haven't migrated to
|
||||
// Polymer 3, which adds typescript support.
|
||||
// tslint:disable-next-line:no-any
|
||||
const appRoot: polymer.Base = (document.getElementById('appRoot') as any) as polymer.Base;
|
||||
const appRoot = appRootEl as unknown as polymer.Base;
|
||||
new App(
|
||||
appRoot, version, digitalocean_api.createDigitalOceanSession,
|
||||
digitalOceanServerRepositoryFactory, new ManualServerRepository('manualServers'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue