ngx-translate / core

The internationalization (i18n) library for Angular
MIT License
4.52k stars 578 forks source link

Feature: pluralization #150

Closed gjuchault closed 7 years ago

gjuchault commented 8 years ago

Hi,

Have you been thinking about implementing pluralization ? https://angular-translate.github.io/docs/#/guide/14_pluralization

cschroeter commented 8 years ago

We are using the i18nPluralPipe to solve this.

Html

<div>
  {{ messages.length | i18nPlural: messageMapping | translate :{count: messages.length} }}
</div>

Typescript

class MyApp {
  messages: any[];
  messageMapping: any = {
    '=0': 'msg.none',
    '=1': 'msg.singular.',
    'other': 'msg.plural'
  }
  // ...
}

Json

{
  "msg.none": "You have no new messages",
  "msg.singular": "You have one new message",
  "msg.plural": "You have {{count}} new messages"
}
gjuchault commented 8 years ago

@cschroeter This might work for english, french etc. but russian will cause you some troubles

ocombe commented 8 years ago

This is something that might be worth adding if the implementation of angular 2 doesn't work for me, but it's not a priority. It would make a nice PR though.

Yimiprod commented 8 years ago

Not working anymore since RC4 (https://github.com/angular/angular/issues/9826)

import { Component } from '@angular/core';
import { NgLocalization } from '@angular/common';

class PluralLocalization extends NgLocalization {
    getPluralCategory(value: any) {
        if (value > 1) {
            return 'other';
        }
    }
}
@Component({
  templateUrl: 'shows.html',
  providers: [{ provide: NgLocalization, useClass: PluralLocalization }]
})
DethAriel commented 8 years ago

While there is no built-in mechanism to do that yet, I came up with my own. If someone finds time to polish the thing, add unit tests, lang-change/translation change events and finally create a pull-request, that would be just awesome :)

import { ChangeDetectorRef, Injectable, Pipe, PipeTransform } from '@angular/core';
import { I18nPluralPipe, NgLocalization } from '@angular/common';

import { TranslateService } from 'ng2-translate';

/**
 * Maps a numeric value to a pluralized translated string.
 *
 * ## Usage
 *
 * expression | pluralate:'PATH.TO.TRANSLATION.MAPPING'
 *
 * ## Example
 *
 * ```ts
 * @Component({
 *   selector: 'my-inbox',
 *   template: `
 *     <div>
 *       {{ messages.length | pluralate:'INBOX.MESSAGES_PLURAL_CATEGORIES' }}
 *     </div>
 *   `,
 * })
 * class MyInbox {
 *   messages: any[];
 *   // ...
 * }
 * ```
 *
 * While the translation file is:
 *
 * ```json
 * // en.json
 * {
 *  "INBOX": {
 *    "INBOX.MESSAGES_PLURAL_ZERO": "Your inbox is empty :(",
 *    "INBOX.MESSAGES_PLURAL_ONE": "You have 1 new message",
 *    "INBOX.MESSAGES_PLURAL_OTHER": "You have {{ count }} new messages",
 *
 *    "MESSAGES_PLURAL_CATEGORIES": {
 *      "=0": "INBOX.MESSAGES_PLURAL_ZERO",
 *      "=1": "INBOX.MESSAGES_PLURAL_ONE",
 *      "other": "INBOX.MESSAGES_PLURAL_OTHER"
 *    }
 *  }
 * }
 * ```
 *
 * @implements {PipeTransform}
 */
@Injectable()
@Pipe({
  name: 'pluralate',
  pure: false, // required to update the value when the promise is resolved
})
export class PluralatePipe implements PipeTransform {
  private value: string = '';

  constructor(
    private translate: TranslateService,
    private changeDetector: ChangeDetectorRef,
    private localization: NgLocalization
  ) {
  }

  public transform(query: string, pluralizationHolderKey: string): string {
    let i18npipe: I18nPluralPipe = new I18nPluralPipe(this.localization);

    if (!query || query.length === 0) {
      return query;
    }

    let queryNum = parseInt(query, 10);
    if (isNaN(queryNum)) {
      return query;
    }

    this.translate.get(pluralizationHolderKey).subscribe((res: { [key: string]: string }) => {
      let mapping = res;
      let pluralCat = i18npipe.transform(queryNum, mapping);

      this.value = this.translate.instant(pluralCat, { count: queryNum });
      this.changeDetector.markForCheck();
    });

    return this.value;
  }
}
bisubus commented 8 years ago

@ocombe Do we consider using A2 built-in i18n facilities for p11n?

