NAME

App::Access2CSV::I18N - Message catalog and translated error messages for App::Access2CSV

VERSION

Version 0.001.0

SYNOPSIS

use App::Access2CSV::I18N;

# 1. Get a message as text
my $text = App::Access2CSV::I18N->i18n('missing_database');
# "Missing database filename"

# 2. Fill in values (the message has %s and %d placeholders)
print App::Access2CSV::I18N->i18n('progress', { params => [1, 3, 'Customers'] }), "\n";
# "[1/3] Customers"

# 3. Let the count choose between singular and plural
print App::Access2CSV::I18N->i18n('summary', { params => [$n, 0], count => $n }), "\n";
# $n == 1: "Processed 1 table, 0 failed"
# $n == 4: "Processed 4 tables, 0 failed"

# 4. Add a translation.  Keys you do not translate stay in English.
$App::Access2CSV::I18N::MESSAGES{de}{missing_database} =
	'Name der Datenbankdatei fehlt';
$ENV{LANG} = 'de_DE.UTF-8';

# 5. Use it as a base class, to get i18n() and the error helpers
package My::Tool;
use parent 'App::Access2CSV::I18N';

sub check {
	my ($self, $file) = @_;
	$self->_croak_i18n('database_not_file', { params => [$file] }) unless -f $file;
	return $self;
}

DESCRIPTION

Every message that App::Access2CSV prints, logs or throws is looked up here, by a short name called a key (for example output_exists). This keeps all the text in one place, so the program can be translated.

The text for each key is a template. A template is usually a sprintf format: %s is replaced by a text value and %d by a whole number, in order. A template can also have different forms:

  • Plural forms, chosen by a count: one for one item, other for any other number. Some languages use more forms (zero, two, few, many).

  • Context forms, chosen by a word you give, such as male or female. A context form can itself contain plural forms.

The templates live in the hash %App::Access2CSV::I18N::MESSAGES:

%MESSAGES = (
	en => {
		missing_database => 'Missing database filename',
		summary => {
			one   => 'Processed %d table, %d failed',
			other => 'Processed %d tables, %d failed',
		},
		...
	},
);

Only English (en) is included.

Which language is used

1. The language field of the object, if you call i18n on an object that has one (for example App::Access2CSV::Exporter->new(language => 'en')).
2. Otherwise the first of these environment variables that is set and is not C or POSIX: LANGUAGE, LC_ALL, LC_MESSAGES, LANG. Only the language part is used: de_DE.UTF-8 means de. Upper or lower case does not matter. LANGUAGE can hold a list such as fr:de; only the first entry is used. An empty value, or an empty first entry (:de), counts as "not set", so the next variable is tried.
3. If there is no catalog for that language, English is used.

Inside a language, a key that has no translation falls back to the English text, one key at a time.

Helpers for subclasses

Subclasses get two protected methods. They can be called only from this class and its subclasses:

  • $self->_croak_i18n($key, \%args) - throws an exception (with "croak" in Carp) with the translated message. It never returns.

  • $self->_carp_i18n($key, \%args) - warns (with "carp" in Carp) with the translated message, with control characters escaped. It returns $self.

  • $self->_printable($text) - returns $text with control characters (C0 except tab, DEL, C1, and text-direction controls) shown as escapes such as \x1B, so that text from an untrusted database is safe to print or log.

ENCODING

  • The English catalog is plain ASCII.

  • Values in params are copied into the message as they are. Byte strings (such as UTF-8 file names from the command line) stay byte strings, so non-ASCII text and emoji come out unchanged when printed.

  • Translations with non-ASCII text (for example German ue written as one letter, or Japanese) must be Perl character strings: write them in a source file with use utf8;. Do not mix them with UTF-8 byte strings in params, or the bytes will be encoded a second time. When you print such messages, give the output handle an encoding, for example binmode(STDERR, ':encoding(UTF-8)'), or Perl warns "Wide character in print".

COMMON PITFALLS

  • Percent signs. A template with no params is returned exactly as written, so 100% is safe. But when you give params, the template goes through sprintf, so a literal percent sign must be written %%.

  • Number of values. Give exactly as many params as the template has placeholders. Too few gives a Perl "Missing argument" warning; undef in params gives a "Use of uninitialized value" warning.

  • No count means plural. If you do not give count, the other form is used, even if the number in params is 1.

  • Replacing a key replaces all of its forms. %MESSAGES is not merged in depth. If you set $MESSAGES{en}{summary} = 'Done', the one and other forms of summary are gone. If a translation gives a plural hash, it should have an other form: when no form fits, the English text for that key is used instead.

  • Unknown context. A context that the template does not have is ignored; the plural forms (or the plain text) are used instead.

  • Unknown keys are fatal. A key that is not in the English catalog is a programming error: i18n calls confess, which stops the program and prints a stack trace.

  • undef arguments. undef for args, or for a field inside the hashref form, is treated as "not given". undef as the key is reported as a missing key.

  • Load with use, not require. The protection of _croak_i18n and _carp_i18n is set up at compile time. After a run-time require it is missing, and Perl prints "Too late to run CHECK block".

