Merge branch 'master' of vc.linet-services.de:public/lx-office-erp
[kivitendo-erp.git] / scripts / locales.pl
1 #!/usr/bin/perl
2
3 # -n do not include custom_ scripts
4 # -v verbose mode, shows progress stuff
5
6 # this version of locles processes not only all required .pl files
7 # but also all parse_html_templated files.
8
9 use utf8;
10 use strict;
11
12 use Data::Dumper;
13 use English;
14 use FileHandle;
15 use Getopt::Long;
16 use List::Util qw(first);
17 use POSIX;
18 use Pod::Usage;
19 use Carp;
20 use File::Slurp qw(slurp);
21
22 $OUTPUT_AUTOFLUSH = 1;
23
24 my $opt_v  = 0;
25 my $opt_n  = 0;
26 my $opt_c  = 0;
27 my $debug  = 0;
28
29 parse_args();
30
31 my $basedir      = "../..";
32 my $locales_dir  = ".";
33 my $bindir       = "$basedir/bin/mozilla";
34 my @progdirs     = ( "$basedir/SL" );
35 my $dbupdir      = "$basedir/sql/Pg-upgrade";
36 my $dbupdir2     = "$basedir/sql/Pg-upgrade2";
37 my $menufile     = "menu.ini";
38 my $submitsearch = qr/type\s*=\s*[\"\']?submit/i;
39
40 my (%referenced_html_files, %locale, %htmllocale, %alllocales, %cached, %submit);
41 my ($ALL_HEADER, $MISSING_HEADER, $LOST_HEADER);
42
43 init();
44
45 sub find_files {
46   my ($dir_name, $files) = @_;
47
48   $files ||= [];
49
50   my @dirs_to_check;
51
52   opendir my $dir, $dir_name or die "$! $dir_name";
53
54   foreach my $name (readdir $dir) {
55     next if $name eq '.' || $name eq '..';
56
57     my $full_name = "${dir_name}/${name}";
58     if (-d $full_name) {
59       push @dirs_to_check, $full_name;
60     } else {
61       push @{ $files }, $full_name;
62     }
63   }
64
65   closedir $dir;
66
67   map { find_files($_, $files) } @dirs_to_check;
68
69   return @{ $files };
70 }
71
72 my @bindir_files = find_files($bindir);
73 my @progfiles    = map { m:^(.+)/([^/]+)$:; [ $2, $1 ]  } grep { /\.pl$/ && !/_custom/ } @bindir_files;
74 my @customfiles  = grep /_custom/, @bindir_files;
75
76 push @progfiles, map { m:^(.+)/([^/]+)$:; [ $2, $1 ] } grep { /\.pm$/ } map { find_files($_) } @progdirs;
77
78 # put customized files into @customfiles
79 my @menufiles;
80
81 if ($opt_n) {
82   @customfiles = ();
83   @menufiles   = ($menufile);
84 } else {
85   opendir DIR, "$basedir" or die "$!";
86   @menufiles = grep { /.*?_$menufile$/ } readdir DIR;
87   closedir DIR;
88   unshift @menufiles, $menufile;
89 }
90
91 opendir DIR, $dbupdir or die "$!";
92 my @dbplfiles = grep { /\.pl$/ } readdir DIR;
93 closedir DIR;
94
95 opendir DIR, $dbupdir2 or die "$!";
96 my @dbplfiles2 = grep { /\.pl$/ } readdir DIR;
97 closedir DIR;
98
99 # slurp the translations in
100 our $self    = {};
101 our $missing = {};
102 our @missing = ();
103 our @lost    = ();
104
105 if (-f "$locales_dir/all") {
106   require "$locales_dir/all";
107 }
108 if (-f "$locales_dir/missing") {
109   require "$locales_dir/missing" ;
110   unlink "$locales_dir/missing";
111 }
112 if (-f "$locales_dir/lost") {
113   require "$locales_dir/lost";
114   unlink "$locales_dir/lost";
115 }
116
117 my $charset = slurp("$locales_dir/charset") || 'utf-8';
118 chomp $charset;
119
120 my %old_texts = %{ $self->{texts} || {} };
121
122 map({ handle_file(@{ $_ }); } @progfiles);
123 map({ handle_file($_, $dbupdir); } @dbplfiles);
124 map({ handle_file($_, $dbupdir2); } @dbplfiles2);
125
126 # generate all
127 generate_file(
128   file      => "$locales_dir/all",
129   header    => $ALL_HEADER,
130   data_name => '$self->{texts}',
131   data_sub  => sub { _print_line($_, $self->{texts}{$_}, @_) for sort keys %alllocales },
132 );
133
134 # calc and generate missing
135 push @missing, grep { !$self->{texts}{$_} } sort keys %alllocales;
136
137 if (@missing) {
138   generate_file(
139     file      => "$locales_dir/missing",
140     header    => $MISSING_HEADER,
141     data_name => '$missing',
142     data_sub  => sub { _print_line($_, '', @_) for @missing },
143   );
144 }
145
146 # calc and generate lost
147 while (my ($text, $translation) = each %old_texts) {
148   next if ($alllocales{$text});
149   push @lost, { 'text' => $text, 'translation' => $translation };
150 }
151
152 if (scalar @lost) {
153   splice @lost, 0, (scalar @lost - 50) if (scalar @lost > 50);
154   generate_file(
155     file      => "$locales_dir/lost",
156     header    => $LOST_HEADER,
157     delim     => '()',
158     data_name => '@lost',
159     data_sub  => sub {
160       _print_line($_->{text}, $_->{translation}, @_, template => "  { 'text' => %s, 'translation' => %s },\n") for @lost;
161     },
162   );
163 }
164
165 my $trlanguage = slurp("$locales_dir/LANGUAGE");
166 chomp $trlanguage;
167
168 search_unused_htmlfiles() if $opt_c;
169
170 my $count  = scalar keys %alllocales;
171 my $notext = scalar @missing;
172 my $per    = sprintf("%.1f", ($count - $notext) / $count * 100);
173 print "\n$trlanguage - ${per}%";
174 print " - $notext/$count missing" if $notext;
175 print "\n";
176
177 exit;
178
179 # eom
180
181 sub init {
182   $ALL_HEADER = <<EOL;
183 # These are all the texts to build the translations files.
184 # The file has the form of 'english text'  => 'foreign text',
185 # you can add the translation in this file or in the 'missing' file
186 # run locales.pl from this directory to rebuild the translation files
187 EOL
188   $MISSING_HEADER = <<EOL;
189 # add the missing texts and run locales.pl to rebuild
190 EOL
191   $LOST_HEADER  = <<EOL;
192 # The last 50 text strings, that have been removed.
193 # This file has been auto-generated by locales.pl. Please don't edit!
194 EOL
195 }
196
197 sub parse_args {
198   my ($help, $man);
199
200   GetOptions(
201     'no-custom-files' => \$opt_n,
202     'check-files'     => \$opt_c,
203     'verbose'         => \$opt_v,
204     'help'            => \$help,
205     'man'             => \$man,
206     'debug'           => \$debug,
207   );
208
209   if ($help) {
210     pod2usage(1);
211     exit 0;
212   }
213
214   if ($man) {
215     pod2usage(-exitstatus => 0, -verbose => 2);
216     exit 0;
217   }
218
219   if (@ARGV) {
220     my $arg = shift @ARGV;
221     my $ok  = 0;
222     foreach my $dir ("../locale/$arg", "locale/$arg", "../$arg", $arg) {
223       next unless -d $dir && -f "$dir/all" && -f "$dir/LANGUAGE";
224       $ok = chdir $dir;
225       last;
226     }
227
228     if (!$ok) {
229       print "The locale directory '$arg' could not be found.\n";
230       exit 1;
231     }
232
233   } elsif (!-f 'all' || !-f 'LANGUAGE') {
234     print "locales.pl was not called from a locale/* subdirectory,\n"
235       .   "and no locale directory name was given.\n";
236     exit 1;
237   }
238 }
239
240 sub handle_file {
241   my ($file, $dir) = @_;
242   print "\n$file" if $opt_v;
243   %locale = ();
244   %submit = ();
245
246   &scanfile("$dir/$file");
247
248   # scan custom_{module}.pl or {login}_{module}.pl files
249   foreach my $customfile (@customfiles) {
250     if ($customfile =~ /_$file/) {
251       if (-f "$dir/$customfile") {
252         &scanfile("$dir/$customfile");
253       }
254     }
255   }
256
257   # if this is the menu.pl file
258   if ($file eq 'menu.pl') {
259     foreach my $item (@menufiles) {
260       &scanmenu("$basedir/$item");
261     }
262   }
263
264   if ($file eq 'menunew.pl') {
265     foreach my $item (@menufiles) {
266       &scanmenu("$basedir/$item");
267       print "." if $opt_v;
268     }
269   }
270
271   $file =~ s/\.pl//;
272
273   foreach my $text (keys %$missing) {
274     if ($locale{$text} || $htmllocale{$text}) {
275       unless ($self->{texts}{$text}) {
276         $self->{texts}{$text} = $missing->{$text};
277       }
278     }
279   }
280 }
281
282 sub extract_text_between_parenthesis {
283   my ($fh, $line) = @_;
284   my ($inside_string, $pos, $text, $quote_next) = (undef, 0, "", 0);
285
286   while (1) {
287     if (length($line) <= $pos) {
288       $line = <$fh>;
289       return ($text, "") unless ($line);
290       $pos = 0;
291     }
292
293     my $cur_char = substr($line, $pos, 1);
294
295     if (!$inside_string) {
296       if ((length($line) >= ($pos + 3)) && (substr($line, $pos, 2)) eq "qq") {
297         $inside_string = substr($line, $pos + 2, 1);
298         $pos += 2;
299
300       } elsif ((length($line) >= ($pos + 2)) &&
301                (substr($line, $pos, 1) eq "q")) {
302         $inside_string = substr($line, $pos + 1, 1);
303         $pos++;
304
305       } elsif (($cur_char eq '"') || ($cur_char eq '\'')) {
306         $inside_string = $cur_char;
307
308       } elsif (($cur_char eq ")") || ($cur_char eq ',')) {
309         return ($text, substr($line, $pos + 1));
310       }
311
312     } else {
313       if ($quote_next) {
314         $text .= '\\' unless $cur_char eq "'";
315         $text .= $cur_char;
316         $quote_next = 0;
317
318       } elsif ($cur_char eq '\\') {
319         $quote_next = 1;
320
321       } elsif ($cur_char eq $inside_string) {
322         undef($inside_string);
323
324       } else {
325         $text .= $cur_char;
326
327       }
328     }
329     $pos++;
330   }
331 }
332
333 sub scanfile {
334   my $file = shift;
335   my $dont_include_subs = shift;
336   my $scanned_files = shift;
337
338   # sanitize file
339   $file =~ s=/+=/=g;
340
341   $scanned_files = {} unless ($scanned_files);
342   return if ($scanned_files->{$file});
343   $scanned_files->{$file} = 1;
344
345   if (!defined $cached{$file}) {
346
347     return unless (-f "$file");
348
349     my $fh = new FileHandle;
350     open $fh, "$file" or die "$! : $file";
351
352     my ($is_submit, $line_no, $sub_line_no) = (0, 0, 0);
353
354     while (<$fh>) {
355       last if /^\s*__END__/;
356
357       $line_no++;
358
359       # is this another file
360       if (/require\s+\W.*\.pl/) {
361         my $newfile = $&;
362         $newfile =~ s/require\s+\W//;
363         $newfile =~ s|bin/mozilla||;
364          $cached{$file}{scan}{"$bindir/$newfile"} = 1;
365       } elsif (/use\s+SL::([\w:]*)/) {
366         my $module =  $1;
367         $module    =~ s|::|/|g;
368         $cached{$file}{scannosubs}{"../../SL/${module}.pm"} = 1;
369       }
370
371       # is this a template call?
372       if (/(?:parse_html_template2?|render)\s*\(\s*[\"\']([\w\/]+)\s*[\"\']/) {
373         my $newfile = "$basedir/templates/webpages/$1.html";
374         if (/parse_html_template2/) {
375           print "E: " . strip_base($file) . " is still using 'parse_html_template2' for " . strip_base($newfile) . ".\n";
376         }
377         if (-f $newfile) {
378            $cached{$file}{scanh}{$newfile} = 1;
379           print "." if $opt_v;
380         } elsif ($opt_c) {
381           print "W: missing HTML template: " . strip_base($newfile) . " (referenced from " . strip_base($file) . ")\n";
382         }
383       }
384
385       my $rc = 1;
386
387       while ($rc) {
388         if (/Locale/) {
389           unless (/^use /) {
390             my ($null, $country) = split /,/;
391             $country =~ s/^ +[\"\']//;
392             $country =~ s/[\"\'].*//;
393           }
394         }
395
396         my $postmatch = "";
397
398         # is it a submit button before $locale->
399         if (/$submitsearch/) {
400           $postmatch = "$'";
401           if ($` !~ /locale->text/) {
402             $is_submit   = 1;
403             $sub_line_no = $line_no;
404           }
405         }
406
407         my ($found) = / (?: locale->text | \b t8 ) \b .*? \(/x;
408         $postmatch = "$'";
409
410         if ($found) {
411           my $string;
412           ($string, $_) = extract_text_between_parenthesis($fh, $postmatch);
413           $postmatch = $_;
414
415           # if there is no $ in the string record it
416           unless (($string =~ /\$\D.*/) || ("" eq $string)) {
417
418             # this guarantees one instance of string
419             $cached{$file}{locale}{$string} = 1;
420
421             # this one is for all the locales
422             $cached{$file}{all}{$string} = 1;
423
424             # is it a submit button before $locale->
425             if ($is_submit) {
426               $cached{$file}{submit}{$string} = 1;
427             }
428           }
429         } elsif ($postmatch =~ />/) {
430           $is_submit = 0;
431         }
432
433         # exit loop if there are no more locales on this line
434         ($rc) = ($postmatch =~ /locale->text/);
435
436         if (   ($postmatch =~ />/)
437             || (!$found && ($sub_line_no != $line_no) && />/)) {
438           $is_submit = 0;
439         }
440       }
441     }
442
443     close($fh);
444
445   }
446
447   map { $alllocales{$_} = 1 }   keys %{$cached{$file}{all}};
448   map { $locale{$_} = 1 }       keys %{$cached{$file}{locale}};
449   map { $submit{$_} = 1 }       keys %{$cached{$file}{submit}};
450   map { &scanfile($_, 0, $scanned_files) } keys %{$cached{$file}{scan}};
451   map { &scanfile($_, 1, $scanned_files) } keys %{$cached{$file}{scannosubs}};
452   map { &scanhtmlfile($_)  }    keys %{$cached{$file}{scanh}};
453
454   @referenced_html_files{keys %{$cached{$file}{scanh}}} = (1) x scalar keys %{$cached{$file}{scanh}};
455 }
456
457 sub scanmenu {
458   my $file = shift;
459
460   my $fh = new FileHandle;
461   open $fh, "$file" or die "$! : $file";
462
463   my @a = grep m/^\[/, <$fh>;
464   close($fh);
465
466   # strip []
467   grep { s/(\[|\])//g } @a;
468
469   foreach my $item (@a) {
470     my @b = split /--/, $item;
471     foreach my $string (@b) {
472       chomp $string;
473       $locale{$string}     = 1;
474       $alllocales{$string} = 1;
475     }
476   }
477
478 }
479
480 sub unescape_template_string {
481   my $in =  "$_[0]";
482   $in    =~ s/\\(.)/$1/g;
483   return $in;
484 }
485
486 sub scanhtmlfile {
487   local *IN;
488
489   my $file = shift;
490
491   if (!defined $cached{$file}) {
492     my %plugins = ( 'loaded' => { }, 'needed' => { } );
493
494     open(IN, $file) || die $file;
495
496     my $copying  = 0;
497     my $issubmit = 0;
498     my $text     = "";
499     while (my $line = <IN>) {
500       chomp($line);
501
502       while ($line =~ m/\[\%[^\w]*use[^\w]+(\w+)[^\w]*?\%\]/gi) {
503         $plugins{loaded}->{$1} = 1;
504       }
505
506       while ($line =~ m/\[\%[^\w]*(\w+)\.\w+\(/g) {
507         my $plugin = $1;
508         $plugins{needed}->{$plugin} = 1 if (first { $_ eq $plugin } qw(HTML LxERP JavaScript MultiColumnIterator L));
509       }
510
511       while ($line =~ m/(?:             # Start von Variante 1: LxERP.t8('...'); ohne darumliegende [% ... %]-Tags
512                           (LxERP\.t8)\( #   LxERP.t8(                             ::Parameter $1::
513                           ([\'\"])      #   Anfang des zu übersetzenden Strings   ::Parameter $2::
514                           (.*?)         #   Der zu übersetzende String            ::Parameter $3::
515                           (?<!\\)\2     #   Ende des zu übersetzenden Strings
516                         |               # Start von Variante 2: [% '...' | $T8 %]
517                           \[\%          #   Template-Start-Tag
518                           [\-~#]?       #   Whitespace-Unterdrückung
519                           \s*           #   Optional beliebig viele Whitespace
520                           ([\'\"])      #   Anfang des zu übersetzenden Strings   ::Parameter $4::
521                           (.*?)         #   Der zu übersetzende String            ::Parameter $5::
522                           (?<!\\)\4     #   Ende des zu übersetzenden Strings
523                           \s*\|\s*      #   Pipe-Zeichen mit optionalen Whitespace davor und danach
524                           (\$T8)        #   Filteraufruf                          ::Parameter $6::
525                           .*?           #   Optionale Argumente für den Filter
526                           \s*           #   Whitespaces
527                           [\-~#]?       #   Whitespace-Unterdrückung
528                           \%\]          #   Template-Ende-Tag
529                         )
530                        /ix) {
531         my $module = $1 || $6;
532         my $string = $3 || $5;
533         print "Found filter >>>$string<<<\n" if $debug;
534         substr $line, $LAST_MATCH_START[1], $LAST_MATCH_END[0] - $LAST_MATCH_START[0], '';
535
536         $string                         = unescape_template_string($string);
537         $cached{$file}{all}{$string}    = 1;
538         $cached{$file}{html}{$string}   = 1;
539         $cached{$file}{submit}{$string} = 1 if $PREMATCH =~ /$submitsearch/;
540         $plugins{needed}->{T8}          = 1 if $module eq '$T8';
541         $plugins{needed}->{LxERP}       = 1 if $module eq 'LxERP.t8';
542       }
543
544       while ($line =~ m/\[\%          # Template-Start-Tag
545                         [\-~#]?       # Whitespace-Unterdrückung
546                         \s*           # Optional beliebig viele Whitespace
547                         (?:           # Die erkannten Template-Direktiven
548                           PROCESS
549                         |
550                           INCLUDE
551                         )
552                         \s+           # Mindestens ein Whitespace
553                         [\'\"]?       # Anfang des Dateinamens
554                         ([^\s]+)      # Beliebig viele Nicht-Whitespaces -- Dateiname
555                         \.html        # Endung ".html", ansonsten kann es der Name eines Blocks sein
556                        /ix) {
557         my $new_file_name = "$basedir/templates/webpages/$1.html";
558         $cached{$file}{scanh}{$new_file_name} = 1;
559         substr $line, $LAST_MATCH_START[1], $LAST_MATCH_END[0] - $LAST_MATCH_START[0], '';
560       }
561     }
562
563     close(IN);
564
565     foreach my $plugin (keys %{ $plugins{needed} }) {
566       next if ($plugins{loaded}->{$plugin});
567       print "E: " . strip_base($file) . " requires the Template plugin '$plugin', but is not loaded with '[\% USE $plugin \%]'.\n";
568     }
569   }
570
571   # copy back into global arrays
572   map { $alllocales{$_} = 1 } keys %{$cached{$file}{all}};
573   map { $locale{$_} = 1 }     keys %{$cached{$file}{html}};
574   map { $submit{$_} = 1 }     keys %{$cached{$file}{submit}};
575
576   map { scanhtmlfile($_)  }   keys %{$cached{$file}{scanh}};
577
578   @referenced_html_files{keys %{$cached{$file}{scanh}}} = (1) x scalar keys %{$cached{$file}{scanh}};
579 }
580
581 sub search_unused_htmlfiles {
582   my @unscanned_dirs = ('../../templates/webpages');
583
584   while (scalar @unscanned_dirs) {
585     my $dir = shift @unscanned_dirs;
586
587     foreach my $entry (<$dir/*>) {
588       if (-d $entry) {
589         push @unscanned_dirs, $entry;
590
591       } elsif (($entry =~ /_master.html$/) && -f $entry && !$referenced_html_files{$entry}) {
592         print "W: unused HTML template: " . strip_base($entry) . "\n";
593
594       }
595     }
596   }
597 }
598
599 sub strip_base {
600   my $s =  "$_[0]";             # Create a copy of the string.
601
602   $s    =~ s|^../../||;
603   $s    =~ s|templates/webpages/||;
604
605   return $s;
606 }
607
608 sub _single_quote {
609   my $val = shift;
610   $val =~ s/(\'|\\$)/\\$1/g;
611   return  "'" . $val .  "'";
612 }
613
614 sub _print_line {
615   my $key      = _single_quote(shift);
616   my $text     = _single_quote(shift);
617   my %params   = @_;
618   my $template = $params{template} || qq|  %-29s => %s,\n|;
619   my $fh       = $params{fh}       || croak 'need filehandle in _print_line';
620
621   print $fh sprintf $template, $key, $text;
622 }
623
624 sub generate_file {
625   my %params = @_;
626
627   my $file      = $params{file}   || croak 'need filename in generate_file';
628   my $header    = $params{header};
629   my $lines     = $params{data_sub};
630   my $data_name = $params{data_name};
631   my @delim     = split //, ($params{delim} || '{}');
632
633   open my $fh, '>:encoding(utf8)', $file or die "$! : $file";
634
635   $charset =~ s/\r?\n//g;
636   my $emacs_charset = lc $charset;
637
638   print $fh "#!/usr/bin/perl\n# -*- coding: $emacs_charset; -*-\n# vim: fenc=$charset\n\nuse utf8;\n\n";
639   print $fh $header, "\n" if $header;
640   print $fh "$data_name = $delim[0]\n" if $data_name;
641
642   $lines->(fh => $fh);
643
644   print $fh qq|$delim[1];\n\n1;\n|;
645   close $fh;
646 }
647
648 sub slurp {
649   my $file = shift;
650   do { local ( @ARGV, $/ ) = $file; <> }
651 }
652
653 __END__
654
655 =head1 NAME
656
657 locales.pl - Collect strings for translation in Lx-Office
658
659 =head1 SYNOPSIS
660
661 locales.pl [options] lang_code
662
663  Options:
664   -n, --no-custom-files  Do not process files whose name contains "_"
665   -c, --check-files      Run extended checks on HTML files
666   -v, --verbose          Be more verbose
667   -h, --help             Show this help
668
669 =head1 OPTIONS
670
671 =over 8
672
673 =item B<-n>, B<--no-custom-files>
674
675 Do not process files whose name contains "_", e.g. "custom_io.pl".
676
677 =item B<-c>, B<--check-files>
678
679 Run extended checks on the usage of templates. This can be used to
680 discover HTML templates that are never used as well as the usage of
681 non-existing HTML templates.
682
683 =item B<-v>, B<--verbose>
684
685 Be more verbose.
686
687 =back
688
689 =head1 DESCRIPTION
690
691 This script collects strings from Perl files, the menu.ini file and
692 HTML templates and puts them into the file "all" for translation.
693
694 =cut