I'm successfully using translate pipe with NgPlural directive and augmented NgLocaleLocalization (see angular/angular#11921 for details) like:

<div [ngPlural]="n">
  <template ngPluralCase="=0">{{ 'N_PLURAL.0' | translate:{ amount: n } }}</template>
  ...
  <template ngPluralCase="=other">{{ 'N_PLURAL.OTHER' | translate:{ amount: n } }}</template>
</div>

I presume that NgPlural and i18nPlural could be extended or forked to use NgLocalization with ng2-translate translation map and *_PLURAL.* convention seamlessly.

engular commented 8 years ago

@DethAriel 'pure = false' is not needed, when you are using Observable's as return value, so you get here a better performance. I'm also not sure, to define the pluralization mappings directly in translation file, because it duplicated when you use more then one language!

I think, that the solution of @cschroeter is really clean! Nevertheless, I've created an own pipe which returns an Observable and it is a little bit cleaner.

+++ UPDATE +++ @DethAriel
You are right, I forgot to consider the categories for each country! I've updated my solution.

Here is my solution:

import { Pipe, PipeTransform } from '@angular/core';
import { TranslateService } from 'ng2-translate';
import { Observable } from 'rxjs/Rx';
import { NgLocaleLocalization } from '@angular/common/src/localization';
import { I18nService } from './i18n.service'; // custom service

/**
 * Maps a numeric value to a pluralized translated object.
 *
 * ## Usage
 *
 * expression:number | pluralize:'PATH.TO.TRANSLATION.PLURAL.KEY' | async
 *
 * ## Example
 *
 * @NgModule({
 * imports: [
 *   TranslateModule.forRoot(I18nService.getTranslateLoaderConfig()),
 *   CommonModule,
 * ],
 * declarations: [
 *   I18nPluralizePipe,
 * ],
 * providers: [
 *   I18nService,
 *   TranslateService,
 * ],
 * exports: [
 *   I18nPluralizePipe,
 * ],
 * })
 *
 *
 * @implements {PipeTransform}
 */
@Pipe({name: 'pluralize'})
export class I18nPluralizePipe implements PipeTransform {
  // in my case: the locale will be set from outside
  private localization = new NgLocaleLocalization(I18nService.getLocale());

  constructor(private translate:TranslateService) {}

  public transform(expression:number, pluralKey:string):Observable<string> {
    const category = this.localization.getPluralCategory(expression);
    const key = pluralKey + '.' + category.toUpperCase();
    return this.translate.get(key, {count: expression});
  }
}
DethAriel commented 8 years ago

@engular it's not really duplicated - every language has its own set of pluralization rules. So English only has "=0", "one", and "other", while Russian has "=0", "one", "few", "many", "other", and Spanish could also have custom rules for "=2" for "ellos/ellas" forms (depending on the input data). It might be that I misunderstood your duplication concern, though

llwt commented 7 years ago

So is the current way to do this with ngx-translate to use the I18nPluralPipe?

gjuchault commented 7 years ago

There are already well-known libraries that uses ICU : https://github.com/messageformat/messageformat.js (used by angular-translate)

Edit: not documented but you can use many, few, etc

egel commented 7 years ago

@gjuchault I agree with you, messageformat.js might be a good starting point of implementation. Here is some reference with using messageformat library for pluralization from angular-translate plugin (for AngularJS v1).

lephyrus commented 7 years ago

So I need this for a project that is gradually being migrated from ng1 to ng2+. We used the messageformat syntax with ng-translate and I want us to be able to use the exact same strings with ngx-translate, otherwise the hybrid phase gets really complicated.

This is the TranslateParser implementation I came up with:

import { Injectable, Injector } from '@angular/core';
import { LangChangeEvent, TranslateParser, TranslateService } from '@ngx-translate/core';
import * as _ from 'lodash';
import * as MessageFormat from 'messageformat';

const getMessageFormat = _.memoize((lang: string) => {
  const mf = new MessageFormat(lang);
  mf.compile = _.memoize(mf.compile);

  return mf;
});

@Injectable()
export class TranslateMessageFormatParser extends TranslateParser {
  private currentLang: string;

  constructor(private injector: Injector) {
    super();

    // FIXME: Acrobatics to avoid a cyclic dependency follow.
    // A clean solution would require a change to TranslateService so
    // TranslateParser gets notified of language changes without having
    // to subscribe.
    setTimeout(() => {
      const translate: TranslateService = this.injector.get(TranslateService);
      translate.onLangChange.subscribe((event: LangChangeEvent) => {
        this.currentLang = event.lang;
      });
    });
  }

