Simple way to provide translated texts in scripts

As I’m (slowly) building small utilities in Scriptable I’d like to be able to easily share them.

As I really like using tools in my own language I guess this is the same for many of us, especially if you provide such tools to family and friends.

So here is a small function that can be used to implement localization:

/** Adding a custom behavior on object to be able to quickly localize resources.
 *
 * Create localization using a simple json format object like:
 *
 * let msg = {
 *   'hello': {
 *     'fr-FR': "Bonjour",
 *     'en-FR': "Hello"
 *   },
 *   'bye': {
 *     '*': "Bye!"
 *   }
 * };
 *
 * Use the '*' key to specify default message (typically english version).
 * 
 * Then to get a localized string just call i18n() function with the key as
 * in:
 * `msg.i18n('bye')`
 */
Object.prototype.i18n = function (key) {
  if ( 'undefined' === typeof this[key] ) {
    console.log(`Missing key ${key}`);
    Script.complete();
  }

  let langs = Device.preferredLanguages();
  langs.push('*');
 
  let msg;
  while ( 'undefined' === typeof msg && (langs.length > 0) ) {
    msg = this[key][langs.shift()];
  }
  
  return msg;
};

This method is added to standard Object class prototype so you can call it easily on any kind of object. But it expects a strict structure:
Top level key is text identifier, value is an object where keys are langage code and values the translated text. Note the use of ‘*’ as a fallback language code for a default value.

How to use this? Damn easy, just define your string in an object using standard JSON-like syntax:

let msg = {
  'hello': {
    'fr-FR': "Bonjour",
    'en-FR': "Hello",
    '*':     "--Hello--"
  },
  'bye': {
    '*': "Bye!"
  }
};

A special language key * is used a default text (English looks like safe default).

You can get a translated text from the key and the function will run through all prefered languages to provide proper translation.

// Show proper localization from msg
console.log(msg.i18n('hello'));

// Show fallback on default text
console.log(msg.i18n('bye'));

I hope you’ll find this small utility function useful.

1 Like

That is a totally cool idea, considering how JavaScript’s built-in localisation features are non-existant. However, and please forgive me for saying so, the concrete implementation has a number of weak points that I think need to be remedied for a localisation scheme to be more widely adopted.

  1. Extending prototypes of objects directly is widely considered A Bad Idea™ in JavaScript. In fact, many frown on this kind of extension in general, but if done, prototype extension should happen through Object.defineProperty() to avoid problems with enumeration (see this Stack Overflow discussion).
  2. Extending the Object prototype is a particularly bad idea because that pollutes the global object namespace. Object is not a special type (like Hashes or Dictionaries in other programming languages): almost every single JavaScript object (with a lower case “o”) inherits from Object. Adding to the Object prototype means you have also added that property to Numeric, String, Boolean, Array etc. Oh, and many JS engines have strange edge cases when you directly extend the root of the object hierarchy; JavaScript Core contexts, which is what Scriptable runs your scripts in, are no exception in my experience.
  3. The implementation fails to provide a default when no translation is available at all; instead, it hard stops the whole script (with a Script.complete() call). It is far more common to simply return the key in such situations, which would allow at least a minimum display variant if the calling app / script so chooses (ever seen an app telling you to tap btnLabelDismiss for textLabelDismissReasons? That).
  4. Good localisation solutions also allow insertion of variable values into localised strings at runtime. Natural language grammar varies widely, you cannot ever assume that stringing bits of localised text together with a variable in a fixed position will result in something idiomatic (I’m a German speaker using a lot of localised US software. Trust me, I know this all too well).
  5. The language lookup uses the wrong value: Device.preferredLanguages(), despite the misleading name it inherited from macOS back when it still called itself “OS X”, is not a user settable list of preferred languages; what it is is the list of system language keyboards installed. Their codes do not necessarily match actual language codes, and their being installed does not indicate the user wants to see a localisation in that language. For instance, on my devices, the call returns [de-DE, en-DE, fr-DE, ja-JP]. As you can see, the second and third are not actually valid language codes, and the fourth one is installed because I am trying to learn the language and cannot input its characters without it; I would be quite flummoxed by a Japanese localisation, considering my current grasp of the language. What you want is Device.language(), which returns the language the device is actually set to, in a format Apple calls a Language ID, a slightly odd mix of ISO 639 language codes and IETF composite language codes. That, BTW, is a single value which has the benefit of allowing you to forego the whole looping and to use far more efficient simple key lookup for message translations.
  6. Finally, fallback needs to be more fine grained: if a user has fr-CA as their Language ID (Canadian French aka Québecois), the fr localisation should be used when the more specific one is not available; falling back to English when a French localisation is available is not the correct thing to do in that case – and I am not even talking politics :innocent: –, nor is forcing translators to repeat everything from the fr localisation.

