Locale

About

This singleton class, which was inspired by Vue I18nopen in new window, manages locales, which are JSON files that provide a set of localized strings for use with your application. If you need to display multiple languages or display DateTimes in formats other than US English, you will most likely want to use locales.

But even if you use a single language, you may want to use locales to provide a consistent way to display DateTimes, and to use it’s powerful templating language to format strings and choose plural forms.

NOTE

You should never instantiate the Locale class. Use the Locale() global method to retrieve the singleton instance of Locale.

Before a locale can be used, it must be loaded using .load(). To ensure there is at least some locale data available, the en locale is loaded when the Locale singleton is instantiated.

Locale names

Locale names are in standard IANA <language>-<region>-<dialect> language tag format. For simplicity the tag is lowercase and hyphens are used between parts. As with IANA language tags, the region and dialect are optional.

The Locale class maintains two locale names:

  • The current resolved locale name. The current locale provides values to users of the locale. For example, DateTime gets full weekday names from <locale>.datetime.weekdays.

  • The default locale name. This is used as a fallback when a locale name cannot be resolved. For example, if you request the fr locale but it has not been loaded, the default locale will be used instead.

Both the current and default locale names are set to en when the Locale singleton is first instantiated. You can get and set both names via Locale properties. See resolve for more information on how locale names are resolved.

Locale file structure

The basic structure of a locale file is as follows (without the comments!):

{
  // IANA language tag with optional region and dialect.
  // Should be all lowercase & must match the filename without extension.
  "name": "en",

  // OPTIONAL: for display only
  "description": "English (US)",

  // OPTIONAL: see Ordinals below for more info
  "ordinal": "",

  // Localization for DateTime.format(). In comma-delimited lists,
  // space around the comma is ignored.
  "datetime": {
    // A comma-delimited list of 7 full day names.
    "weekdays": "Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday",

    // OPTIONAL: A comma-delimited list of 7 short day names.
    // If omitted, the first 3 characters of the full weekday name is used.
    "weekdaysShort": "Sun,Mon,Tue,Wed,Thu,Fri,Sat",

    // OPTIONAL: A comma-delimited list of 7 minimum day names.
    // If omitted, the first 2 characters of the full weekday name is used.
    "weekdaysMin": "Su,Mo,Tu,We,Th,Fr,Sa",

    // A comma-delimited list of 12 full month names.
    "months": "January,February,March,April,May,June,July,August,September,October,November,December",

    // A comma-delimited list of 12 short month names.
    // If omitted, the first 3 characters of the full month name is used.
    "monthsShort": "Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec",

    // OPTIONAL: The text that replaces the `@` token, which
    // represents the word used before a time, e.g. in the sentence:
    // "The demo will begin on December 1st at 12:00 pm CST".
    // If omitted, defaults to "@".
    "at": "at",

    // Localizable formats for use with DateTime.format().
    // The lowercase versions of these formats, if present,
    // represent short versions of the uppercase versions.
    // If omitted, lowercase formats do the following token
    // replacements:
    //   MMMM -> MMM
    //   MM -> M
    //   DD -> D
    //   dddd -> ddd
    //
    // For example, if "LLLL" is "dddd, DD MMMM YYYY",
    // then "llll" will default to "ddd, D MMM YYYY".

    // The comments below describe what tokens each format should include.
    // Token order within each format is dependent on the locale.
    "formats": {
      // Time
      "LT": "h:mm A",

      // Time with seconds
      "LTS": "h:mm:ss A",

      // Short date with year (may be 2 digits depending on locale)
      "L": "MM/DD/YYYY",

      // Full month + day + full year
      "LL": "MMMM D, YYYY",

      // Full month + day + full year + time
      "LLL": "MMMM D, YYYY h:mm A",

      // Full weekday + full month + day + full year + time
      "LLLL": "dddd, MMMM D, YYYY h:mm A",

      // Full weekday + full month + ordinal day + full year + time
      "LLLLO": "dddd, MMMM Do, YYYY h:mm A",

      // Full month + day
      "LDM": "MMMM D",

      // Full weekday + full month + day
      "LWDM": "dddd, MMMM D",

      // Full weekday + full month + ordinal day
      "LWDOM": "dddd, MMMM Do"

      // OPTIONAL: By default, lowercase "short" versions of the date formats
      // above ("l", "ll", etc.) are automatically defined. The lowercase
      // versions do the following replacements of the uppercase version
      // tokens:
      //   MMMM -> MMM
      //   MM -> M
      //   DD -> D
      //   dddd -> ddd
      //   A -> a
      //
      // For example, if "LLLL" is "dddd, DD MMMM YYYY h:mm A",
      // then "llll" will default to "ddd, D MMM YYYY h:mm a".
      //
      // If the short versions of the formats are different from the
      // default, you should define them here.
    },

    // OPTIONAL: Localizable custom formats for use with cs.js.DateTime.format().
    // These formats can either be set in a locale file or set at runtime
    // via DateTime.setCustomFormat().
    "customFormats": {
      // The key is the name that will be passed to cs.js.DateTime.format().
      // The value is the format string.
      "date-at": "dddd, MMMM Do, @ h:mm A"
    }
  },

  // Localized strings for general use can also be defined here,
  // split up into a hierarchy of as many subobjects as you like.
  // The keys may use any Unicode alphanumeric characters (in any language,
  // including those using non-Latin characters!), underscores, and "$".

  // For example, if you wanted to put common global strings in a
  // "$" subobject:
  "$": {
    "yes": "yes",
    "no": "no",
    "ok": "OK",
    "cancel": "cancel"
    // ...
  },

  // In a French locale, it would be:
  "$": {
    "yes": "oui",
    "no": "non",
    "ok": "OK",
    "cancel": "annuler"
    // ...
  }

  // To use these strings, you would do:
  // t("$.yes")  // "yes" in English, "oui" in French, etc.

  // See the documention for the t() & tc() methods for more info.
}

