| File: | blib/lib/App/Access2CSV/I18N.pm |
| Coverage: | 96.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Access2CSV::I18N; | |||||
| 2 | ||||||
| 3 | 43 43 43 | 143175 29 575 | use strict; | |||
| 4 | 43 43 43 | 71 26 757 | use warnings; | |||
| 5 | 43 43 43 | 524 12719 87 | use autodie qw(:all); | |||
| 6 | ||||||
| 7 | # Sub::Private must be switched to enforce mode before it is loaded, | |||||
| 8 | # otherwise it falls back to namespace mode, which breaks OO dispatch | |||||
| 9 | 43 | 106956 | BEGIN { $Sub::Private::config{mode} = 'enforce' } | |||
| 10 | ||||||
| 11 | 43 43 43 | 101 34 1049 | use Carp qw(carp confess croak); | |||
| 12 | 43 43 43 | 93 29 571 | use Config; | |||
| 13 | 43 41 41 | 6975 150209 1103 | use Params::Get qw(get_params); | |||
| 14 | 41 40 40 | 10132 340375 1049 | use Params::Validate::Strict qw(validate_strict); | |||
| 15 | 40 40 40 | 96 30 671 | use Readonly; | |||
| 16 | 40 39 39 | 5524 9896 963 | use Return::Set qw(set_return); | |||
| 17 | 39 38 38 | 7886 499521 102 | use Sub::Private; | |||
| 18 | 38 37 37 | 10768 131450 87 | use Sub::Protected; | |||
| 19 | ||||||
| 20 | our $VERSION = '0.001.0'; | |||||
| 21 | ||||||
| 22 | # The wrappers installed by Sub::Private/Sub::Protected add stack frames; | |||||
| 23 | # listing them here stops Carp from blaming the wrapper for our errors | |||||
| 24 | our @CARP_NOT = qw(Sub::Private Sub::Protected); | |||||
| 25 | ||||||
| 26 | # Catalog used when no better match for the user's locale exists | |||||
| 27 | Readonly::Scalar my $DEFAULT_LANGUAGE => 'en'; | |||||
| 28 | ||||||
| 29 | # Locale values that mean "no preference" rather than a real language | |||||
| 30 | Readonly::Hash my %NEUTRAL_LOCALES => (C => 1, POSIX => 1); | |||||
| 31 | ||||||
| 32 | # Environment variables consulted, most specific first (GNU gettext order) | |||||
| 33 | Readonly::Array my @LOCALE_VARIABLES => qw(LANGUAGE LC_ALL LC_MESSAGES LANG); | |||||
| 34 | ||||||
| 35 | # Characters that a terminal or log viewer would act on rather than show: | |||||
| 36 | # C0 controls except tab (ESC starts escape sequences, CR overwrites the | |||||
| 37 | # line, LF forges new log lines, BEL rings), DEL, and the invisible | |||||
| 38 | # text-direction controls. C1 controls are matched as characters in | |||||
| 39 | # Perl character strings and as their UTF-8 bytes in byte strings. | |||||
| 40 | # | |||||
| 41 | # One character class (faster than alternatives), made of these ranges: | |||||
| 42 | # \x00-\x08 \x0A-\x1F C0 controls, except tab (\x09) | |||||
| 43 | # \x7F DEL | |||||
| 44 | # \x{80}-\x{9F} C1 controls (U+009B works like ESC [) | |||||
| 45 | # \x{200E}\x{200F} left-to-right and right-to-left marks | |||||
| 46 | # \x{202A}-\x{202E} embeddings and overrides (U+202E is RLO) | |||||
| 47 | # \x{2066}-\x{2069} isolates | |||||
| 48 | # (No spaces or comments inside the brackets: /x does not apply there.) | |||||
| 49 | Readonly::Scalar my $UNPRINTABLE_RE => | |||||
| 50 | qr/[\x00-\x08\x0A-\x1F\x7F\x{80}-\x{9F}\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}]/; | |||||
| 51 | Readonly::Scalar my $UNPRINTABLE_BYTES_RE => qr/ | |||||
| 52 | [\x00-\x08\x0A-\x1F\x7F] # C0 controls except tab, and DEL | |||||
| 53 | | \xC2 [\x80-\x9F] # C1 controls, UTF-8 encoded | |||||
| 54 | | \xE2 \x80 [\x8E\x8F\xAA-\xAE] # LRM, RLM, embeddings, overrides | |||||
| 55 | | \xE2 \x81 [\xA6-\xA9] # isolates | |||||
| 56 | /x; | |||||
| 57 | ||||||
| 58 | # Coverage: Devel::Cover finds code through the symbol table, but in | |||||
| 59 | # enforce mode Sub::Private and Sub::Protected replace every private and | |||||
| 60 | # protected sub there with a wrapper (at CHECK time), so the real subs | |||||
| 61 | # would never appear in coverage reports. Only when Devel::Cover is | |||||
| 62 | # loaded, give each sub of this distribution a second name, in a package | |||||
| 63 | # nothing calls, before the wrapping happens. CHECK blocks run | |||||
| 64 | # last-defined first, so this one runs before the attribute handlers'. | |||||
| 65 | Readonly::Array my @OWN_PACKAGES => qw(App::Access2CSV App::Access2CSV::Exporter App::Access2CSV::I18N); | |||||
| 66 | Readonly::Scalar my $UNWRAPPED => 'App::Access2CSV::_Unwrapped'; | |||||
| 67 | ||||||
| 68 | CHECK { | |||||
| 69 | 33 | 72960 | if($INC{'Devel/Cover.pm'}) { | |||
| 70 | 33 | 94 | require B; | |||
| 71 | 37 37 37 | 6672 60 14727 | no strict 'refs'; | |||
| 72 | 33 | 126 | foreach my $package (@OWN_PACKAGES) { | |||
| 73 | 99 99 | 299 146 | foreach my $name (keys %{"${package}::"}) { | |||
| 74 | 9025 9025 | 4985 9499 | my $code = *{"${package}::$name"}{CODE} or next; | |||
| 75 | # Only subs written in this package, not imported ones | |||||
| 76 | 2747 | 4640 | next unless B::svref_2object($code)->GV->STASH->NAME eq $package; | |||
| 77 | 1644 1644 | 1362 2683 | *{"${UNWRAPPED}::${package}::$name"} = $code; | |||
| 78 | } | |||||
| 79 | } | |||||
| 80 | } | |||||
| 81 | } | |||||
| 82 | ||||||
| 83 | # Signals that mean "stop now": Ctrl-C is INT, Ctrl-\ is QUIT; TERM and | |||||
| 84 | # HUP come from kill, service managers and closed terminals | |||||
| 85 | Readonly::Array my @INTERRUPT_SIGNALS => qw(INT QUIT TERM HUP); | |||||
| 86 | ||||||
| 87 | # Plural category used when a language has no rule of its own | |||||
| 88 | Readonly::Scalar my $PLURAL_OTHER => 'other'; | |||||
| 89 | ||||||
| 90 | # CLDR-style plural rules. Each returns the category name for a count. | |||||
| 91 | # Only languages whose rule differs from English need an entry here. | |||||
| 92 | Readonly::Hash my %PLURAL_RULES => ( | |||||
| 93 | en => sub { $_[0] == 1 ? 'one' : 'other' }, | |||||
| 94 | de => sub { $_[0] == 1 ? 'one' : 'other' }, | |||||
| 95 | fr => sub { ($_[0] == 0 || $_[0] == 1) ? 'one' : 'other' }, | |||||
| 96 | ja => sub { 'other' }, | |||||
| 97 | ko => sub { 'other' }, | |||||
| 98 | zh => sub { 'other' }, | |||||
| 99 | ); | |||||
| 100 | ||||||
| 101 | # Message catalog: language => key => template. | |||||
| 102 | # A template is either a sprintf() format, or a hashref whose keys are | |||||
| 103 | # contexts (for example 'male'/'female') and/or plural categories | |||||
| 104 | # ('zero', 'one', 'two', 'few', 'many', 'other'). | |||||
| 105 | # It is a package variable, not Readonly, so that applications and | |||||
| 106 | # Object::Configure style configuration can add languages or override text. | |||||
| 107 | our %MESSAGES = ( | |||||
| 108 | en => { | |||||
| 109 | column_output => 'OUTPUT FILE', | |||||
| 110 | column_rows => 'ROWS', | |||||
| 111 | column_table => 'TABLE', | |||||
| 112 | count_unreadable => 'mdb-count printed no number: "%s"', | |||||
| 113 | count_failed => 'Cannot count the rows of %s: %s', | |||||
| 114 | database_not_file => 'Database %s is not a regular file', | |||||
| 115 | database_not_found => 'Cannot read database %s: %s', | |||||
| 116 | database_unreadable => 'Database %s is not readable', | |||||
| 117 | dry_run_title => 'DRY RUN', | |||||
| 118 | export_failed => 'FAILED: %s: %s', | |||||
| 119 | exported => 'Exported %s => %s', | |||||
| 120 | exported_rows => { | |||||
| 121 | one => 'Exported %s => %s (%d row)', | |||||
| 122 | other => 'Exported %s => %s (%d rows)', | |||||
| 123 | }, | |||||
| 124 | fatal => 'access2csv: %s', | |||||
| 125 | invalid_name => 'the name contains a NUL byte', | |||||
| 126 | interrupted_reading => 'Interrupted by SIG%s while reading the database from standard input', | |||||
| 127 | interrupted => 'Interrupted by SIG%s: stopped, and the table being exported was discarded', | |||||
| 128 | invalid_setting => 'Invalid setting: %s', | |||||
| 129 | invalid_utf8 => 'Table %s, line %d: output of mdb-export is not valid UTF-8', | |||||
| 130 | log_failed => 'Cannot write to the log: %s', | |||||
| 131 | log_is_symlink => 'it is a symbolic link', | |||||
| 132 | log_open_failed => 'Cannot open log file %s: %s', | |||||
| 133 | logger_unavailable => 'no logger was created', | |||||
| 134 | missing_database => 'Missing database filename', | |||||
| 135 | needs_object => 'run() must be called on an object created by new()', | |||||
| 136 | mkdir_failed => 'Cannot create output directory %s: %s', | |||||
| 137 | no_row_counter => 'mdb-count not found in PATH; row counts are unavailable', | |||||
| 138 | output_exists => 'Output file already exists: %s (use --overwrite to replace it)', | |||||
| 139 | program_failed => '%s failed with exit status %d: %s', | |||||
| 140 | program_found => 'Found %s at %s', | |||||
| 141 | program_missing => 'Required program not found in PATH: %s', | |||||
| 142 | program_not_run => '%s could not be run: %s', | |||||
| 143 | program_signalled => '%s was killed by signal %d', | |||||
| 144 | progress => '[%d/%d] %s', | |||||
| 145 | summary => { | |||||
| 146 | one => 'Processed %d table, %d failed', | |||||
| 147 | other => 'Processed %d tables, %d failed', | |||||
| 148 | }, | |||||
| 149 | stdin_empty => 'Standard input is empty: no database was piped in', | |||||
| 150 | stdin_is_terminal => 'Standard input is a terminal: pipe the database in, or give its file name', | |||||
| 151 | stdin_read_failed => 'Cannot read standard input: %s', | |||||
| 152 | version => 'access2csv version %s', | |||||
| 153 | unknown_message => 'Unknown message key: %s', | |||||
| 154 | unknown_tables => { | |||||
| 155 | one => 'Table not found in database: %s', | |||||
| 156 | other => 'Tables not found in database: %s', | |||||
| 157 | }, | |||||
| 158 | unmappable => 'Table %s, line %d: cannot be represented in %s', | |||||
| 159 | write_failed => 'Cannot write %s: %s', | |||||
| 160 | }, | |||||
| 161 | ); | |||||
| 162 | ||||||
| 163 | =encoding utf8 | |||||
| 164 | ||||||
| 165 - 494 | =head1 NAME
App::Access2CSV::I18N - Message catalog and translated error messages for App::Access2CSV
=head1 VERSION
Version 0.001.0
=head1 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;
}
=head1 DESCRIPTION
Every message that App::Access2CSV prints, logs or throws is looked up
here, by a short name called a I<key> (for example C<output_exists>).
This keeps all the text in one place, so the program can be translated.
The text for each key is a I<template>. A template is usually a
C<sprintf> format: C<%s> is replaced by a text value and C<%d> by a whole
number, in order. A template can also have different forms:
=over 4
=item * B<Plural forms>, chosen by a count: C<one> for one item,
C<other> for any other number. Some languages use more forms
(C<zero>, C<two>, C<few>, C<many>).
=item * B<Context forms>, chosen by a word you give, such as C<male> or
C<female>. A context form can itself contain plural forms.
=back
The templates live in the hash C<%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 (C<en>) is included.
=head2 Which language is used
=over 4
=item 1. The C<language> field of the object, if you call C<i18n> on an
object that has one (for example
C<< App::Access2CSV::Exporter->new(language => 'en') >>).
=item 2. Otherwise the first of these environment variables that is set
and is not C<C> or C<POSIX>: C<LANGUAGE>, C<LC_ALL>, C<LC_MESSAGES>,
C<LANG>. Only the language part is used: C<de_DE.UTF-8> means C<de>.
Upper or lower case does not matter. C<LANGUAGE> can hold a list such as
C<fr:de>; only the first entry is used. An empty value, or an empty
first entry (C<:de>), counts as "not set", so the next variable is tried.
=item 3. If there is no catalog for that language, English is used.
=back
Inside a language, a key that has no translation falls back to the
English text, one key at a time.
=head2 Helpers for subclasses
Subclasses get two protected methods. They can be called only from this
class and its subclasses:
=over 4
=item * C<< $self->_croak_i18n($key, \%args) >> - throws an exception
(with L<Carp/croak>) with the translated message. It never returns.
=item * C<< $self->_carp_i18n($key, \%args) >> - warns (with
L<Carp/carp>) with the translated message, with control characters
escaped. It returns C<$self>.
=item * C<< $self->_printable($text) >> - returns C<$text> with control
characters (C0 except tab, DEL, C1, and text-direction controls) shown
as escapes such as C<\x1B>, so that text from an untrusted database is
safe to print or log.
=back
=head1 ENCODING
=over 4
=item * B<The English catalog> is plain ASCII.
=item * B<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.
=item * B<Translations with non-ASCII text> (for example German C<ue>
written as one letter, or Japanese) must be Perl character strings: write
them in a source file with C<use utf8;>. Do not mix them with UTF-8
I<byte> strings in C<params>, or the bytes will be encoded a second time.
When you print such messages, give the output handle an encoding, for
example C<binmode(STDERR, ':encoding(UTF-8)')>, or Perl warns
"Wide character in print".
=back
=head1 COMMON PITFALLS
=over 4
=item * B<Percent signs.> A template with no C<params> is returned
exactly as written, so C<100%> is safe. But when you give C<params>, the
template goes through C<sprintf>, so a literal percent sign must be
written C<%%>.
=item * B<Number of values.> Give exactly as many C<params> as the
template has placeholders. Too few gives a Perl "Missing argument"
warning; C<undef> in C<params> gives a "Use of uninitialized value"
warning.
=item * B<No count means plural.> If you do not give C<count>, the
C<other> form is used, even if the number in C<params> is 1.
=item * B<Replacing a key replaces all of its forms.>
C<%MESSAGES> is not merged in depth. If you set
C<< $MESSAGES{en}{summary} = 'Done' >>, the C<one> and C<other> forms of
C<summary> are gone. If a translation gives a plural hash, it should have
an C<other> form: when no form fits, the English text for that key is
used instead.
=item * B<Unknown context.> A C<context> that the template does not have
is ignored; the plural forms (or the plain text) are used instead.
=item * B<Unknown keys are fatal.> A key that is not in the English
catalog is a programming error: C<i18n> calls C<confess>, which stops the
program and prints a stack trace.
=item * B<undef arguments.> C<undef> for C<args>, or for a field inside
the hashref form, is treated as "not given". C<undef> as the key is
reported as a missing key.
=item * B<Load with use, not require.> The protection of C<_croak_i18n>
and C<_carp_i18n> is set up at compile time. After a run-time
C<require> it is missing, and Perl prints "Too late to run CHECK block".
=back
=head1 METHODS
=head2 i18n
=head3 Purpose
Turn a message key into text in the user's language. Choose the right
context and plural form, then fill in the values.
=head3 Arguments
You can call C<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 });
=over 4
=item C<key> (string, required)
The message key, for example C<'output_exists'>.
=item C<args> (hash reference, optional)
=over 4
=item C<params> - an array reference of the values for the placeholders,
in order.
=item C<count> - a whole number, 0 or more, that chooses the plural form.
=item C<context> - a word that chooses a context form, for example
C<'female'>.
=back
=back
=head3 Returns
The message as a string, without a newline at the end.
=head3 Side Effects
None. It only reads C<%MESSAGES> and C<%ENV>. Your C<$@>, C<$!> and
C<$_> are left as they were.
=head3 Usage
my $text = $self->i18n('summary', { params => [3, 0], count => 3 });
=head3 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"
=head3 API SPECIFICATION
=head4 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 F<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"
=head4 Output
{
type => 'string',
}
=head3 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 | |
+-----------------------------+-------------------------------+---------------------------------+
=head3 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
=cut | |||||
| 495 | ||||||
| 496 | sub i18n { | |||||
| 497 | 1235 | 906639 | my $self = shift; | |||
| 498 | ||||||
| 499 | # Validation uses eval internally; the caller's $@ must survive | |||||
| 500 | 1235 | 1012 | local $@; | |||
| 501 | ||||||
| 502 | # Accept both i18n('key', {...}) and i18n({ key => ..., args => {...} }) | |||||
| 503 | # Undefined values are dropped so that validation reports them as missing | |||||
| 504 | 1235 7 | 3401 11 | my $in = (ref($_[0]) eq 'HASH') ? { %{ $_[0] } } : { key => $_[0], args => $_[1] }; | |||
| 505 | 1235 1235 2468 1235 | 1164 1119 2544 1930 | delete @{$in}{ grep { !defined $in->{$_} } keys %{$in} }; | |||
| 506 | ||||||
| 507 | 1235 | 10064 | my $params = validate_strict( | |||
| 508 | schema => { | |||||
| 509 | key => { type => 'string', min => 1 }, | |||||
| 510 | args => { | |||||
| 511 | type => 'hashref', | |||||
| 512 | optional => 1, | |||||
| 513 | schema => { | |||||
| 514 | params => { type => 'arrayref', optional => 1 }, | |||||
| 515 | count => { type => 'integer', optional => 1, min => 0 }, | |||||
| 516 | context => { type => 'string', optional => 1 }, | |||||
| 517 | }, | |||||
| 518 | }, | |||||
| 519 | }, | |||||
| 520 | input => $in, | |||||
| 521 | ); | |||||
| 522 | 1195 | 233339 | my $args = $params->{args} || {}; | |||
| 523 | ||||||
| 524 | # Resolve the template in the user's language, falling back to English | |||||
| 525 | # Try the chosen language, then English: a fixed list of at most two | |||||
| 526 | # attempts. There is deliberately no recursion here, so no mistake in | |||||
| 527 | # choosing the language (a bug, or a mutation of _language) can ever | |||||
| 528 | # make this loop forever. | |||||
| 529 | 1195 | 1098 | my $key = $params->{key}; | |||
| 530 | # An undefined language (only possible through a bug) means English | |||||
| 531 | 1195 | 2464 | my $lang = $self->_language() // $DEFAULT_LANGUAGE; | |||
| 532 | 1195 | 916 | my $entry; | |||
| 533 | 1195 | 1583 | foreach my $try ($lang eq $DEFAULT_LANGUAGE ? ($lang) : ($lang, $DEFAULT_LANGUAGE)) { | |||
| 534 | 1205 | 2058 | $entry = _narrow($self->_lookup($try, $key), $try, $args); | |||
| 535 | 1198 | 1270 | last if defined $entry; | |||
| 536 | } | |||||
| 537 | ||||||
| 538 | # Premise 1: every English template is complete. Premise 2: not even | |||||
| 539 | # the English one gave a usable form. Conclusion: the catalog is | |||||
| 540 | # broken, which is a programming error. | |||||
| 541 | 1188 | 1078 | _unknown_key($key) unless defined $entry; | |||
| 542 | ||||||
| 543 | # A literal message with no placeholders is returned untouched, which | |||||
| 544 | # protects any '%' characters it contains from sprintf() | |||||
| 545 | # Use the validated array reference directly rather than copying it | |||||
| 546 | 1178 | 884 | my $values = $args->{params}; | |||
| 547 | 1178 814 | 1491 2064 | my $text = ($values && @{$values}) ? sprintf($entry, @{$values}) : $entry; | |||
| 548 | ||||||
| 549 | 1178 | 2733 | return set_return($text, { type => 'string' }); | |||
| 550 | } | |||||
| 551 | ||||||
| 552 | # _croak_i18n | |||||
| 553 | # Purpose: Throw a localised exception from the caller's point of view. | |||||
| 554 | # Entry Criteria: $key is a catalog key; $args is an optional i18n() hashref. | |||||
| 555 | # Exit Status: Never returns; always croaks. | |||||
| 556 | # Side Effects: Unwinds the stack with a Carp exception. | |||||
| 557 | sub _croak_i18n :Protected { | |||||
| 558 | 358 | 7920 | my ($self, $key, $args) = @_; | |||
| 559 | ||||||
| 560 | 358 | 617 | croak($self->i18n($key, $args)); | |||
| 561 | 37 37 37 | 107 28 442 | } | |||
| 562 | ||||||
| 563 | # _carp_i18n | |||||
| 564 | # Purpose: Emit a localised warning from the caller's point of view. | |||||
| 565 | # Entry Criteria: $key is a catalog key; $args is an optional i18n() hashref. | |||||
| 566 | # Exit Status: Returns $self for chaining. | |||||
| 567 | # Side Effects: Writes a warning to STDERR (or $SIG{__WARN__}). | |||||
| 568 | sub _carp_i18n :Protected { | |||||
| 569 | 120 | 3668 | my ($self, $key, $args) = @_; | |||
| 570 | ||||||
| 571 | 120 | 241 | carp($self->_printable($self->i18n($key, $args))); | |||
| 572 | 120 | 19310 | return $self; | |||
| 573 | 37 37 37 | 4379 31 276 | } | |||
| 574 | ||||||
| 575 | # _interrupt_signals | |||||
| 576 | # Purpose: List the "stop now" signals that this program may take | |||||
| 577 | # over: those that exist on this system and that nobody | |||||
| 578 | # has set a handler for. Used by code that must clean up | |||||
| 579 | # (delete temporary files) when stopped, since Perl's | |||||
| 580 | # default action for these signals skips all clean-up. | |||||
| 581 | # Entry Criteria: None. | |||||
| 582 | # Exit Status: Returns an arrayref of signal names, e.g. ['INT', 'TERM']. | |||||
| 583 | # The caller localises %SIG for exactly these names. | |||||
| 584 | # Side Effects: None. | |||||
| 585 | sub _interrupt_signals :Protected { | |||||
| 586 | 315 21420 | 6510 19713 | my %exists = map { $_ => 1 } split ' ', $Config{sig_name}; | |||
| 587 | 315 1260 | 2019 5511 | return [ grep { $exists{$_} && ($SIG{$_} // 'DEFAULT') eq 'DEFAULT' } @INTERRUPT_SIGNALS ]; | |||
| 588 | 37 37 37 | 4691 32 287 | } | |||
| 589 | ||||||
| 590 | # _printable | |||||
| 591 | # Purpose: Make text safe to show on a terminal or write to a log, | |||||
| 592 | # by replacing control characters with visible escapes | |||||
| 593 | # such as \x1B. Text may come from a hostile database | |||||
| 594 | # (table names, mdbtools error output). | |||||
| 595 | # Entry Criteria: $text is a string (Perl characters or UTF-8 bytes) or undef. | |||||
| 596 | # Exit Status: Returns the escaped string ('' for undef). | |||||
| 597 | # Side Effects: None. | |||||
| 598 | sub _printable :Protected { | |||||
| 599 | 584 | 27024 | my ($self, $text) = @_; | |||
| 600 | ||||||
| 601 | 584 | 824 | $text //= ''; | |||
| 602 | 584 | 939 | if(utf8::is_utf8($text)) { | |||
| 603 | 0 0 | 0 0 | $text =~ s/($UNPRINTABLE_RE)/sprintf(ord($1) > 0xFF ? '\\x{%X}' : '\\x%02X', ord $1)/ge; | |||
| 604 | } else { | |||||
| 605 | 584 43 52 | 4848 133 208 | $text =~ s{($UNPRINTABLE_BYTES_RE)}{join(q{}, map { sprintf(q{\\x%02X}, ord) } split(//, $1))}ge; | |||
| 606 | } | |||||
| 607 | 584 | 4945 | return $text; | |||
| 608 | 37 37 37 | 5842 28 291 | } | |||
| 609 | ||||||
| 610 | # _language | |||||
| 611 | # Purpose: Work out which catalog language to use. | |||||
| 612 | # Entry Criteria: $self is a class name or an object (optionally with {language}). | |||||
| 613 | # Exit Status: Returns a lower-case language code, e.g. 'en' or 'de'. | |||||
| 614 | # Side Effects: None; reads %ENV only. | |||||
| 615 | sub _language :Private { | |||||
| 616 | 1205 | 21748 | my $self = shift; | |||
| 617 | ||||||
| 618 | # An explicit per-object choice beats anything in the environment. | |||||
| 619 | # Otherwise take the first meaningful locale variable; LANGUAGE may be | |||||
| 620 | # a colon-separated preference list, so only its first entry counts. | |||||
| 621 | # Empty values and C/POSIX mean "no preference", so they are skipped. | |||||
| 622 | 1205 | 2635 | my $wanted = ref($self) && $self->{language}; | |||
| 623 | 4752 | 10714 | ($wanted) = grep { length && !$NEUTRAL_LOCALES{$_} } | |||
| 624 | 1205 4752 | 4678 24020 | map { (split /:/, $ENV{$_} // '')[0] // '' } @LOCALE_VARIABLES | |||
| 625 | unless $wanted; | |||||
| 626 | ||||||
| 627 | # "de_DE.UTF-8@euro" -> "de"; anything unparseable means English | |||||
| 628 | 1205 | 4000 | my ($code) = ($wanted // '') =~ /\A([A-Za-z]{2,3})(?:[_\-.@]|\z)/; | |||
| 629 | 1205 | 2445 | return (defined($code) && exists($MESSAGES{lc $code})) ? lc($code) : $DEFAULT_LANGUAGE; | |||
| 630 | 37 37 37 | 6244 35 313 | } | |||
| 631 | ||||||
| 632 | # _lookup | |||||
| 633 | # Purpose: Fetch the raw template for $key in $lang. | |||||
| 634 | # Entry Criteria: $lang is a catalog language; $key is a non-empty string. | |||||
| 635 | # Exit Status: Returns a string or hashref template. | |||||
| 636 | # Side Effects: confess()es if $key is missing from the default catalog, | |||||
| 637 | # because that can only be a programming error. | |||||
| 638 | sub _lookup :Private { | |||||
| 639 | 1212 | 10975 | my ($self, $lang, $key) = @_; | |||
| 640 | ||||||
| 641 | # A partial translation silently falls back to English per key | |||||
| 642 | 1212 | 1781 | foreach my $catalog ($MESSAGES{$lang}, $MESSAGES{$DEFAULT_LANGUAGE}) { | |||
| 643 | 1243 | 4674 | return $catalog->{$key} if $catalog && exists($catalog->{$key}); | |||
| 644 | } | |||||
| 645 | ||||||
| 646 | 11 | 24 | return _unknown_key($key); | |||
| 647 | 37 37 37 | 4316 44 255 | } | |||
| 648 | ||||||
| 649 | # _narrow | |||||
| 650 | # Purpose: Reduce a template to one sprintf() format: first the | |||||
| 651 | # form for the context (if the template has one), then | |||||
| 652 | # the plural form for the count, falling back to "other". | |||||
| 653 | # Entry Criteria: $entry is a string or hashref template; $lang is the | |||||
| 654 | # language whose plural rule applies; $args is the | |||||
| 655 | # validated i18n() args hashref. | |||||
| 656 | # Exit Status: Returns a plain string, or undef if the template has no | |||||
| 657 | # usable form (a gap in a translation). | |||||
| 658 | # Side Effects: None. A plain function, not a method. | |||||
| 659 | sub _narrow :Private { | |||||
| 660 | 1217 | 107723 | my ($entry, $lang, $args) = @_; | |||
| 661 | ||||||
| 662 | 1217 | 1961 | if(ref($entry) eq 'HASH' && defined($args->{context}) && exists($entry->{$args->{context}})) { | |||
| 663 | 14 | 19 | $entry = $entry->{$args->{context}}; | |||
| 664 | } | |||||
| 665 | 1217 | 1339 | if(ref($entry) eq 'HASH') { | |||
| 666 | 180 | 404 | my $category = _plural_category($lang, $args->{count}); | |||
| 667 | 180 | 364 | $entry = exists($entry->{$category}) ? $entry->{$category} : $entry->{$PLURAL_OTHER}; | |||
| 668 | } | |||||
| 669 | 1217 | 2306 | return (defined($entry) && !ref($entry)) ? $entry : undef; | |||
| 670 | 37 37 37 | 5262 29 299 | } | |||
| 671 | ||||||
| 672 | # _unknown_key | |||||
| 673 | # Purpose: Report a message key that the English catalog lacks. | |||||
| 674 | # Entry Criteria: $key is the key that could not be resolved. | |||||
| 675 | # Exit Status: Never returns; always confesses (a stack trace helps, | |||||
| 676 | # because this can only be a programming error). | |||||
| 677 | # Side Effects: Unwinds the stack. | |||||
| 678 | # The text is built directly from the catalog, not through i18n(), which | |||||
| 679 | # could recurse forever if 'unknown_message' itself were missing. | |||||
| 680 | sub _unknown_key :Private { | |||||
| 681 | 23 | 2159 | my $key = shift; | |||
| 682 | ||||||
| 683 | 23 | 352 | confess(sprintf($MESSAGES{$DEFAULT_LANGUAGE}{unknown_message} || 'Unknown message key: %s', $key)); | |||
| 684 | 37 37 37 | 3547 57 264 | } | |||
| 685 | ||||||
| 686 | # _plural_category | |||||
| 687 | # Purpose: Map a count to a CLDR plural category for a language. | |||||
| 688 | # Entry Criteria: $lang is a language code; $count is a non-negative | |||||
| 689 | # integer or undef (undef means "no plural choice"). | |||||
| 690 | # Exit Status: Returns a category name such as 'one' or 'other'. | |||||
| 691 | # Side Effects: None. A plain function, not a method. | |||||
| 692 | sub _plural_category :Private { | |||||
| 693 | 193 | 6129 | my ($lang, $count) = @_; | |||
| 694 | ||||||
| 695 | 193 | 699 | my $rule = $PLURAL_RULES{$lang} || $PLURAL_RULES{$DEFAULT_LANGUAGE}; | |||
| 696 | 193 | 1228 | return defined($count) ? $rule->($count) : $PLURAL_OTHER; | |||
| 697 | 37 37 37 | 3413 61 248 | } | |||
| 698 | ||||||
| 699 | 1; | |||||
| 700 | ||||||