That’s a lot of stuff to keep in mind, so I had a stab at it for my own usage while ago. The core of it is a Localization class:

// Localize messages and UI text into device language using {strings}.
class Localization {
  constructor (strings) {
    this.language = Device.language()
    let group = this.language.split(/[-_]/)[0]
  
    // baseline and fallback is (US) English
    let langDefault = 'en-US'
    let groupDefault = 'en'

    this.strings = strings[groupDefault] || {}
    if (strings[langDefault]) {
      this.strings = Object.assign(this.strings, strings[langDefault])
    }
    // but prefer strings from the language group (e.g 'fr')
    if (group !== groupDefault && strings[group]) {
      this.strings = Object.assign(this.strings, strings[group])
    }
    // or, even better, from the precise language (e.g. 'fr-CA')
    if (this.language !== langDefault && this.language !== group && strings[this.language]) {
      this.strings = Object.assign(this.strings, strings[this.language])
    }
  }
    
  // get the localized string for {key}, falling back to {key} itself if no
  // match is found in the current localization. Named placeholders with the
  // syntax %{name[:default text]} can be specified and will be replaced by the
  // values for key {name} in {replacements} (if provided); umatched placeholders
  // will be replaced by {default text} (if specified).
  string (key, replacements) {
    let str = this.strings[key] || key
    if (str.includes('%{')) {
      if (replacements != null) {
        // replace matching placeholders
        str = Object.keys(replacements).reduce((acc, cur) => {
          if (replacements[cur] != null) {
            let regex = new RegExp(`%\\{${cur}(:[^}]+)?\\}`, 'g')
            acc = acc.replace(regex, replacements[cur])
          }
          return acc
        }, str)
      }
      // replace unmatched placeholders with provided defaults
      str = str.replace(/%\{[^:}]+:([^}]+)\}/g, '$1')
    }
    return str
  }
}

which allows you to do something like this:

const strings = {
  en: {
    promptTitle:'Password',
    promptMessage: 'Please enter your password for use by %{source:the source app}.'
  },

  de: {
    promptTitle:'Passwort',
    promptMessage: 'Bitte Passwort für die Verwendung durch %{source:die aufrufende App} eingeben.'
  }
}

const l8n = new Localization(strings)
let msg = l8n.string('promptMessage', {source: 'Scriptable'})

and get a message localised in the next best match for your device language (or English if nothing else matches) with “Scriptable” inserted at the grammatically correct spot.

As this class is a bit unwieldy to insert verbatim everywhere, I suggest you use my module importing hack and wrap the class code into a module, i.e. its own file with the following content:

const exports = {
  Localization: // replace comment by the full class code shown above
}

then require() it as per the linked post.

2 Likes

100% agree. My solution was not intended to provide a full-fledge feature but rather a quick and easy way to copy-paste across scripts.

When we’ll be able to import a script in a integrated manner it will be worth the effort (for me) to build a reusable component.

I take JS for what it is: a scripting language, not something to drive a giant app. Especially in the Scriptable context :slight_smile:

1 Like

Yep this is probably not the good API. But the list matches the configured keyboard languages on iOS (it seems so at least) and is fully configurable on macOS. That appeared as a good options to have a fallback potentially known by user. But this is clearly a choice I made, not the way to got for everyone.

I agree on the sentiment, if not on the solution. You might want to take a look at the linked require() system, which gives you most of the CommonJS module system in six lines of code (and that is counting the closing brace). I find it easier to insert only that snippet everywhere and use modules than to inline necessarily restricted solutions everywhere.