rbalet / ngx-translate-multi-http-loader

A loader for ngx-translate that loads translations with http calls
MIT License
77 stars 15 forks source link

The loader tries to load unneeded files, causes 404 errors in the browser and doesn't merge the files correctly. #4

Closed codehan-de closed 4 years ago

codehan-de commented 5 years ago

I use this extension to create business case-specific translations based on the current logged-in business case.

But I currently have the problem that the extension tries to load unnecessary files and throws a corresponding error message in the console.

Here is an example of my implementation:

My factory

export function multiTranslateHttpLoaderFactory(http: HttpClient, authService: UserAuthService) {
  return new MultiTranslateHttpLoader(http, [
    {prefix: './assets/i18n/default/', suffix: '.json'},
    {prefix: './assets/i18n/bc/', suffix: '.json'},
  ]);
}

The logic inside my AppComponent:

// Imports
... 

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnDestroy {

  constructor(private translate: TranslateService,
              private config: ConfigurationService,
              private settings: SettingsService,
              private authService: UserAuthService,
              private titleService: Title) {
    translate.addLangs(Object.keys(config.languages));
    translate.setDefaultLang(config.defaultLanguage);
    this.initLanguageBC();
    this.initTabName();
  }

  // Subscribe to the observable getAutoConfiguredBCLanguage$ to retrieve the selected language
  initLanguageBC() {
    const userDefinedLanguage =  this.settings.language;
    if (userDefinedLanguage) {
      // Use the selected language by the user
      this.translate.use(userDefinedLanguage);
    } else {
      // Use configured language
      this.config.getAutoConfiguredBCLanguage$()
      .pipe(takeUntil(this.destroy$))
      .subscribe(lang => {
        this.translate.use(lang);
      });
    }
    // Listen to translation change events
    this.translate.onLangChange.subscribe((langEvent: LangChangeEvent) => {
      console.log('set global language: ' + langEvent.lang);
      // Set locale and get specific locale settings
      moment.locale(langEvent.lang, this.getCustomMomentLocaleSettings(langEvent.lang));
    });
  }
}

The configuration of the language based on the currently logged in business case in my configuration service:

// Imports
...

@Injectable()
export class ConfigurationService {

  defaultLanguage = 'en';

  languages = {
    en: 'English',
    de: 'German',
    es: 'Spanish',
    ja: 'Japanese',
    pt: 'Portuguese',
  };

  // Business cases with available translation files
  businessCases = {
    riego: '_riego',
    ceres: '_ceres'
  };

  constructor(
    private translate: TranslateService,
    private authService: UserAuthService
  ) {}

  // Will emit a new  language every time  activeBusinessCase changes 
  getAutoConfiguredBCLanguage$(): Observable<string>{
    return this.authService.activeBusinessCase$.pipe(
      // use 'distinctUntilChanged' to only emit values that are different from the previous
      distinctUntilChanged(),
      map(bc => this.getAutoConfiguredBCLanguage(bc))
    );
  }

  // Gets the currently activeBusinessCase as input parameter
  getAutoConfiguredBCLanguage(activeBusinessCase?: string) {
    const browserLang = this.translate.getBrowserLang();
    console.log('BROWSER LANGUAGE: ', browserLang);
    console.log('ACTIVE BUSINESS CASE: ', activeBusinessCase);
    if (this.languages.hasOwnProperty(browserLang)) {
      switch (activeBusinessCase) {
        case 'RIEGO':
          return browserLang.concat(this.businessCases.riego);
        case 'CERES':
          return browserLang.concat(this.businessCases.ceres);
        default:
          return browserLang;
      }
    } else {
      return this.defaultLanguage;
    }
  }

}

The translations are working correctly, but the console sais:

GET https://localhost:8080/ui/assets/i18n/default/de_ceres.json 404 (Not Found) Could not find translation file: ./assets/i18n/default/de_ceres.json

or

GET https://localhost:8080/ui/assets/i18n/default/de_riego.json 404 (Not Found) Could not find translation file: ./assets/i18n/default/de_riego.json

And this happens, because the loader is searching for the specific files in the default folder, where only the default json files are placed (de.json, en.js, etc.).

The specific files are located in the bc folder (de_riego.json, de_ceres.json etc.)

Conversely, the loader also searches for the default files (de.json, en.json etc.) in the bc folder, although only the specific JSONs are stored here. This causes these error messages:

GET https://localhost:8080/ui/assets/i18n/bc/en.json 404 (Not Found) Could not find translation file: ./assets/i18n/bc/en.json

and

GET https://localhost:8080/ui/assets/i18n/bc/de.json 404 (Not Found) Could not find translation file: ./assets/i18n/bc/de.json

I also noticed that the files are not correctly merged. It is always a default file to be taken (eg de.json) and an additional specific file (eg de_riego.json). Currently, however, only the de_riego.json is taken and the contents of de.json are getting ignored, so that the browser automatically selects english.

What am I doing wrong here? Hope someone can help me..

denniske commented 5 years ago

What you want to do is to add new translation sources during runtime, right?