METHODS

i18n

Purpose

Turn a message key into text in the user's language. Choose the right context and plural form, then fill in the values.

Arguments

You can call i18n on the class or on an object. There are two ways to give the arguments:

$obj->i18n($key, \%args);
$obj->i18n({ key => $key, args => \%args });
key (string, required)

The message key, for example 'output_exists'.

args (hash reference, optional)
params - an array reference of the values for the placeholders, in order.
count - a whole number, 0 or more, that chooses the plural form.
context - a word that chooses a context form, for example 'female'.

Returns

The message as a string, without a newline at the end.

Side Effects

None. It only reads %MESSAGES and %ENV. Your $@, $! and $_ are left as they were.

Usage

my $text = $self->i18n('summary', { params => [3, 0], count => 3 });

EXAMPLE

# "Output file already exists: out/Orders.csv (use --overwrite to replace it)"
my $msg = App::Access2CSV::I18N->i18n('output_exists', { params => ['out/Orders.csv'] });

# A message with context and plural forms
$App::Access2CSV::I18N::MESSAGES{en}{greeting} = {
	female => { one => 'She sent %d letter', other => 'She sent %d letters' },
	other  => 'They sent %d letters',
};
print App::Access2CSV::I18N->i18n('greeting',
	{ params => [2], count => 2, context => 'female' }), "\n";
# "She sent 2 letters"

API SPECIFICATION

Input

{
	key => {
		type     => 'string',
		min      => 1,
		optional => 0,
	},
	args => {
		type     => 'hashref',
		optional => 1,
		schema   => {
			params  => { type => 'arrayref', optional => 1 },
			count   => { type => 'integer', optional => 1, min => 0 },
			context => { type => 'string', optional => 1 },
		},
	},
}

Valid and invalid values (tested in t/domain.t):