Customizing locales

js.component comes with the following built-in locales:

LocaleDescription
deGerman
de-atGerman (Austria)
de-chGerman (Switzerland)
enEnglish (US)
en-auEnglish (Australia)
en-caEnglish (Canada)
en-gbEnglish (United Kingdom)
en-nzEnglish (New Zealand)
esSpanish
es-usSpanish (United States)
frFrench
fr-caFrench (Canada)
fr-chFrench (Switzerland)
itItalian
it-chItalian (Switzerland)
jaJapanese

These locales provide basic support for localizing DateTime. When Locale.load() is called, it will first look in the project’s Resources/locales directory for the locale file, allowing you to override the built-in locales or add new locales.

To modify a built-in locale, either to change the date formats or to add localized strings, copy the locale file from the js.component Resources/locales directory to your project’s Resources/locales directory and modify it as needed.

TIP

To add new locales, it is recommended that you use existing locale files as a starting point to ensure you include the necessary datetime configuration.

Ordinals

When a locale is loaded, the .ordinal property of a locale is converted to a formula that combines the number with the ordinal suffix. Set the .ordinal property to one of the following values in your locale file:

  • If there are no ordinal suffixes in the locale, you may omit the ordinal property or set it to an empty string.

  • If the ordinal suffix is the same for all numbers, set the ordinal property to a single string. For example, in es locales .ordinal is set to "º".

  • If the ordinal suffix depends on the number, set the .ordinal property to the text of a formula (without the enclosing Formula) which will combine the number with the correct ordinal suffix. Within the formula text, $1 represents the number and should be converted to a string. For example, in en locales .ordinal is set to:

{
  "ordinal": "String:C10($1)+((($1%10)>=4) ? \"th\" : Substring:C12(\"thstndrd\"; (($1%10)*2)+1; 2))"
}

TIP

If you expect an ordinal formula to be loaded on machines with different languages, you should add token numbers after 4D commands as in the example above. This will ensure that the commands are translated into the user’s language.

Meridiems

If a locale uses meridiems and they are something other than AM/PM, then you must define a .datetime.meridiems string property which is a comma-delimited list of two strings, the first representing the AM meridiem and the second representing the PM meridiem.

NOTE

You do not need to specify the .meridiems property for locales that use AM/PM meridiems.

API

.default

.default : Text
.default := locale : Text

As a getter, returns the default locale, which is used as a fallback when trying to get an non-existent locale.

As a setter, sets the default locale to locale. locale is resolved first, and if resolution fails, no change is made.

Examples

// There is a fr.json locale file
Locale.load("fr")
Locale.default:="fr"
$locale:=Locale.default  // "fr"

// fr-ch has not been loaded
Locale.default:="fr-ch"
$locale:=Locale.default  // "fr"

// Now attempt to get a non-loaded locale,
// which will fall back to the default.
$data:=Locale.getData("it")  // Data for the "fr" locale

.getData()

.getData(locale : Text) : Object

