File Coverage

blib/lib/Locale/Babelfish.pm
Criterion Covered Total %
statement 169 202 83.6
branch 54 92 58.7
condition 19 39 48.7
subroutine 29 31 93.5
pod 13 13 100.0
total 284 377 75.3


line stmt bran cond sub pod time code
1             package Locale::Babelfish;
2              
3             # ABSTRACT: Perl I18n using https://github.com/nodeca/babelfish format.
4              
5             our $VERSION = '2.10'; # VERSION
6              
7              
8 1     1   777 use utf8;
  1         2  
  1         6  
9 1     1   30 use strict;
  1         2  
  1         18  
10 1     1   5 use warnings;
  1         2  
  1         21  
11 1     1   5 use Data::Dumper;
  1         2  
  1         56  
12              
13 1     1   6 use Carp qw/ confess /;
  1         2  
  1         45  
14 1     1   5 use File::Find qw( find );
  1         1  
  1         57  
15 1     1   6 use File::Spec ();
  1         2  
  1         28  
16              
17 1     1   456 use YAML::SyckWrapper qw( load_yaml );
  1         34899  
  1         68  
18 1     1   541 use Locale::Babelfish::Phrase::Parser ();
  1         3  
  1         26  
19 1     1   6 use Locale::Babelfish::Phrase::Compiler ();
  1         2  
  1         20  
20              
21              
22 1     1   4 use parent qw( Class::Accessor::Fast );
  1         2  
  1         4  
23              
24             use constant {
25 1         502 MTIME_INDEX => 9,
26 1     1   64 };
  1         2  
