File Coverage

blib/lib/Locale/Babelfish.pm
Criterion Covered Total %
statement 138 170 81.1
branch 42 78 53.8
condition 16 36 44.4
subroutine 25 27 92.5
pod 13 13 100.0
total 234 324 72.2


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