key      valid:   a key in the English catalog
         invalid: "" (1 character is the minimum), undef, a
                  reference, an unknown key (fatal: "Unknown message
                  key"), a known key with extra characters
count    valid:   whole numbers from 0 up (0 is the minimum; 2**53
                  works); undef means "no count"
         invalid: -1 and below, fractions (1.5), words
         edges:   English and German: 1 is singular, 0 and 2 plural.
                  French: 0 and 1 singular.  Japanese, Korean,
                  Chinese: always the "other" form
params   any number of values, including none; each value is
         copied into the text exactly, whether it is a Perl
         character string or UTF-8 bytes (non-ASCII letters,
         emoji, joined emoji, combining marks, right-to-left text)
context  any string; one the template does not have (including "")
         is ignored; a reference is invalid
language (from the environment) the first 2 or 3 letters, in any
         case; 1 letter, non-ASCII letters, C and POSIX all mean
         "no language"

Output

{
	type => 'string',
}

MESSAGES

+-----------------------------+-------------------------------+---------------------------------+
| Message                     | Meaning                       | What to do                      |
+-----------------------------+-------------------------------+---------------------------------+
| Unknown message key: KEY    | KEY is not in the English     | Programming error: add KEY to   |
|  (fatal, with stack trace)  | catalog                       | $MESSAGES{en}                   |
| Required parameter 'key' is | No key was given              | Give a key                      |
|  missing (fatal)            |                               |                                 |
| Unknown parameter 'X'       | args has a field that is not  | Use only params, count and      |
|  (fatal)                    | params, count or context      | context                         |
| Parameter 'count' (X) must  | count is negative or not a    | Give a whole number, 0 or more  |
|  be ... (fatal)             | whole number                  |                                 |
| Parameter 'params' must be  | params is not an array        | Give an array reference         |
|  an arrayref (fatal)        | reference                     |                                 |
+-----------------------------+-------------------------------+---------------------------------+

PSEUDOCODE

check key and args
lang := the object's language, or the language from the environment,
        or English if there is no catalog for it
for try in (lang, then en if lang is not en):   # at most 2 tries, no recursion
	entry := catalog[try][key], or else catalog[en][key]
	if entry has forms and one matches args.context:
		entry := that form
	if entry still has forms:
		entry := the form for plural_category(try, count),
		         or else the "other" form
	stop if entry is now a plain string
if no try gave a plain string: confess (the catalog is broken)
if there are params: return sprintf(entry, params)
else: return entry unchanged

LIMITATIONS

  • Only an English catalog is included. Other languages fall back to English, one key at a time.

  • Plural rules exist only for a few languages (en, de, fr, ja, ko, zh). Other languages use the English rule.

  • Messages from other modules (for example Params::Validate::Strict and autodie) are not translated.

  • Sub::Private and Sub::Protected set up their protection at CHECK time. If this module is first loaded at run time (with require after the program has been compiled), Perl warns "Too late to run CHECK block" and the private and protected helpers are not protected.

SEE ALSO

App::Access2CSV, App::Access2CSV::Exporter

AUTHOR

Nigel Horne, <njh at nigelhorne.com>

LICENSE AND COPYRIGHT

Copyright 2026 Nigel Horne.

Usage is subject to the GPL2 licence terms. If you use it, please let me know.

FORMAL SPECIFICATION

These schemas use the Z notation. ? marks an input and ! an output. You do not need to read this section to use the module.

[KEY, LANG, CTX, VALUE]
CATEGORY ::= zero | one | two | few | many | other
TEMPLATE ::= text⟨⟨seq CHAR⟩⟩
           | forms⟨⟨(CTX ∪ CATEGORY) ⇸ TEMPLATE⟩⟩

┌─ Catalog ──────────────────────────────────────────────────
│ MESSAGES : LANG ⇸ (KEY ⇸ TEMPLATE)
│ plural : LANG × ℕ → CATEGORY
├────────────────────────────────────────────────────────────
│ en ∈ dom MESSAGES
└────────────────────────────────────────────────────────────

i18n

┌─ I18n ─────────────────────────────────────────────────────
│ ΞCatalog
│ key? : KEY ; params? : seq VALUE ; count? : ℕ ; context? : CTX
│ userLang : LANG ; lang : LANG ; msg! : seq CHAR
├────────────────────────────────────────────────────────────
│ key? ∈ dom MESSAGES(en)
│ lang = (if userLang ∈ dom MESSAGES then userLang else en)
│ t₀ = (if key? ∈ dom MESSAGES(lang)
│        then MESSAGES(lang)(key?) else MESSAGES(en)(key?))
│ t₁ = (if t₀ = forms(f) ∧ context? ∈ dom f then f(context?) else t₀)
│ t₂ = (if t₁ = forms(g)
│        then (if plural(lang, count?) ∈ dom g
│              then g(plural(lang, count?)) else g(other))
│        else t₁)
│ msg! = (if params? = ⟨⟩ then t₂ else sprintf(t₂, params?))
└────────────────────────────────────────────────────────────

┌─ I18nUnknownKey ───────────────────────────────────────────
│ ΞCatalog
│ key? : KEY ; error! : seq CHAR
├────────────────────────────────────────────────────────────
│ key? ¬in; dom MESSAGES(en)
│ error! = "Unknown message key: " ⁀ key?
└────────────────────────────────────────────────────────────

STATE DIAGRAM

This module keeps no state between calls: i18n only reads %MESSAGES and %ENV. The diagram shows the steps of one call. Each box is a step. Each arrow shows what decides the next step.

 i18n($key, \%args)
         |
         v
+------------------+  key missing, or args
|    VALIDATING    |  has a bad field
+------------------+-----------------------------+
         | OK                                    |
         v                                       |
+------------------+                             |
| CHOOSING LANGUAGE|  object language, else      |
|                  |  LANGUAGE/LC_ALL/           |
|                  |  LC_MESSAGES/LANG, else en  |
+------------------+                             |
         |                                       |
         v                                       v
+------------------+  key not in       +------------------+
|  LOOKING UP KEY  |  English either   |      FATAL       |
| (language, then  |------------------>| croak / confess  |
|  English)        |                   +------------------+
+------------------+
         | template found
         v
+------------------+  plain text
| NARROWING FORMS  |-----------------------+
| 1. context form  |                       |
| 2. plural form   |                       |
|    (else other)  |                       |
+------------------+                       |
         | one plain template left         |
         v                                 v
+------------------+  no params    +------------------+
|   FORMATTING     |-------------->|   return text    |
| sprintf(params)  |-------------->|   unchanged /    |
+------------------+   params      |   formatted      |
                                   +------------------+