  public interpolate(expr: string, params?: any): string {
    if (typeof expr !== 'string' || !params) {
      return expr;
    }

    return getMessageFormat(this.currentLang).compile(expr)(params);
  }

  public getValue(target: any, key: string): string {
    return target[key];
  }
}

This can be used as a drop-in replacement for the TranslateDefaultParser:

TranslateModule.forRoot({
  parser: {
    provide: TranslateParser,
    useClass: TranslateMessageFormatParser
  }
})

The following caveats prevent me from making this a pull request:

chancezeus commented 7 years ago

For a project I need proper support for pluralization, genders and other related translations. I made quite a lot of projects in angular1 but for the new project I wanted to go with angular2 (especially since it depends heavily on forms) and was looking for a translate solution compatible with Ionic and/or at least easier to implement then the standard angular2 i18n (single build instead of a build per language).

Looking at the module it looks really nice (and familiar) but is (indeed) missing ICU support. I started browsing through the sources and the JSDocs for the MessageFormat js library and I think I have a good solution for everyone using/requiring messageFormat support for their projects.

The MessageFormat.prototype.compile function specifies 2 uses:

  1. Compile a single value to a js function
  2. Compile an object into an object of js functions and add a toString function to export the data

Example from the docs:

var fs = require('fs');
var mf = new MessageFormat('en').setIntlSupport();
var msgSet = {
  a: 'A {TYPE} example.',
  b: 'This has {COUNT, plural, one{one member} other{# members}}.',
  c: 'We have {P, number, percent} code coverage.'
};
var cfStr = mf.compile(msgSet).toString('module.exports');
fs.writeFileSync('messages.js', cfStr);
...
var messages = require('./messages');
messages.a({ TYPE: 'more complex' })
// 'A more complex example.'

messages.b({ COUNT: 3 })

If we take (a slightly modified version) of the first part of this code and convert that into a TranslateLoader which actually loads the data from json (and probably flatten the keys into . notation) and then feeds that to messageFormat.compile (which does accept a locale parameter so you can even use a global/class wide messageFormat instance instead of creating a new one).

The returned "translations" object will now have all the (flattened) keys as message format functions.

The third part of this code can then be put into a TranslateParser, getValue can return a /^key(?:\.|$)/ object/array of matched keys or a translations[key] single value and interpolate just calls each/the returned function with the interpolate params.

Extra option if somebody knows if this can be done: The second part writes the code to disk (using a string), maybe this can be used to generate precompiled language files during AOT building

lephyrus commented 7 years ago

@chancezeus Nice! I didn't have a close look at the messageformat.js API when I came up with the solution above. This would be much cleaner.

The translations object is not in fact flattened with dot-separated keys - it's actually supposed to be a nested object. Luckily, messageformat.js' compile() works with nested object the way we want it to: If messages is a hierarchical structure of such strings, the output of compile() will match that structure, with each string replaced by its corresponding JavaScript function.. It looks to be very straight-forward to transform a nested object of ICU-formatted strings into a nested object of messageformat.js functions while loading translations for ngx-translate.

At this point, instead of string values, our translations object has functions as its values - otherwise, everything should work transparently for every part of ngx-translate (except the parser). The parser's getValue() method could work exactly as it does currently, and interpolate() would be very simple - just invoke the passed method with the passed params and return the result.

There's one problem I can see: Unfortunately, getValue() is expected to return a string, and interpolate() expects a string as its first argument. These will be functions though. It shouldn't be a problem at runtime, but maybe the maintainers would be open to relax the string requirement here?

chancezeus commented 7 years ago

@lephyrus Nice for spotting/testing that the compile() will work with nested objects, that was unclear from the docs (to me) and hence I suggested flattening the object.

santialbo commented 7 years ago

Hi, this has been opened for almost a year. Is there a recommended approach worth mentioning on the README?

lephyrus commented 7 years ago

I've given this some more thought and I have an idea for what I think is a clean implementation. No promises, but I hope to have a PR up this week.

lephyrus commented 7 years ago

https://github.com/ngx-translate/core/pull/553

Thanks @chancezeus for setting me on the right path with this. Also, the idea to actually contribute was helped by a conversation with @PascalPrecht at Jazoon.

alexcouret commented 7 years ago

Hey @lephyrus, thanks for your PR, do you know when will it be merged?

lephyrus commented 7 years ago

@OzoTek No idea - it's just a PR of what I think is a decent solution. It may never get merged if the maintainers disagree. If there's still no reaction in a week, I might try to get some attention. For now we should be patient.

