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..df31ff79 100644 --- a/src/server_manager/index.html +++ b/src/server_manager/index.html @@ -25,7 +25,6 @@ - @@ -37,8 +36,5 @@ 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..3e999736 --- /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 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); +} diff --git a/src/server_manager/ui_components/app-root.html b/src/server_manager/ui_components/app-root.html index 97757b1b..a98fdf6c 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: [], @@ -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. diff --git a/src/server_manager/web_app/main.ts b/src/server_manager/web_app/main.ts index e88fe240..a960e7b4 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 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'),