diff --git a/src/server_manager/bower.json b/src/server_manager/bower.json
index c4ab5bed..23dd797d 100644
--- a/src/server_manager/bower.json
+++ b/src/server_manager/bower.json
@@ -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",
diff --git a/src/server_manager/index.html b/src/server_manager/index.html
index 0abdd6ab..4f01b2b8 100644
--- a/src/server_manager/index.html
+++ b/src/server_manager/index.html
@@ -25,7 +25,6 @@
-
@@ -37,7 +36,6 @@
Outline Manager
-
diff --git a/src/server_manager/infrastructure/i18n.spec.ts b/src/server_manager/infrastructure/i18n.spec.ts
new file mode 100644
index 00000000..7c1f4ef6
--- /dev/null
+++ b/src/server_manager/infrastructure/i18n.spec.ts
@@ -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();
+ });
+});
diff --git a/src/server_manager/infrastructure/i18n.ts b/src/server_manager/infrastructure/i18n.ts
new file mode 100644
index 00000000..a7784ccb
--- /dev/null
+++ b/src/server_manager/infrastructure/i18n.ts
@@ -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 normalizedLanguage: string;
+
+ constructor(languageCodeStr: string) {
+ this.language = languageCodeStr;
+ this.normalizedLanguage = languageCodeStr.toLowerCase();
+ }
+ matches(other: LanguageCode): boolean {
+ return this.normalizedLanguage === other.normalizedLanguage;
+ }
+ 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);
+}
diff --git a/src/server_manager/ui_components/app-root.html b/src/server_manager/ui_components/app-root.html
index 97757b1b..eff677a9 100644
--- a/src/server_manager/ui_components/app-root.html
+++ b/src/server_manager/ui_components/app-root.html
@@ -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: [],
@@ -649,16 +594,8 @@
};
}
- ready() {
- 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");
- this.$.appDrawer.align = "right";
- this.$.sideBar.align = "right";
- }
+ constructor() {
+ super();
this.addEventListener("RegionSelected", this.handleRegionSelected);
this.addEventListener("SetUpGenericCloudProviderRequested", this.handleSetUpGenericCloudProviderRequested);
this.addEventListener("SetUpAwsRequested", this.handleSetUpAwsRequested);
@@ -666,6 +603,16 @@
this.addEventListener("ManualServerEntryCancelled", this.handleManualCancelled);
}
+ setLanguage(newLanguage, langDir) {
+ const messagesUrl = `/messages/${newLanguage}.json`;
+ this.loadResources(messagesUrl, newLanguage);
+ if (langDir === "rtl") {
+ this.$.appDrawer.align = "right";
+ this.$.sideBar.align = "right";
+ }
+ this.language = newLanguage;
+ }
+
showIntro() {
this.maybeCloseDrawer();
this.selectedServer = undefined;
@@ -976,10 +923,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.
diff --git a/src/server_manager/web_app/main.ts b/src/server_manager/web_app/main.ts
index e88fe240..69f647f3 100644
--- a/src/server_manager/web_app/main.ts
+++ b/src/server_manager/web_app/main.ts
@@ -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 getLanguageToUse(): 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,13 @@ document.addEventListener('WebComponentsReady', () => {
};
// Create and start the app.
+ const language = getLanguageToUse();
+ const languageDirection = SUPPORTED_LANGUAGES[language.string()].dir;
+ document.documentElement.setAttribute('dir', languageDirection);
// 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 = document.getElementById('appRoot') as unknown as polymer.Base;
+ appRoot.setLanguage(language.string(), languageDirection);
new App(
appRoot, version, digitalocean_api.createDigitalOceanSession,
digitalOceanServerRepositoryFactory, new ManualServerRepository('manualServers'),