27              
28             __PACKAGE__->mk_accessors( qw(
29             dictionaries
30             fallbacks
31             fallback_cache
32             dirs
33             suffix
34             default_locale
35             watch
36             watchers
37             ) );
38              
39             my $parser = Locale::Babelfish::Phrase::Parser->new();
40             my $compiler = Locale::Babelfish::Phrase::Compiler->new();
41              
42              
43             sub _built_config {
44 1     1   2 my ( $cfg ) = @_;
45             return {
46             dictionaries => {},
47             fallbacks => {},
48             fallback_cache => {},
49             suffix => $cfg->{suffix} // 'yaml',
50             default_locale => $cfg->{default_locale} // 'en_US',
51             watch => $cfg->{watch} || 0,
52             watchers => {},
53 1   50     18 %{ $cfg // {} },
  1   50     13  
      50        
      50        
54             };
55             }
56              
57             sub new {
58 1     1 1 742 my ( $class, $cfg ) = @_;
59              
60             my $self = bless {
61             _cfg => $cfg,
62 1         2 %{ _built_config( $cfg ) },
  1         3  
63             }, $class;
64              
65 1         5 $self->load_dictionaries;
66 1         5 $self->locale( $self->{default_locale} );
67              
68 1         11 return $self;
69             }
70              
71              
72             sub locale {
73 3     3 1 1639 my $self = shift;
74 3 50       9 return $self->{locale} if scalar(@_) == 0;
75 3         10 $self->{locale} = $self->detect_locale( $_[0] );
76             }
77              
78              
79             sub on_watcher_change {
80 0     0 1 0 my ( $self ) = @_;
81 0         0 delete $self->{keys %$self};
82 0         0 my %new_cfg = %{ _built_config( $self->{_cfg} ) };
  0         0  
83 0         0 while( my ( $key, $value ) = each %new_cfg ) {
84 0         0 $self->{$key} = $value;
85             }
86 0         0 $self->load_dictionaries;
87 0         0 $self->locale( $self->{default_locale} );
88             }
89              
90              
91             sub look_for_watchers {
92 0     0 1 0 my ( $self ) = @_;
93 0 0       0 return unless $self->{watch};
94 0         0 my $ok = 1;
95 0         0 while ( my ( $file, $mtime ) = each %{ $self->watchers } ) {
  0         0  
96 0         0 my $new_mtime = (stat($file))[MTIME_INDEX];
97 0 0 0     0 if ( !defined( $mtime ) || !defined( $new_mtime ) || $new_mtime != $mtime ) {
      0        
98 0         0 $ok = 0;
99 0         0 last;
100             }
101             }
102 0 0       0 return if $ok;
103 0         0 $self->on_watcher_change();
104             }
105              
106              
107             sub t_or_undef {
108 28     28 1 79 my ( $self, $dictname_key, $params, $custom_locale ) = @_;
109              
110              
111 28 50       67 confess 'No dictname_key' unless $dictname_key;
112             # запрещаем ключи не ASCII
113 28 50       111 confess("wrong dictname_key: $dictname_key") if $dictname_key =~ m/\P{ASCII}/;
114              
115 28 50       70 my $locale = $custom_locale ? $self->detect_locale( $custom_locale ) : $self->{locale};
116              
117 28         68 my $r = $self->{dictionaries}->{$locale}->{$dictname_key};
118              
119 28 100       59 if ( defined $r ) {
120 25 100       79 if ( ref( $r ) eq 'SCALAR' ) {
    100          
121 11         46 $self->{dictionaries}->{$locale}->{$dictname_key} = $r = $compiler->compile(
122             $parser->parse( $$r, $locale ),
123             );
124             }
125             elsif ( ref( $r ) eq 'ARRAY' ) {
126 6         30 $self->{dictionaries}{$locale}{$dictname_key} = $r
127             = _process_list_items( $r, $locale );
128             }
129             }
130             # fallbacks
131             else {
132 3   100     17 $self->{fallback_cache}->{$locale} //= {};
133             # в кэше может быть undef, чтобы не пробегать локали для несуществующих ключей повторно.
134 3 50       7 if ( exists $self->{fallback_cache}->{$locale}->{$dictname_key} ) {
135 0         0 $r = $self->{fallback_cache}->{$locale}->{$dictname_key};
136             }
137             else {
138 3   100     5 my @fallback_locales = @{ $self->{fallbacks}->{$locale} // [] };
  3         12  
139 3         7 for ( @fallback_locales ) {
140 2         5 $r = $self->{dictionaries}->{$_}->{$dictname_key};
141 2 100       6 if ( defined $r ) {
142 1 50       5 if ( ref( $r ) eq 'SCALAR' ) {
    0          
143 1         6 $self->{dictionaries}->{$_}->{$dictname_key} = $r = $compiler->compile(
144             $parser->parse( $$r, $_ ),
145             );
146             }
147             elsif ( ref( $r ) eq 'ARRAY' ) {
148 0         0 $self->{dictionaries}{$locale}{$dictname_key} = $r
149             = _process_list_items( $r, $locale );
150             }
151 1         2 last;
152             }
153             }
154 3         10 $self->{fallback_cache}->{$locale}->{$dictname_key} = $r;
155             }
156             }
157              
158 28 100       103 if ( ref( $r ) eq 'CODE' ) {
159 23         50 my $flat_params = {};
160             # Переводим хэш параметров в "плоскую форму" так как в babelfish они имеют вид params.key.subkey
161 23 100       52 if ( defined($params) ) {
162             # переданный скаляр превращаем в хэш { count, value }.
163 20 100       52 if ( ref($params) eq '' ) {
164 3         13 $flat_params = {
165             count => $params,
166             value => $params,
167             };
168             }
169             else {
170 17         39 _flat_hash_keys( $params, '', $flat_params );
171             }
172             }
173              
174 23         464 return $r->( $flat_params );
175             }
176              
177 5         35 return $r;
178             }
179              
180              
181             sub t {
182 28     28 1 8855 my $self = shift;
183              
184 28   66     69 return $self->t_or_undef( @_ ) || "[$_[0]]";
185             }
186              
187              
188             sub has_any_value {
189 1     1 1 4 my ( $self, $dictname_key, $custom_locale ) = @_;
190              
191             # запрещаем ключи не ASCII
192 1 50       6 confess("wrong dictname_key: $dictname_key") if $dictname_key =~ m/\P{ASCII}/;
193              
194 1 50       5 my $locale = $custom_locale ? $self->detect_locale( $custom_locale ) : $self->{locale};
195              
196 1 50       8 return 1 if $self->{dictionaries}->{$locale}->{$dictname_key};
197              
198 0   0     0 $self->{fallback_cache}->{$locale} //= {};
199             return ( ( defined $self->{fallback_cache}->{$locale}->{$dictname_key} ) ? 1 : 0 )
200 0 0       0 if exists $self->{fallback_cache}->{$locale}->{$dictname_key};
    0          
201              
202 0   0     0 my @fallback_locales = @{ $self->{fallbacks}->{$locale} // [] };
  0         0  
203 0         0 for ( @fallback_locales ) {
204 0 0       0 return 1 if defined $self->{dictionaries}->{$_}->{$dictname_key};
205             }
206              
207             }
208              
209              
210             sub load_dictionaries {
211 1     1 1 2 my $self = shift;
212              
213 1         2 for my $dir ( @{$self->dirs} ) {
  1         27  
214 1         49 my $fdir = File::Spec->rel2abs( $dir );
215             find( {
216             follow => 1,
217             no_chdir => 1,
218             wanted => sub {
219 14     14   527 my $file = File::Spec->rel2abs( $File::Find::name );
220 14 100       1374 return unless -f $file;
221 8         111 my ( $volume, $directories, $base ) = File::Spec->splitpath( $file );
222              
223 8         33 my @tmp = split m/\./, $base;
224              
225 8         16 my $cur_suffix = pop @tmp;
226 8 50       182 return unless $cur_suffix eq $self->suffix;
227 8         54 my $lang = pop @tmp;
228              
229 8 50       19 pop @tmp if $tmp[-1] eq 'tt'; # словари вида formatting.tt.ru_RU.yaml - имеют имя formatting
230 8 50       17 if ( $tmp[-1] eq 'js') {
231             # словари .js перекрывают одноимённые словари без суффикса
232             # если это нежелательное поведение - словарь с суффиксом .tt перекроет одноимённый .js, и будет доступен только на сервере
233 0         0 pop @tmp; # словари вида formatting.js.ru_RU.yaml - имеют имя formatting
234             # и не загружаются, если есть аналогичный tt.
235 0 0       0 return if -f File::Spec->catpath( $volume, $directories, join('.', @tmp). ".tt.$lang.$cur_suffix" );
236             }
237 8         17 my $dictname = join('.', @tmp);
238 8         53 my $subdir = File::Spec->catpath( $volume, $directories, '' );
239 8 100       65 if ( $subdir =~ m/\A\Q$fdir\E[\\\/](.+)\z/ ) {
240 6         18 $dictname = "$1$dictname";
241             }
242              
243 8         21 $self->load_dictionary($dictname, $lang, $file);
244             },
245 1         170 }, $dir );
246             }
247 1         35 $self->prepare_to_compile;
248             }
249              
250              
251             sub load_dictionary {
252 8     8 1 18 my ( $self, $dictname, $lang, $file ) = @_;
253              
254 8   100     149 $self->dictionaries->{$lang} //= {};
255              
256 8         68 my $yaml = load_yaml( $file );
257              
258 8         13262 _flat_hash_keys( $yaml, "$dictname.", $self->dictionaries->{$lang} );
259              
260 8 50       154 return unless $self->watch;
261              
262 0         0 $self->watchers->{$file} = (stat($file))[MTIME_INDEX];
263             }
264              
265              
266             sub phrase_need_compilation {
267 27     27 1 47 my ( undef, $phrase, $key ) = @_;
268 27 50       44 die "L10N: $key is undef" unless defined $phrase;
269 27   100     130 return 1
270             && ref($phrase) eq ''
271             && $phrase =~ m/ (?: \(\( | \#\{ | \\\\ )/x
272             ;
273             }
274              
275              
276              
277             sub prepare_to_compile {
278 1     1 1 3 my ( $self ) = @_;
279 1         2 while ( my ($lang, $dic) = each(%{ $self->{dictionaries} }) ) {
  3         12  
280 2         7 while ( my ($key, $value) = each(%$dic) ) {
281 27 100       42 if ( $self->phrase_need_compilation( $value, $key ) ) {
282 15         55 $dic->{$key} = \$value; # отложенная компиляция
283             #my $ast = $parser->parse($value, $lang);
284             #$dic->{$key} = $compiler->compile( $ast );
285             }
286             }
287             }
288 1         2 return 1;
289             }
290              
291              
292             sub detect_locale {
293 4     4 1 10 my ( $self, $locale ) = @_;
294 4 100       102 return $locale if $self->dictionaries->{$locale};
295 1         7 my @alt_locales = grep { $_ =~ m/\A\Q$locale\E[\-_]/i } keys %{ $self->dictionaries };
  2         36  
  1         20  
296 1 50       4 confess "only one alternative locale allowed: ", join ',', @alt_locales
297             if @alt_locales > 1;
298              
299 1         2 my $alt_locale = $alt_locales[0];
300 1 50 33     22 if ( $alt_locale && $self->dictionaries->{$alt_locale} ) {
301             # сделаем locale dictionary ссылкой на alt locale dictinary.
302             # это ускорит работу всех t с указанием языка типа "ru" вместо локали "ru_RU".
303 1         23 $self->dictionaries->{$locale} = $self->dictionaries->{$alt_locale};
304              
305             $self->fallback_cache->{$locale} = $self->fallback_cache->{$alt_locale}
306 1 50       24 if exists $self->fallback_cache->{$alt_locale};
307              
308             $self->fallbacks->{$locale} = $self->fallbacks->{$alt_locale}
309 1 50       23 if exists $self->fallbacks->{$alt_locale};
310              
311 1         29 return $locale;
312             }
313 0 0       0 return $self->{default_locale} if $self->dictionaries->{ $self->{default_locale} };
314 0         0 confess "bad locale: $locale and bad default_locale: $self->{default_locale}.";
315             }
316              
317              
318             sub set_fallback {
319 1     1 1 7 my ( $self, $locale, @fallback_locales ) = @_;
320 1 50       3 return unless scalar( @fallback_locales );
321              
322 1         3 $locale = $self->detect_locale( $locale );
323              
324 1 50 33     10 @fallback_locales = @{ $fallback_locales[0] } if 1
  0         0  
325             && scalar( @fallback_locales ) == 1
326             && ref( $fallback_locales[0] ) eq 'ARRAY'
327             ;
328              
329 1         18 $self->fallbacks->{ $locale } = \@fallback_locales;
330 1         6 delete $self->{fallback_cache}->{ $locale };
331              
332 1         6 return 1;
333             }
334              
335              
336             sub _flat_hash_keys {
337 35     35   124 my ( $hash, $prefix, $store ) = @_;
338 35         135 while ( my ($key, $value) = each(%$hash) ) {
339 58 100       112 if (ref($value) eq 'HASH') {
340 10         33 _flat_hash_keys( $value, "$prefix$key.", $store );
341             } else {
342 48         201 $store->{"$prefix$key"} = $value;
343             }
344             }
345 35         79 return 1;
346             }
347              
348              
349             sub _process_list_items {
350 6     6   13 my ( $r, $locale ) = @_;
351              
352 6         10 my @compiled_items;
353 6         10 for my $item ( @{ $r } ) {
  6         29  
354 14 100 66     60 if ( ref $item eq 'HASH' ) {
    100          
355 8         23 push @compiled_items, _process_nested_hash_item( $item, $locale );
356             }
357             elsif ( ref $item ne 'HASH' && defined $item ) {
358 4         17 push @compiled_items, $compiler->compile( $parser->parse( $item, $locale ) );
359             }
360             else {
361 2         6 push @compiled_items, $item;
362             }
363             }
364              
365             return sub {
366 6     6   11 my $results = [];
367              
368 6         18 for my $item ( @compiled_items ) {
369 14 100       51 if ( ref( $item ) eq 'CODE' ) {
    100          
370 4         6 push @{ $results }, $item->(@_);
  4         97  
371             }
372             # Нужно скомпилить значения в хэшрефе
373             elsif ( ref( $item ) eq 'HASH' ) {
374 8         34 while ( my ( $key, $value ) = each ( %$item ) ) {
375 12 100       42 if ( ref ($value) eq 'CODE' ) {
376 4         142 $item->{$key} = $value->(@_);
377             }
378             }
379 8         10 push @{ $results }, $item;
  8         19  
380             }
381             else {
382 2         3 push @{ $results }, $item;
  2         4  
383             }
384             }
385              
386 6         58 return $results;
387 6         56 };
388             }
389              
390             sub _process_nested_hash_item {
391 8     8   17 my ( $hashref, $locale ) = @_;
392              
393 8         68 while ( my ( $key, $value ) = each ( %$hashref ) ) {
394 12         39 my $compiled_value = $compiler->compile( $parser->parse( $value, $locale ) );
395 12         254 $hashref->{$key} = $compiled_value;
396             }
397              
398 8         28 return $hashref;
399             }
400              
401              
402             1;
403              
404             __END__
405              
406             =pod
407              
408             =encoding utf-8
409              
410             =head1 NAME
411              
412             Locale::Babelfish - Perl I18n using https://github.com/nodeca/babelfish format.
413              
414             =head1 VERSION
415              
416             version 2.10
417              
418             =head1 DESCRIPTION
419              
420             Библиотека локализации.
421              
422             =head1 NAME
423              
424             Locale::Babelfish
425              
426             =head1 SYNOPSYS
427              
428             package Foo;
429              
430             use Locale::Babelfish ();
431              
432             my $bf = Locale::Babelfish->new( { dirs => [ '/path/to/dictionaries' ] } );
433             print $bf->t('dictionary.firstkey.nextkey', { foo => 'bar' } );
434              
435             More sophisticated example:
436              
437             package Foo::Bar;
438              
439             use Locale::Babelfish ();
440              
441             my $bf = Locale::Babelfish->new( {
442             # configuration
443             dirs => [ '/path/to/dictionaries' ],
444             default_locale => [ 'ru_RU' ], # By default en_US
445             } );
446              
447             # using default locale
448             print $bf->t( 'dictionary.akey' );
449             print $bf->t( 'dictionary.firstkey.nextkey', { foo => 'bar' } );
450              
451             # using specified locale
452             print $bf->t( 'dictionary.firstkey.nextkey', { foo => 'bar' }, 'by_BY' );
453              
454             # using scalar as count or value variable
455             print $bf->t( 'dictionary.firstkey.nextkey', 90 );
456             # same as
457             print $bf->t( 'dictionary.firstkey.nextkey', { count => 90, value => 90 } );
458              
459             # set locale
460             $bf->locale( 'en_US' );
461             print $bf->t( 'dictionary.firstkey.nextkey', { foo => 'bar' } );
462              
463             # Get current locale
464             print $bf->locale;
465              
466             =head1 DICTIONARIES
467              
468             =head2 Phrases Syntax
469              
470             #{varname} Echoes value of variable
471             ((Singular|Plural1|Plural2)):variable Plural form
472             ((Singular|Plural1|Plural2)) Short plural form for "count" variable
473              
474             Example:
475              
476             I have #{nails_count} ((nail|nails)):nails_count
477              
478             or short form
479              
480             I have #{count} ((nail|nails))
481              
482             or with zero and onу plural forms:
483              
484             I have ((=0 no nails|=1 a nail|#{nails_count} nail|#{nails_count} nails)):nails_count
485              
486             =head2 Dictionary file example
487              
488             Module support only YAML format. Create dictionary file like: B<dictionary.en_US.yaml> where
489             C<dictionary> is name of dictionary and C<en_US> - its locale.
490              
491             profile:
492             apps:
493             forums:
494             new_topic: New topic
495             last_post:
496             title : Last message
497             demo:
498             apples: I have #{count} ((apple|apples))
499             list:
500             - some content #{data}
501             - some other content #{data}
502              
503             =head1 DETAILS
504              
505             Словари грузятся при создании экземпляра, сразу в плоской форме
506             $self->{dictionaries}->{ru_RU}->{dictname_key}...
507              
508             Причем все скалярные значения, при необходимости (есть спецсимволы Babelfish),
509             преобразуются в ссылки на скаляры (флаг - "нужно скомпилировать").
510              
511             Метод t_or_undef получает значение по указанному ключу.
512              
513             Если это ссылка на скаляр, то парсит и компилирует строку.
514              
515             Если это ссылка на массив, то работаем со всеми элементами массива как со скалярами,
516             собираем полученные результаты компиляции в новый массив и возвращаем ссылку на этот массив.
517              
518             Результат компиляции либо ссылка на подпрограмму, либо просто строка.
519              
520             Если это ссылка на подпрограмму, мы просто вызываем ее с плоскими параметрами.
521              
522             Если просто строка, то возвращаем её as is.
523              
524             Поддерживается опция watch.
525              
526             =head1 METHODS
527              
528             =over
529              
530             =item locale
531              
532             Если указана локаль, устанавливет её. Если нет - возвращает.
533              
534             =item on_watcher_change
535              
536             Перечитывает все словари.
537              
538             =item look_for_watchers
539              
540             Обновляет словари оп мере необходимости, через L</on_watcher_change>.
541              
542             =item t_or_undef
543              
544             $self->t_or_undef( 'main.key.subkey' , { paaram1 => 1 , param2 => 'test' } , 'ru' );
545              
546             Локализация по ключу.
547              
548             первой частью в ключе $key должен идти словарь, например, main.key
549             параметр языка не обязательный.
550              
551             $params - хэш параметров
552              
553             =item t
554              
555             $self->t( 'main.key.subkey' , { paaram1 => 1 , param2 => 'test' } , 'ru' );
556              
557             Локализация по ключу.
558              
559             первой частью в ключе $key должен идти словарь, например, main.key
560             параметр языка не обязательный.
561              
562             $params - хэш параметров
563              
564             =item has_any_value
565              
566             $self->has_any_value( 'main.key.subkey' );
567              
568             Проверяет есть ли ключ в словаре
569              
570             первой частью в ключе должен идти словарь, например, main.
571              
572             =item load_dictionaries
573              
574             Загружает все yaml словари с диска
575              
576             =item load_dictionary
577              
578             Загружает один yaml словарь с диска
579              
580             =item phrase_need_compilation
581              
582             $self->phrase_need_compilation( $phrase, $key )
583             $class->phrase_need_compilation( $phrase, $key )
584              
585             Определяет, требуется ли компиляция фразы.
586              
587             Используется также при компиляции плюралов (вложенные выражения).
588              
589             =item prepare_to_compile
590              
591             $self->prepare_to_compile()
592              
593             Либо маркирует как refscalar строки в словарях, требующие компиляции,
594             либо просто компилирует их.
595              
596             =item detect_locale
597              
598             $self->detect_locale( $locale );
599              
600             Определяем какой язык будет использован.
601             приоритет $locale, далее default_locale.
602              
603             =item set_fallback
604              
605             $self->set_fallback( 'by_BY', 'ru_RU', 'en_US');
606             $self->set_fallback( 'by_BY', [ 'ru_RU', 'en_US' ] );
607              
608             Для указанной локали устанавливает список локалей, на которые будет производится откат
609             в случае отсутствия фразы в указанной.
610              
611             Например, в вышеуказанных примерах при отсутствии фразы в
612             белорусской локали будет затем искаться фраза в русской локали,
613             затем в англоамериканской.
614              
615             =item _flat_hash_keys
616              
617             _flat_hash_keys( $hash, '', $result );
618              
619             Внутренняя, рекурсивная.
620             Преобразует хэш любой вложенности в строку, где ключи хешей разделены точками.
621              
622             =item _process_list_items
623              
624             _process_list_items( $dictionary_values);
625              
626             Обрабатывает ключи словарей содержащие списки, и оборачивает в функцию для компиляции списка.
627             Поддерживаются вложенные в список плоские хэшрефы
628              
629             =back
630              
631             =head1 AUTHORS
632              
633             =over 4
634              
635             =item *
636              
637             Akzhan Abdulin <akzhan@cpan.org>
638              
639             =item *
640              
641             Igor Mironov <grif@cpan.org>
642              
643             =item *
644              
645             Victor Efimov <efimov@reg.ru>
646              
647             =item *
648              
649             REG.RU LLC
650              
651             =item *
652              
653             Kirill Sysoev <k.sysoev@me.com>
654              
655             =item *
656              
657             Alexandr Tkach <tkach@reg.ru>
658              
659             =back
660              
661             =head1 COPYRIGHT AND LICENSE
662              
663             This software is Copyright (c) 2014 by REG.RU LLC.
664              
665             This is free software, licensed under:
666              
667             The MIT (X11) License
668              
669             =cut