Returns the locale data for locale, falling back to the current locale if locale is empty or the default locale if locale cannot be resolved to a loaded locale.

The returned object is the cached locale data object, so it should not be modified.

Examples

$data:=Locale.getData()  // => Тhe current locale data

Locale.load("fr")
Locale.locale:="fr"

// Try to get data for a non-loaded locale
$data:=Locale.getData("fr-ch")

// "fr-ch" resolves to "fr"
$data.name="fr"  // true

.getLoadedLocales()

.getLoadedLocales() : Collection

Returns a sorted collection of the names of all loaded locales.


.isLoaded()

.isLoaded(locale : Text) : Boolean

Returns true if locale is loaded, false otherwise.


.load()

.load(locale : Text) : Boolean
.load(locale : Object) : Boolean

From disk

In the first form, this function attempts to load the named locale from disk. locale should be a plain, all lowercase IANA locale name, e.g. fr-ca. Loading proceeds as follows:

  • If the named locale has already been loaded, true is returned.

  • If /RESOURCES/locales/<locale>.json exists in the host db or in js.component, it is read and parsed.

  • If parsing fails, a SyntaxError is thrown and false is returned. The error’s .options.cause property will contain the original error, and its .path property will contain the native path to the locale file.

  • If the .name property of the parsed locale data does not match locale, a RangeError is thrown and false is returned.

  • Otherwise the parsed locale file is added to the locale cache.

  • If no locale file can be found, a SystemError is thrown and false is returned. The error’s .path property will contain the native path of the missing file.

From an object

In the second form, locale should be an object with the same structure as a locale file. This allows you to retrieve locales from an alternate location, for example in an arbitrary folder on disk or in the db.

The .name property of locale is used as the locale name. If a locale with that name has already been loaded, true is returned and no change is made. Otherwise, locale is added to the locale cache and true is returned.

TIP

To force a locale to be reloaded, use Locale.reload().


.locale

.locale : Text
.locale:= locale : Text

As a getter, returns the current locale name.

As a setter, resolves locale with fallback and sets the current locale name to the resolved locale.


.ordinal()

.ordinal(number : Real { ; locale : Text }) : Text

Returns the ordinal form of number. If locale is omitted or empty, the current locale is used. Otherwise locale is resolved with fallback and the ordinal form of number is returned in that locale.

Examples

// The current locale is "en"
Locale.ordinal(1)  // "1st"

Locale.load("fr")
Locale.locale:="fr"
Locale.ordinal(1)        // "1er"
Locale.ordinal(1; "en")  // "1st"

.reload()

.reload(locale : Text) : Boolean
.reload(locale : Object) : Boolean

This function is the same as .load(), except that it will update a locale if it has already been loaded.


.resolve()

.resolve(locale : Text { ; fallback : Boolean }) : Text

Resolves locale to a loaded locale name. If locale is empty or cannot be resolved, the default locale is returned if fallback is true (the default), otherwise an empty string is returned.

Examples

Locale.load("fr")
Locale.default:="fr"
Locale.load("fr-ch")
Locale.resolve("fr-ch")      // "fr-ch"
Locale.resolve("fr-ca")      // "fr"
Locale.resolve("it")         // "fr"
Locale.resolve("it", False)  // ""

.setCustomLinkModifiers()

.setCustomLinkModifiers(modifiers : Object)

Sets custom modifiers for all locales. modifiers should be an object whose keys are modifier names and whose values are Callables that take a string and return a string. modifiers is merged with the standard modifiers, so any existing standard modifiers with the same name will be overwritten.

For more information on modifiers, see Modifiers.


.setPluralRules()

.setPluralRules(rules : Object)

Sets custom plural rules for the given locales. rules should be an object whose keys are locale names and whose values are formulas. The formula receives three parameters:

  • $1 The count that was passed to .tc()
  • $2 The number of plural forms for the translation in the current locale
  • $3 As a fallback, the default plural rule formula, which chooses between 0, 1 or >1

The formula should return the index of the plural form to use.

Example

Let’s say we want to use plural rules for the Czech language, which has four plural forms: 0, 1, 2–4 and 5+. We can do this by creating a csPluralRule method:

#DECLARE($count : Integer) : Integer

Case of
  : ($count<2)
    return $count

  : ($count<5)
    return 2

  Else
    return 3
End case

Then we can set the plural rule for the cs locale:

Locale.setPluralRules(New object("cs"; Formula(csPluralRule($1))))