This is not really supported by translate loader / multitranslate loader.

But maybe you can try this way:

Do not create new languages for each of your business cases like "de_riego", "de_ceres". This should not be needed.

Instead when changing a business case use the API to change translation source configuration:

function businessCaseChanged(newBC) {
    this.translate.currentLoader.resources = [
        {prefix: './assets/i18n/default/', suffix: '.json'},
        {prefix: './assets/i18n/bc/' + newBC, suffix: '.json'},
    ];
    // This will reset currently loaded translations. Maybe you also need to trigger reload of new translations.
    this.translate.resetLang('de');
    this.translate.resetLang('en');
}

resources is a private property of the MultiTranslateLoader so this is a bit hacky but it should work.

See MultitranslateLoader source here.

You will probably now reload not only new bc translations but also the default translation files, so this is not optimal.

codehan-de commented 5 years ago

Hi.

First of all thank you for your quick reply.

Well, with my app, a business case is already given directly after the login. If the user uses the navbar to get into specific areas, this business case may change as well.

If I shouldn't create a separate file for each business case, I do not understand how a distinction should be made here.

For example, in assets /i18n/default I have a de.json that contains, for example, the following contents:

{
"Hello": "Hello",
"World": "World"
}

For example, in assets /i18n/bc, there is a de_riego.json with the following content:

{
"World": "Erde"
}

From this the following should be taken for the translation:

{
"Hello": "Hello",
"World": "Erde"
}

In my current version, however, only one file (de_riego.json) is taken, so the other translations are missing and the browser uses the default language.

In addition, I have the mentioned error messages in the browser, because the loader also looks under assets /i18n/bc for the default files (de.json, en.json, ..) and of course doesn't find them. Do the files have to have the same name to be merged correctly?

I will try your approach tomorrow. But doesn't the function have to be an exported function? And why exactly should I use this.translate.resetLang('de') to reset the currently loaded translations?

denniske commented 5 years ago

I have put my suggestion into the stackblitz here: https://stackblitz.com/edit/ngx-translate-multi-http-loader-sample-yss2uc

Does this work for you?

It is not optimal because translation files are reloaded. If you want a better version you will need to write your custom TranslateLoader.

codehan-de commented 5 years ago

I've got it, but errors are still being thrown in the console because it still unnecessarily searches for the specific files in the default folder.

My version worked by simply deleting the translate.setDefaultLang(config.defaultLanguage) line and swapping the remaining lines from the constructor to the ngOnInit lifecyle method.

Is there perhaps a simple way to correct the error messages or just ignore them?

denniske commented 5 years ago

I don't think that it is possible to correct the messages because your case is not really supported by this plugin. I would suggest you go with the solution in the stackblitz I posted. This won't lead to error messages.

codehan-de commented 5 years ago

I have already tried, unfortunately, your version does not work for me. The business cases are placed in many places of the application, for this reason probably. Currently everything works, but of course the error messages are annoying.

codehan-de commented 5 years ago