alexcouret commented 7 years ago

Alright thanks :)

DavidDomB commented 7 years ago

How do I use i18nPluralPipe with ngx-translate? I tried adding the code provided by @cschroeter but it shows the mapped key instead of the value in locale file. Do I need to add any other library or configuration?

I want to use pluralization but I don't want to use the angular native implementation because in my opinion .xlf files maintenance could require a lot of effort.

I'm new using Angular, any help will be apreciated.

atiris commented 7 years ago

My solution for plural pipe is, I think, more general (I apologize in advance, but it was not tested in detail). It is based on the format commonly used in angular. But no nested conditions can be applied. In translation file you can use variables as in ngx-translate core and then, with this pipe, select exact part of translated string according to variable. It supports variable comparison using '=' (for string or numbers), '<' and '>' (for numbers) and default value.

You can use it in html template after translate applied: {{ 'translate key' | translate:{variable:value} | translateSelector:variable }}

Format in json: "translate key": "=[number | string] {text value if equal} >[number] {text if greather than number} [other | else] {default text} Text from first condition match (from left) will be applied.

Example. With translation defined in json: "results": "=0 {No results} =1 {One result} >100 {Too many results} >1 {{{count}} results found}" and in component html: {{ 'results' | translate:{count:cnt} | translateSelector:cnt }}

It show:

Example with strings. Sample json: "mf": "=m {male} =f {female} other {unknown}" in component html (example with constant): {'mf' | translate | translateSelector:'m'}}

It show:

Pipe code

import { Pipe, PipeTransform } from '@angular/core';

/**
 * Usage in translation json files:
 * - en.json (key: value):
 * "found results": "=0 {No results} =1 {One result} >1 {{{count}} results found}",
 * - sk.json (key: value):
 * "found results": "=0 {Žiadne výsledky} =1 {Jeden výsledok} <5 {{{count}} výsledky} other {{{count}} nájdených záznamov}"
 *
 * Usage in component:
 * {{'found results' | translate:{count: cnt} | translateSelector:cnt}}
 *
 * Results:
 * If cnt = 0: No results (en) Žiadne výsledky (sk)
 * If cnt = 7: 7 results found (en) 7 nájdených záznamov (sk)
 */
@Pipe({
  name: 'translateSelector'
})
export class TranslateSelectorPipe implements PipeTransform {

  transform(text: string, value: string | number): string {
    const match = text.match(/(([=<>][^}]+|other|else) ?{([^}]+))}/g);
    if (match) {
      const ret = match.map(
        m => m.match(/([=<>oe]) ?([^{]+) ?{([^}]+)}/)
      ).find(
        f => this.evalCondition(value, f[1], f[2].trim())
        );
      if (ret) { return ret[3]; }
    }
    return text;
  }

  private evalCondition(
    left: number | string,
    operator: string,
    right: string): boolean {

    if (['o', 'e'].includes(operator)) { return true; }

    const strings = typeof left === 'string';
    left = left.toString();
    const leftNumber: number = Number.parseInt(left);
    const rightNumber = Number.parseInt(right);

    if (strings && ['<', '>'].includes(operator)) {
      return false;
    } else if (!strings && (Number.isNaN(leftNumber) || Number.isNaN(rightNumber))) {
      return false;
    }
    switch (operator) {
      case '=': return strings ? left === right : leftNumber === rightNumber;
      case '<': return leftNumber < rightNumber;
      case '>': return leftNumber > rightNumber;
    }
    return false;
  }
}

I suppose it is not written optimally, please improve it if you know how. Import it into your (shared) module and use in module component like in example on top.

import { TranslateSelectorPipe } from './your-pipes/this-pipe-name.pipe';
...
declarations: [TranslateSelectorPipe],
...
exports: [TranslateSelectorPipe]   // if in shared module
lephyrus commented 7 years ago

I'll leave this here for visibility: https://www.npmjs.com/package/ngx-translate-messageformat-compiler

I'll try and prepare a PR to get this mentioned in the README, hopefully soon.

michaelstokes93 commented 4 years ago

Thank you each and all and particularly @cschroeter

I found that both the following worked (The approach I opted for was using the directives (https://github.com/ngx-translate/core#5-use-the-service-the-pipe-or-the-directive)

                <span [translate]="messages.length| i18nPlural: messageMapping" [translateParams]="{count: messages.length}"></span>
  {{ messages.length | i18nPlural: messageMapping | translate :{count: messages.length} }}

Thanks very much