In our cs locale file, we define the four plural forms:

{
  "name": "cs",

  "$": {
    "appointments": "@#:$.count_appointments.",
    "count_appointments": "Nemáte žádné schůzky | Máte jednu schůzku | Máte ${count} schůzky | Máte ${count} schůzek"
  }
}

Now when we get the translation for $.appointments, we get the correct plural form:

Locale.t("$.appointments"; New object("locale"; "cs"; "count"; 0))
// "Nemáte žádné schůzky."

Locale.t("$.appointments"; New object("locale"; "cs"; "count"; 1))
// "Máte jednu schůzku."

Locale.t("$.appointments"; New object("locale"; "cs"; "count"; 2))
// "Máte 2 schůzky."

Locale.t("$.appointments"; New object("locale"; "cs"; "count"; 5))
// "Máte 5 schůzek."

TIP

Since the rule value is a formula, it can be inline code, a call to a method, or a call to a class function. For example, the fallback plural rule is defined as:

Formula(($1>1) ? 2 : $1)

.t()

.t(keyPath : Text { ; params : Object }) : Text

Returns the translation for keyPath in the current locale. If keyPath is empty or cannot be resolved, an empty string is returned.

TIP

A shortcut for this function is the global method t().

If params is provided, the translation is passed as the template string to _.format() along with params. This allows you to use placeholders in your translations with the full power of _.format().

If params contains a .locale property, it is used to resolve the locale with fallback. Otherwise the current locale is used.

TIP

If you need to set the locale and are worried about thread safety, you should set the .locale property.

Examples

We have locale files with the following data:

{
  "name": "en",
  "translations": {
    "appointment": "Your appointment is scheduled for ${when|LWDOM @ LT}."
  }
}
{
  "name": "fr",
  "translations": {
    "appointment": "Votre rendez-vous est fixé au ${when|LWDOM @ LT}."
  }
}

In our 4D code:

$dt:=cs.DateTime.new(!2022-12-01!; ?12:00:00?)

Locale.locale:="en"
Locale.load("fr")

// We will use the global method `t()` shortcut

$appointment:=t("translations.appointment"; New object("when"; $dt))
// "Your appointment is scheduled for December 1st at 12:00 PM."

Locale.locale:="fr"
$appointment:=t("translations.appointment"; New object("when"; $dt))
// "Votre rendez-vous est fixé au 1er décembre à 12:00."

// If you want thread safety, pass .locale in _params_:
$appointment:=t(\
  "translations.appointment"; \
  New object("when"; $dt; "locale"; "fr"))

Linked translations

To reduce duplication of translations, you can refer to a linked translation within any given translation. The format for linked translations is as follows:

<link><modifiers>:<keyPath>

<link> is one of:
  @  // Link to a simple translation
  @# // Link to a plural translation

<modifiers> is optionally one or more of:
  .<modifier>
  where <modifier> is one or more word characters (in any language) or "_"

<keyPath> is a dot-separated path to a translation,
optionally surrounded by "{" and "}". A key may consist of:
  - Word characters (in any language, including non-Latin characters)
  - "$"
  - "_"

For example, these are all valid linked translations:

@:$.foo.bar
@:$.foo.bar.  // The trailing . is not part of the keyPath
@:{foo.bar}s
@.capitalize:$.foo.bar  // Modifiers: capitalize
@.capitalize.quote:$.foo.bar  // Modifiers: capitalize, quote
@:$.фу.бар  // Ukrainian characters
// and all of the above with @# instead of @

Modifiers

If the link contains modifiers, they are applied to the linked translation in the order they appear. The following modifiers are built in:

  • .capitalize_.capitalize($translation)
  • .lowerLowercase($translation)
  • .upperUppercase($translation)

You can add your own modifiers to the built-in modifiers by calling Locale.setCustomLinkModifiers(). A modifier is a name associated with a formula which receives the linked translation as $1 and returns a string. Within the formula, This is the current locale data, which makes it easy to refer to other translations.

Examples

We have locale files with the following data:

{
  "name": "en",
  "$": {
    "choose": "Please enter 1 @.capitalize.quote.parens:$.yes or 2 @.capitalize.quote.parens:$.no.",
    "yes": "yes",
    "no": "no",
    "quotes": "“”",
    "appointments": "You have @#:$.count_appointments.",
    "appointment": "appointment",
    "count_appointments": "no @:{$.appointment}s | one @:$.appointment | ${count} @:{$.appointment}s"
  }
}
{
  "name": "fr",
  "$": {
    "choose": "Veuillez choisir 1 @.capitalize.quote.parens:$.yes ou 2 @.capitalize.quote.parens:$.no.",
    "yes": "oui",
    "no": "non",
    "quotes": "«»",
    "appointments": "Vous avez @#:$.count_appointments.",
    "appointment": "rendez-vous",
    "count_appointments": "aucun @:{$.appointment}s | un @:$.appointment | ${count} @:{$.appointment}"
  }
}

In our 4D code:

Locale.load("fr")

// NOTE: These are somewhat contrived examples, as you could just as easily
// use quotes and parens directly in the translation, but it shows how
// custom modifiers work.
Locale.setCustomLinkModifiers(New object(\
  "quote"; Formula(This.$.quotes[[1]]+$1+This.$.quotes[[2]]); \
  "parens"; Formula("("+$1+")")\
  ))

$message:=t("$.choose"; New object("locale"; $user.locale))
// en: "Please choose 1 (“Yes”) or 2 (“No”)."
// fr: "Veuillez choisir 1 («Oui») ou 2 («Non»)."

$message:=t("$.appointments"; New object("count"; $count; "locale"; $user.locale))
// count = 0, en: "You have no appointments."
// count = 0, fr: "Vous avez aucun rendez-vous."
// count = 1, en: "You have one appointment."
// count = 1, fr: "Vous avez un rendez-vous."
// count = 2, en: "You have 2 appointments."
// count = 2, fr: "Vous avez 2 rendez-vous."

TIP

Since modifiers are applied in order, the order of modifiers matters! For example, if the order of the modifiers were reversed in the above example, the result would be:

Please enter 1 “(yes)” or 2 “(no)”.

That’s because yes would undergo this transformation:

parens: yes → (yes)
quote: (yes) → “(yes)”
capitalize: “(yes)” → “(yes)”  // First letter is "“"!

.tc()

.tc(keyPath : Text; params : Object) : Text

Returns the translation for keyPath in the current locale, with a plural form determined by a count. If keyPath is empty or cannot be resolved, an empty string is returned.

TIP

A shortcut for this function is the global method tc().

The .count or .n property of params determines the plural form, which is passed to _.format() along with params. If neither property exists and is a number, a TypeError is thrown and an empty string is returned.

If params contains a .locale property, it is used to resolve the locale with fallback. Otherwise the current locale is used.

TIP

If you need to set the locale and are worried about thread safety, you should set the .locale property.

Translation format

The translation for keyPath should be a string with the following format:

"<zero form> | <singular form> | <plural form>"

Spaces around the | separators are ignored. If the translation does not contain a <plural form>, the <singular form> is used for all plural forms.

The count value is available in the translation as both ${count} and ${n}, and any given .<property> in params is available as ${<property>}.

Each of the forms may contain linked translations.

Examples

We have locale files with the following data:

{
  "name": "en",
  "translations": {
    "appointment_message": "You have ${appointments}.",
    "appointment_count": "no @:{translations.appointment}s | one @:translations.appointment | ${count} @:{translations.appointment}s",
    "appointment": "appointment"
  }
}
{
  "name": "fr",
  "translations": {
    "appointment_message": "Vous avez @#:translations.appointment_count.",
    "appointment_count": "aucun @:translations.appointment | un @:translations.appointment | ${count} @:translations.appointment",
    "appointment": "rendez-vous"
  }
}

In our 4D code:

// Class AppointmentManager

Function sendAppointmentMessage($count: Integer; $user: User)
  // We will use the global method `tc()` shortcut
  var $counted; $message : Text
  var $params : Object

  // Let's keep this thread-safe and pass the locale
  $params:=New object("locale"; $user.locale; "count"; $count)
  $message:=t("translations.appointment_message"; $params)

  $user.sendMessage($message)

Somewhere else in our code:

$manager:=AppointmentManager.new()
// query for the user's appointments
$manager.sendAppointmentMessage($selection.length; $user)

// If $user.locale = "en":

// $selection.length = 0
// "You have no appointments."

// $selection.length = 1
// "You have 1 appointment."

// $selection.length = 3
// "You have 3 appointments."

// If $user.locale = "fr":

// $selection.length = 0
// "Vous avez aucun rendez-vous."

// $selection.length = 1
// "Vous avez un rendez-vous."

// $selection.length = 3
// "Vous avez 3 rendez-vous."
Last Updated:
Contributors: Aparajita