This is my current AppComponent (it's working like that, but only with error messages):

import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { UserAuthService } from './auth/user-auth.service';
import { SettingsService } from './auth/settings.service';
import { TranslateService, LangChangeEvent } from '@ngx-translate/core';
import { ConfigurationService } from './config/configuration.service';
import * as moment from 'moment';
import { Title } from '@angular/platform-browser';
import { environment } from '../environments/environment';
import { takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { MultiTranslateHttpLoader } from 'ngx-translate-multi-http-loader';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnDestroy, OnInit {

  // Needed for unsubscribing
  private destroy$ = new Subject<void>();

  constructor(private translate: TranslateService,
              private config: ConfigurationService,
              private settings: SettingsService,
              private authService: UserAuthService,
              private titleService: Title) {
  }

  @HostListener('window:focus')
  onFocus() {
    this.authService.checkAutoLogout();
  }

  ngOnInit() {
    this.translate.addLangs(Object.keys(this.config.languages));
    this.initLanguageBC();
    this.initTabName();
  }

  // Subscribe to the observable getAutoConfiguredBCLanguage$ to retrieve the selected language
  initLanguageBC() {
    const userDefinedLanguage =  this.settings.language;
    if (userDefinedLanguage) {
      // Use the selected language by the user
      this.translate.use(userDefinedLanguage);
    } else {
      // Use configured language
      this.config.getAutoConfiguredBCLanguage$()
      .pipe(takeUntil(this.destroy$))
      .subscribe(lang => {
        this.translate.use(lang);
      });
    }
    // Listen to translation change events
    this.translate.onLangChange.subscribe((langEvent: LangChangeEvent) => {
      console.log('set global language: ' + langEvent.lang);
      // Set locale and get specific locale settings
      moment.locale(langEvent.lang, this.getCustomMomentLocaleSettings(langEvent.lang));
    });
  }

  getCustomMomentLocaleSettings(lang) {
    if (lang === 'en') {
      return {
        relativeTime: {
          future: 'in %s',
          past: '%s ago',
          s: '1 second',
          ss: '%d seconds',
          m: '1 minute',
          mm: '%d minutes',
          h: '1 hour',
          hh: '%d hours',
          d: '1 day',
          dd: '%d days',
          M: '1 month',
          MM: '%d months',
          y: '1 year',
          yy: '%d years'
        }
      };
    }
    if (lang === 'de') {
      return {
        relativeTime: {
          future: 'in %s',
          past: 'vor %s',
          s: '1 Sekunde',
          ss: '%d Sekunden',
          m: '1 Minute',
          mm: '%d Minuten',
          h: '1 Stunde',
          hh: '%d Stunden',
          d: '1 Tag',
          dd: '%d Tage',
          M: '1 Monat',
          MM: '%d Monate',
          y: '1 Jahr',
          yy: '%d Jahre'
        }
      };
    }
    return undefined;
  }

  initTabName() {
    if (environment.default_business_case === 'DEMO') {
      this.titleService.setTitle('SmAg');
    } else {
      this.titleService.setTitle(environment.default_business_case);
    }
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }

}

export function multiTranslateHttpLoaderFactory(http: HttpClient, authService: UserAuthService) {
  return new MultiTranslateHttpLoader(http, [
    {prefix: './assets/i18n/default/', suffix: '.json'},
    {prefix: './assets/i18n/bc/', suffix: '.json'},
  ]);
}

And this is the current configurations.service:

import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import 'moment/locale/de';
import { UserAuthService } from 'app/auth/user-auth.service';
import { Observable } from 'rxjs';
import { map, distinctUntilChanged } from 'rxjs/operators';

@Injectable()
export class ConfigurationService {

  defaultLanguage = 'en';

  languages = {
    en: 'English',
    de: 'German',
    es: 'Spanish',
    ja: 'Japanese',
    pt: 'Portuguese',
  };

  // Business cases with available translation files
  businessCases = {
    riego: '_riego',
    ceres: '_ceres',
    milk: '_milk',
    asparagus: '_asparagus',
    traci: '_traci',
    smag: '_smag',
    demo: '_demo'
  };

  constructor(
    private translate: TranslateService,
    private authService: UserAuthService
  ) {}

  // Will emit a new language every time the activeBusinessCase changes
  getAutoConfiguredBCLanguage$(): Observable<string> {
    return this.authService.activeBusinessCase$.pipe(
      // using 'distinctUntilChanged' to only emit values that are different from the previous
      distinctUntilChanged(),
      map(bc => this.getAutoConfiguredBCLanguage(bc))
    );
  }

  // Gets the currently activeBusinessCase as an input parameter
  getAutoConfiguredBCLanguage(activeBusinessCase?: string) {
    const browserLang = this.translate.getBrowserLang();
    console.log('BROWSER LANGUAGE: ', browserLang);
    console.log('ACTIVE BUSINESS CASE: ', activeBusinessCase);
    if (this.languages.hasOwnProperty(browserLang)) {
      switch (activeBusinessCase) {
        case 'RIEGO':
          return browserLang.concat(this.businessCases.riego);
        case 'CERES':
          return browserLang.concat(this.businessCases.ceres);
        case 'MILK':
          return browserLang.concat(this.businessCases.milk);
        case 'ASPARAGUS':
          return browserLang.concat(this.businessCases.asparagus);
        case 'TRACI':
          return browserLang.concat(this.businessCases.traci);
        case 'SMAG':
          return browserLang.concat(this.businessCases.smag);
        case 'DEMO':
          return browserLang.concat(this.businessCases.demo);
        default:
          return browserLang;
      }
    } else {
      return this.defaultLanguage;
    }
  }

}

And these are the relevant parts of my authService:

@Injectable()
export class UserAuthService {
 public activeBusinessCase$ = new BehaviorSubject<string>(null);
  constructor(private router: Router,
              private httpClient: HttpClient,
              private pls: PathLocationStrategy,
              private titleService: Title) {
  }
  login(name: string, password: string, imTid: string): Observable<UiInfo> {
    return this.loginWithBackend(name, password, imTid).pipe(
      tap(() => {
        this.user.user_name = translate('default-user');
        // TODO: Check if loggedoff is obsolete?
        if (this.loggedOff) {
          this.pls.back();
        } else if (this.redirectUrl) {
          this.router.navigate([this.redirectUrl]);
          this.redirectUrl = null;
          console.log(Constants.texts.loginSuccessRedirect);
        } else {
          const bc = this.activeBusinessCase$.getValue();
          if (bc) {
            this.router.navigate([Constants.routing.explorer + bc.toLowerCase()]);
          } else {
            const err = new LoginError('Business case is missing');
            throw err;
          }
        }
        this.loggedOff = false;
      }));
  }
  setDefaultBusinessCaseIfNotExisting() {
    if (this.businessCases.length === 0) {
      this.businessCases.push(environment.default_business_case);
    }
    this.setActiveBusinessCase(this.businessCases[0]);
  }
  setActiveBusinessCase(bcase: string) {
    this.activeBusinessCase$.next(bcase); // Emit the value
    this.setTitle(bcase);
  }