Group
Extension

Text-PO/lib/Text/PO/Gettext.pm

##----------------------------------------------------------------------------
## PO Files Manipulation - ~/lib/Text/PO/Gettext.pm
## Version v0.3.1
## Copyright(c) 2023 DEGUEST Pte. Ltd.
## Author: Jacques Deguest <jack@deguest.jp>
## Created 2021/07/12
## Modified 2023/10/31
## All rights reserved
## 
## This program is free software; you can redistribute  it  and/or  modify  it
## under the same terms as Perl itself.
##----------------------------------------------------------------------------
package Text::PO::Gettext;
BEGIN
{
    use strict;
    use warnings;
    use warnings::register;
    use parent qw( Module::Generic );
    use vars qw( $VERSION $L10N $DOMAIN_RE $LOCALE_RE );
    use I18N::Langinfo qw( langinfo );
    use POSIX ();
    use Text::PO;
    # l10n_id => lang => string => local string
    our $L10N = {};
    our $DOMAIN_RE = qr/^[a-z]+(\.[a-zA-Z0-9\_\-]+)*$/;
    our $LOCALE_RE = qr/^
        (?<locale>
            (?<locale_lang>
                [a-z]{2}
            )
            (?:
                [_-](?<locale_country>[A-Z]{2})
            )?
            (?:\.(?<locale_encoding>[\w-]+))?
        )
    $/x;
    our $VERSION = 'v0.3.1';
};

use strict;
use warnings;

sub init
{
    my $self = shift( @_ );
    $self->{category} = 'LC_MESSAGES';
    $self->{domain} = undef;
    # We also try LANGUAGE because GNU gettext actually only recognise LANGUAGE
    # For example: LANGUAGE=fr_FR.utf-8 TEXTDOMAINDIR=./t gettext -d "com.example.api" -s "Bad Request"
    $self->{locale} = $ENV{LANG} || $ENV{LANGUAGE};
    $self->{path}   = undef;
    $self->{plural} = [];
    $self->{use_json} = 1;
    $self->{_init_strict_use_sub} = 1;
    $self->{_init_params_order} = [qw( category path domain locale plural use_json )];
    $self->SUPER::init( @_ ) || return( $self->pass_error );
    if( !defined( $self->{path} ) || !length( $self->{path} ) )
    {
        return( $self->error( "No directory path was provided for localisation" ) );
    }
    $self->textdomain( $self->{domain} ) || return( $self->pass_error );
    return( $self );
}

sub addItem
{
    my $self = shift( @_ );
    my( $locale, $key, $value ) = @_;
    my $hash = $self->getDomainHash();
    return( $self->error( "No locale was provided." ) ) if( !defined( $locale ) || !length( $locale ) );
    return( $self->error( "No msgid was provided." ) ) if( !defined( $key ) || !length( $key ) );
    $locale = $self->locale_unix( $locale );
    if( !$self->isSupportedLanguage( $locale ) )
    {
        return( $self->error( "Language requested \"${locale}\" to add item is not supported." ) );
    }
    $hash->{ $locale }->{ $key } = { msgid => $key, msgstr => $value };
    return( $hash->{ $locale }->{ $key } );
}

sub category { return( shift->_set_get_scalar_as_object( 'category', @_ ) ); }

sub charset { return( shift->_get_po->charset ); }

sub contentEncoding { return( shift->_get_po->content_encoding ); }

sub contentType { return( shift->_get_po->content_type ); }

sub currentLang { return( shift->_get_po->current_lang ); }

sub dgettext { return( shift->dngettext( @_ ) ); }

sub dngettext
{
    my $self = shift( @_ );
    my $opts = {};
    $opts = pop( @_ ) if( ref( $_[-1] ) eq 'HASH' );
    my( $domain, $msgid, $msgidPlural, $count ) = @_;
    my $default;
    my $index;
    if( !defined( $count ) || $count !~ /^\d+$/ )
    {
        $default = $msgidPlural || $msgid;
    }
    if( !exists( $opts->{locale} ) || !length( $opts->{locale} ) )
    {
        $opts->{locale} = $self->locale;
    }
    my $hash = $self->getDomainHash({ domain => $domain });
    my $plural = $self->plural;
    if( !exists( $hash->{ $opts->{locale} } ) )
    {
        warn( "No locale \"$opts->{locale}\" found for the domain \"${domain}\".\n" ) if( $self->_is_warnings_enabled( 'Text::PO' ) );
        return( Text::PO::String->new( $default ) );
    }
    my $l10n = $hash->{ $opts->{locale} };
    my $dict = $l10n->{ $msgid };
    if( $dict )
    {
        if( $plural->length == 0 )
        {
            $plural = $self->getPlural();
        }
        if( ref( $dict->{msgstr} ) eq 'ARRAY' )
        {
            if( $self->_is_number( $count ) &&
                int( $plural->[0] ) > 0 )
            {
                no warnings 'once';
                my $n = $count;
                my $expr = $plural->[1];
                $expr =~ s/(?:^|\b)(?<!\$)(n)(?:\b|$)/\$$1/g;
                $index = eval( $expr );
                $index = int( $index );
            }
            else
            {
                $index = 0;
            }
            # return( join( '', @{$dict->{msgstr}->[ $index ]} ) || $default );
            my $locale_str = join( '', @{$dict->{msgstr}->[ $index ]} );
            return( Text::PO::String->new( $locale_str => $opts->{locale} ) ) if( length( "$locale_str" ) );
            return( Text::PO::String->new( $default ) );
        }
        return( $dict->{msgstr} || $default );
    }
    else
    {
        warn( "No dictionary was found for msgid \"${msgid}\" and domain \"${domain}\"" ) if( $self->_is_warnings_enabled( 'Text::PO' ) );
    }
    return( $default );
}

sub domain
{
    my $self = shift( @_ );
    if( @_ )
    {
        my $v = shift( @_ );
        if( !$v )
        {
            return( $self->error( "No domain was provided." ) );
        }
        elsif( $v !~ /^$DOMAIN_RE$/ )
        {
            return( $self->error( "Domain provided \"$v\" contains illegal characters." ) );
        }
        my $caller = [caller(1)]->[3];
        # We do not call textdomain upon init, because we need both domain and locale to be set first
        # textdomain() is called directly in init()
        $self->textdomain( $v ) unless( $caller eq 'Module::Generic::init' );
        $self->{domain} = $v;
    }
    return( $self->_set_get_scalar_as_object( 'domain' ) );
}

sub exists
{
    my $self = shift( @_ );
    my $lang = shift( @_ );
    if( !defined( $lang ) )
    {
        return( $self->error( "No language to check for existence was provided." ) );
    }
    elsif( !length( $lang ) )
    {
        return( $self->error( "Language provided to check for existence is null." ) );
    }
    elsif( $lang !~ /^$LOCALE_RE$/ )
    {
        return( $self->error( "Unsupported locale format \"${lang}\"." ) );
    }
    $lang = $self->locale_unix( $lang );
    my $hash = $self->getDomainHash();
    return( exists( $hash->{ $lang } ) );
}

sub fetchLocale
{
    my $self = shift( @_ );
    my $key  = shift( @_ );
    my $hash = $self->getDomainHash();
    my $spans = [];
    # Browsing through each available locale language
    # Make it predictable using sort()
    foreach my $k ( sort( keys( %$hash ) ) )
    {
        my $locWeb = $self->locale_web( $k );
        push( @$spans, "<span lang=\"${locWeb}\">" . $self->dngettext( $self->domain, $key, { locale => $k }) . '</span>' );
    }
    return( $self->new_array( $spans ) );
}

sub getDataPath { return( $ENV{TEXTDOMAINDIR} ); }

sub getDaysLong
{
    my $self = shift( @_ );
    my $opts = $self->_get_args_as_hash( @_ );
    my $ref  = $self->_get_days( $self->locale );
    my $days = $ref->[1];
    if( $opts->{monday_first} )
    {
        # Move Sunday at the end
        push( @$days, shift( @$days ) );
    }
    return( $days );
}

sub getDaysShort
{
    my $self = shift( @_ );
    my $opts = $self->_get_args_as_hash( @_ );
    my $ref  = $self->_get_days( $self->locale );
    my $days = $ref->[0];
    if( $opts->{monday_first} )
    {
        # Move Sunday at the end
        push( @$days, shift( @$days ) );
    }
    return( $days );
}

sub getDomainHash
{
    my $self = shift( @_ );
    my $opts = $self->_get_args_as_hash( @_ );
    $opts->{domain} //= $self->domain;
    
    my $hash = $L10N;
    if( !exists( $hash->{ $opts->{domain} } ) )
    {
        retrn( $self->error( "No locale data for domain \"$opts->{domain}\"." ) );
    }
    my $l10n = $hash->{ $opts->{domain} };
    if( exists( $opts->{locale} ) && 
        defined( $opts->{locale} ) )
    {
        $opts->{locale} = $self->locale_unix( $opts->{locale} );
        if( length( $opts->{locale} ) == 0 )
        {
            return( $self->error( "Locale was provided, but is empty." ) );
        }
        return( $l10n->{ $opts->{locale} } );
    }
    return( $l10n );
}

sub getLangDataPath { return( $ENV{TEXTLOCALEDIR} ); }

sub getLanguageDict
{
    my $self = shift( @_ );
    my $lang = shift( @_ ) || return( $self->error( "Language provided, to get its dictionary, is undefined or null." ) );
    if( $lang !~ /^$LOCALE_RE$/ )
    {
        return( $self->error( "Locale provided (${lang}) is in an unsupported format." ) );
    }
    $lang = $self->locale_unix( $lang );
    
    if( !$self->isSupportedLanguage( $lang ) )
    {
        return( $self->error( "Language provided (${lang}), to get its dictionary, is unsupported." ) );
    }
    my $hash = $self->getDomainHash();
    if( !exists( $hash->{ $lang } ) )
    {
        return( $self->error( "Language provided (${lang}), to get its dictionary, could not be found. This is weird. Most likely a configuration mistake." ) );
    }
    return( $hash->{ $lang } );
}

sub getLocale { return( shift->locale ); }

sub getLocales
{
    my $self = shift( @_ );
    my $key  = shift( @_ ) || return( $self->error( "No text provided to get its localised equivalent" ) );
    my $res = $self->fetchLocale( $key ) || return( $self->pass_error );
    if( scalar( @$res ) > 0 )
    {
        return( join( "\n", @$res ) );
    }
    else
    {
        return( $key );
    }
}

sub getLocalesf
{
    my $self = shift( @_ );
    my $key  = shift( @_ ) || return( $self->error( "No text provided to get its localised equivalent" ) );
    my $res = $self->fetchLocale( $key ) || return( $self->pass_error );
    if( scalar( @$res ) > 0 )
    {
        for( my $i = 0; $i < scalar( @$res ); $i++ )
        {
            $res->[$i] = sprintf( $res->[$i], @_ );
        }
        return( join( "\n", @$res ) );
    }
    else
    {
        return( sprintf( $key, @_ ) );
    }
}

sub getMetaKeys
{
    my $self = shift( @_ );
    my $hash = $self->getDomainHash({ locale => $self->locale });
    my $po = $hash->{_po} || return( $self->error( "Unable to get the po object in the locale data hash" ) );
    return( $po->meta_keys );
}

sub getMetaValue
{
    my $self = shift( @_ );
    my $field = shift( @_ ) || return( $self->error( "No meta field provided to get its value." ) );
    my $hash = $self->getDomainHash({ locale => $self->locale });
    my $po = $hash->{_po} || return( $self->error( "Unable to get the po object in the locale data hash" ) );
    return( $po->meta( $field ) );
}

sub getMonthsLong
{
    my $self = shift( @_ );
    my $ref  = $self->_get_months( $self->locale );
    return( $ref->[1] );
}

sub getMonthsShort
{
    my $self = shift( @_ );
    my $ref  = $self->_get_months( $self->locale );
    return( $ref->[0] );
}

sub getNumericDict
{
    my $self = shift( @_ );
    my $ref  = $self->_get_numeric_dict( $self->locale );
    return( $ref->[0] );
}

sub getNumericPosixDict
{
    my $self = shift( @_ );
    my $ref  = $self->_get_numeric_dict( $self->locale );
    return( $ref->[1] );
}

sub getPlural
{
    my $self = shift( @_ );
    my $po = $self->_get_po || return( $self->error( "Unable to get the po object in the locale data hash" ) );
    return( $po->plural );
}

sub getText
{
    my $self = shift( @_ );
    my( $key, $lang ) = @_;
    return( $self->error( "No text to get its localised equivalent was provided." ) ) if( !defined( $key ) || !length( $key ) );
    return( $self->dngettext( $self->domain, $key, { locale => $lang }) );
}

sub getTextf
{
    my $self = shift( @_ );
    my $opts = {};
    $opts = pop( @_ ) if( ref( $_[-1] ) eq 'HASH' );
    $opts->{lang} = $self->locale || $self->currentLang();
    my $key  = shift( @_ );
    my $text = $self->getText( $key, $opts->{lang} );
    return( sprintf( $text, @_ ) );
}

sub gettext
{
    my $self = shift( @_ );
    return( $self->dngettext( $self->domain, shift( @_ ) ) );
}

sub gettextf { return( shift->getTextf( @_ ) ); }

sub isSupportedLanguage
{
    my $self = shift( @_ );
    my $lang = shift( @_ ) || return(0);
    $lang = $self->locale_unix( $lang );
    my $dom  = $self->domain;
    return( $self->error( "No domain \"$dom\" set!" ) ) if( !CORE::exists( $L10N->{ $dom } ) );
    my $dict = $L10N->{ $dom };
    if( CORE::exists( $dict->{ $lang } ) )
    {
        return(1);
    }
    else
    {
        return(0);
    }
}

sub language { return( shift->_get_po->language ); }

sub languageTeam { return( shift->_get_po->language_team ); }

sub lastTranslator { return( shift->_get_po->last_translator ); }

sub mimeVersion { return( shift->_get_po->mime_version ); }

sub locale
{
    my $self = shift( @_ );
    if( @_ )
    {
        my $v = shift( @_ );
        if( !defined( $v ) || !length( $v ) )
        {
            return( $self->error( "No language was set." ) );
        }
        elsif( $v =~ /^$LOCALE_RE$/ )
        {
            $v = join( '_', $+{locale_lang}, ( $+{locale_country} ? $+{locale_country} : () ) );
            $v .= '.' . $+{locale_encoding} if( $+{locale_encoding} );
        }
        else
        {
            return( $self->error( "Language provided (\"$v\") is in an unsupported format. Use something like \"en_GB\", \"en-GB\" or simply \"en\" or even \"en_GB.utf-8\"." ) );
        }
        return( $self->error( "No domain is set or it has disappeared!" ) ) if( !$self->{domain} );
        $self->{locale} = $v;
        my $caller = [caller(1)]->[3];
        # We do not call textdomain upon init, because we need both domain and locale to be set first
        # textdomain() is called directly in init()
        $self->textdomain( $self->{domain} ) unless( $caller eq 'Module::Generic::init' );
    }
    return( $self->_set_get_scalar_as_object( 'locale' ) );
}

sub locale_unix
{
    my $self = shift( @_ );
    my $loc  = shift( @_ ) || $self->locale;
    # Only once
    if( $loc =~ /^$LOCALE_RE$/ )
    {
        $loc = join( '_', $+{locale_lang}, ( $+{locale_country} ? $+{locale_country} : () ) );
        $loc .= '.' . $+{locale_encoding} if( $+{locale_encoding} );
    }
    return( $loc );
}

sub locale_web
{
    my $self = shift( @_ );
    my $loc  = shift( @_ ) || $self->locale;
    # Only once
    if( $loc =~ /^$LOCALE_RE$/ )
    {
        $loc = join( '-', $+{locale_lang}, ( $+{locale_country} ? $+{locale_country} : () ) );
        $loc .= '.' . $+{locale_encoding} if( $+{locale_encoding} );
    }
    return( $loc );
}

sub ngettext
{
    my $self = shift( @_ );
    my( $msgid, $msgidPlural, $count ) = @_;
    return( $self->dngettext( $self->domain, $msgid, $msgidPlural, $count ) );
}

sub path { return( shift->_set_get_file( 'path', @_ ) ); }

sub plural
{
    my $self = shift( @_ );
    if( @_ )
    {
        return( $self->_set_get_array_as_object( 'plural', @_ ) );
    }
    else
    {
        if( !scalar( @{$self->{plural}} ) )
        {
            $self->{plural} = $self->getPlural();
        }
        return( $self->_set_get_array_as_object( 'plural' ) );
    }
}

sub pluralForms { return( shift->_get_po->plural_forms ); }

sub po_object { return( shift->_get_po ); }

sub poRevisionDate { return( shift->_get_po->po_revision_date ); }

sub potCreationDate { return( shift->_get_po->pot_creation_date ); }

sub projectIdVersion { return( shift->_get_po->project_id_version ); }

sub reportBugsTo { return( shift->_get_po->report_bugs_to ); }

sub textdomain
{
    my $self = shift( @_ );
    my $dom  = shift( @_ ) || return( $self->error( "No domain was provided." ) );
    my $base = $self->path;
    my $lang = $self->locale_unix;
    my $path_po   = $base->join( $base, $lang, ( $self->category ? $self->category : () ), "${dom}.po" );
    my $path_json = $base->join( $base, $lang, ( $self->category ? $self->category : () ), "${dom}.json" );
    my $path_mo   = $base->join( $base, $lang, ( $self->category ? $self->category : () ), "${dom}.mo" );
    my $file;
    my $po;
    
    
    if( $self->use_json && $path_json->exists )
    {
        $file = $path_json;
        $po = Text::PO->new( domain => $dom, use_json => 1, debug => $self->debug ) ||
            return( $self->pass_error( Text::PO->error ) );
        $po->parse2object( $file ) ||
            return( $self->pass_error( $po->error ) );
    }
    elsif( $path_po->exists )
    {
        $file = $path_po;
        $po = Text::PO->new( domain => $dom, debug => $self->debug ) ||
            return( $self->pass_error( Text::PO->error ) );
        $po->parse( $file ) ||
            return( $self->pass_error( $po->error ) );
    }
    elsif( $path_mo->exists )
    {
        $file = $path_mo;
        my $mo = Text::PO::MO->new( $file, { domain => $dom, debug => $self->debug }) ||
            return( $self->pass_error( Text::PO::MO->error ) );
        $po = $mo->as_object ||
            return( $self->pass_error( $po->error ) );
    }
    else
    {
        return( $self->error( "No data file could be found for \"$dom\" for either json, po, or mo file." ) );
    }
    $L10N->{ $dom } = {} if( ref( $L10N->{ $dom } ) ne 'HASH' );
    my $dict = $L10N->{ $dom }->{ $lang } = {} if( ref( $L10N->{ $dom }->{ $lang } ) ne 'HASH' );
    $dict->{_po} = $po;
    $po->elements->foreach(sub
    {
        my $ref = shift( @_ );
        $dict->{ $ref->{msgid} } = $ref;
    });
    return( $self );
}

sub use_json { return( shift->_set_get_boolean( 'use_json', @_ ) ); }

sub _get_days
{
    my $self = shift( @_ );
    my $locale = shift( @_ );
    my $oldlocale = POSIX::setlocale( &POSIX::LC_ALL );
    my $short = $self->new_array;
    my $long  = $self->new_array;

    POSIX::setlocale( &POSIX::LC_ALL, $locale ) if( defined( $locale ) );

    for (my $i = 1; $i <= 7; $i++)
    {
        # my $const = "I18N::Langinfo::ABDAY_${i}";
        my $const = I18N::Langinfo->can( "ABDAY_${i}" );
        # $short->[$i-1] = langinfo( &$const );
        $short->[$i-1] = langinfo( $const->() );
    }
    for (my $i = 1; $i <= 7; $i++)
    {
        # my $const = "I18N::Langinfo::DAY_${i}";
        my $const = I18N::Langinfo->can( "DAY_${i}" );
        # $long->[$i-1] = langinfo( &$const );
        $long->[$i-1] = langinfo( $const->() );
    }

    POSIX::setlocale( &POSIX::LC_ALL, $oldlocale) if( defined( $locale ) );

    return( [ $short, $long ] );
}

sub _get_months
{
    my $self   = shift( @_ );
    my $locale = shift( @_ );
    my $oldlocale = POSIX::setlocale( &POSIX::LC_ALL );
    my $short = $self->new_array;
    my $long  = $self->new_array;

    POSIX::setlocale( &POSIX::LC_ALL, $locale ) if( defined( $locale ) );

    for( my $i = 1; $i <= 12; $i++ )
    {
        # my $const = "I18N::Langinfo::ABMON_${i}";
        # $short->[$i-1] = langinfo( &$const );
        my $const = I18N::Langinfo->can( "ABMON_${i}" );
        $short->[$i-1] = langinfo( $const->() );
    }
    for( my $i = 1; $i <= 12; $i++ )
    {
        # my $const = "I18N::Langinfo::MON_${i}";
        # $long->[$i-1] = langinfo( &$const );
        my $const = I18N::Langinfo->can( "MON_${i}" );
        $long->[$i-1] = langinfo( $const->() );
    }

    POSIX::setlocale( &POSIX::LC_ALL, $oldlocale) if( defined( $locale ) );

    return( [ $short, $long ] );
}

sub _get_numeric_dict
{
    my $self   = shift( @_ );
    my $locale = shift( @_ );
    my $oldlocale = POSIX::setlocale( &POSIX::LC_ALL );
    POSIX::setlocale( &POSIX::LC_ALL, $locale) if( defined( $locale ) );
    my $lconv = POSIX::localeconv();
    POSIX::setlocale( &POSIX::LC_ALL, $oldlocale) if( defined( $locale ) );
    my $def = $self->new_hash;
    @$def{qw( currency decimal int_currency negative_sign thousand precision )} = 
    @$lconv{qw( currency_symbol decimal_point int_curr_symbol negative_sign thousands_sep frac_digits )};
    use utf8;
    $def->{currency} = '€' if( CORE::exists( $def->{currency} ) && defined( $def->{currency} ) && $def->{currency} eq 'EUR' );
    $lconv->{currency_symbol} = '€' if( CORE::exists( $lconv->{currency_symbol} ) && defined( $lconv->{currency_symbol} ) && $lconv->{currency_symbol} eq 'EUR' );
    $lconv->{grouping} = unpack( "C*", $lconv->{grouping} ) if( CORE::exists( $lconv->{grouping} ) && defined( $lconv->{grouping} ) );
    $lconv->{mon_grouping} = unpack( "C*", $lconv->{mon_grouping} ) if( CORE::exists( $lconv->{mon_grouping} ) && defined( $lconv->{mon_grouping} ) );
    $lconv = $self->new_hash( $lconv );
    return( [ $def, $lconv ] );
}

sub _get_po
{
    my $self = shift( @_ );
    my $hash = $self->getDomainHash({ locale => $self->locale });
    return( $hash->{_po} );
}

# NOTE: Text::PO::String class
{
    package
        Text::PO::String;
    BEGIN
    {
        use strict;
        use warnings;
        use parent qw( Module::Generic );
        use vars qw( $VERSION );
        our $VERSION = 'v0.1.0';
        use overload (
            '""'    => 'as_string',
            'bool'  => sub{1},
            fallback => 1,
        );
    };
    
    use strict;
    use warnings;
    
    sub init
    {
        my $self = shift( @_ );
        my $value = shift( @_ );
        my $locale = shift( @_ );
        $self->{locale} = $locale;
        $self->{value}  = $value;
        $self->SUPER::init( @_ );
        return( $self );
    }
    
    sub as_string { return( shift->value->scalar ); }
    
    sub locale { return( shift->_set_get_scalar_as_object( 'locale', @_ ) ); }

    sub value { return( shift->_set_get_scalar_as_object( 'value', @_ ) ); }
    
    sub TO_JSON { return( shift->as_string ); }
}

1;
# NOTE: POD
__END__

=encoding utf-8

=head1 NAME

Text::PO::Gettext - A GNU Gettext implementation

=head1 SYNOPSIS

    use Text::PO::Gettext;
    my $po = Text::PO::Gettext->new || die( Text::PO::Gettext->error, "\n" );
    my $po = Text::PO::Gettext->new({
        category => 'LC_MESSAGES',
        debug    => 3,
        domain   => "com.example.api",
        locale   => 'ja-JP',
        path     => "/home/joe/locale",
        use_json => 1,
    }) || die( Text::PO::Gettext->error, "\n" );

=head1 VERSION

    v0.3.1

=head1 DESCRIPTION

This module is used to access the data in either C<po>, C<mo> or C<json> file and provides various methods to access those data.

The conventional way to use GNU gettext is to set the global environment variable C<LANGUAGE> (not C<LANG> by the way. GNU gettext only uses C<LANGUAGE>), then set the L<POSIX/setlocale> to the language such as:

    use Locale::gettext;
    use POSIX ();
    POSIX::setlocale( &POSIX::LC_ALL, 'ja_JP' );
    my $d = Locale::gettext->domain( 'com.example.api' );

And then in your application, you would write a statement like:

    print $d->get( 'Hello!' );

Or possibly using direct access to the C function:

    use Locale::gettext;
    use POSIX ();
    POSIX::setlocale( &POSIX::LC_ALL, 'ja_JP' );
    textdomain( 'com.example.api' );

And then:

    print gettext( 'Hello!' );

See L<Locale::gettext> for more on this.

This works fine, but has the inconvenience that it uses the global C<LANGUAGE> environment variable and makes it less than subpar as to the necessary flexibility when using multiple domains and flipping back and forth among locales.

Thus comes a more straightforward object-oriented interface offered by this module.

You instantiate an object, passing the domain, the locale and the filesystem path where the locale data resides.

    my $po = Text::PO::Gettext->new(
        domain => 'com.example.api',
        locale => 'ja_JP',
        path   => '/some/where/locale'
    );
    print $po->gettext( 'Hello!' );

This will load into memory the locale data whether they are stored as C<.po>, C<.mo> or even C<.json> file, thus making calls to L</gettext> super fast since they are in memory.

More than one locale can be loaded, each with its own L<Text::PO::Gettext> object

This distribution comes with its Javascript library equivalent. See the C<share> folder alone with its own test units.

Also, there is a script in C<scripts> that can be used to transcode C<.po> or C<.mo> files into json format and vice versa.

Still, it is better to convert the original C<.po> files to json using the C<po.pl> utility that comes in this L<Text::PO> distribution since it would allow the standalone JavaScript library to read json-based po files. For example:

    ./po.pl --as-json --output /home/joe/www/locale/ja_JP/LC_MESSAGES/com.example.api.json ./ja_JP.po

This api supports locale that use hyphens or underscore in them such as C<en-GB> or C<en_GB>. You can use either, it will be converted internally.

=head1 CONSTRUCTOR

=head2 new

Takes the following options and returns a Gettext object.

=over 4

=item * C<category>

If I<category> is defined, such as C<LC_MESSAGES> (by default), it will be used when building the I<path>.

Other possible category values are: C<LC_CTYPE>, C<LC_NUMERIC>, C<LC_TIME>, C<LC_COLLATE>, C<LC_MONETARY>

See L<GNU documentation for more information|https://www.gnu.org/software/gettext/manual/html_node/Locale-Environment-Variables.html> and L<perllocale/"LOCALE CATEGORIES">

On the web, using the path is questionable.

See the L<GNU documentation|https://www.gnu.org/software/libc/manual/html_node/Using-gettextized-soft
ware.html> for more information on this.

=item * C<domain>

The portable object domain, such as C<com.example.api>

=item * C<locale>

The locale, such as C<ja_JP>, or C<en>, or it could even contain a dash instead of an underscore, such as C<en-GB>. Internally, though, this will be converted to underscore.

=item * C<path>

The uri path where the gettext localised data are.

This is used to form a path along with the locale string. For example, with a locale of C<ja_JP> and a domain of C<com/example.api>, if the path were C</locale>, the data po json data would be fetched from C</locale/
ja_JP/LC_MESSAGES/com.example.api.json>

=back

=head1 METHODS

=head2 addItem

This takes a C<locale>, a message id and its localised version and it will add this to the current dictionary for the current domain.

    $po->addItem( 'ja_JP', 'Hello!' => "今日は!" );

=head2 category

The category to use. This defaults to C<LC_MESSAGES>, but if you prefer you can nix its use by making it undefined, or empty:

    my $po = Text::PO::Gettext->new(
        category => '',
        domain => 'com.example.api',
        locale => 'ja_JP',
        path   => '/some/where/locale'
    );
    # Setting category to empty string will have the module get the po data 
    # under C</some/where/locale/ja_JP/com.example.api.json> for example.
    print $po->gettext( 'Hello!' );

=head2 charset

Returns a string containing the value of the charset encoding as defined in the C<Content-Type> header.

    $po->charset()

=head2 contentEncoding

Returns a string containing the value of the header C<Content-Encoding>.

    $po->contentEncoding();

=head2 contentType

Returns a string containing the value of the header C<Content-Type>.

    $po->contentType(); # text/plain; charset=utf-8

=head2 currentLang

Return the current globally used locale. This is the value found in environment variables C<LANGUAGE> or C<LANG>. Note that GNU gettext only recognises C<LANGUAGE>

and thus, this is different from the C<locale> set in the Gettext class object using </setLocale> or upon class object instantiation.

=head2 dgettext

Takes a domain and a message id and returns the equivalent localised string if any, otherwise the original message id.

    $po->dgettext( 'com.example.auth', 'Please enter your e-mail address' );
    # Assuming the locale currently set is ja_JP, this would return:
    # 電子メールアドレスをご入力下さい。

=head2 dngettext

Same as L</ngettext>, but takes also a domain as first argument. For example:

    $po->ngettext( 'com.example.auth', '%d comment awaiting moderation', '%d comments awaiting moderation', 12 );
    # Assuming the locale is ru_RU, this would return:
    # %d комментариев ожидают проверки

Note that as of version C<v0.5.0>, this returns a C<Text::PO::String>, which is lightweight and stringifies automatically. It provides the benefit of tagging the string with the locale attached to it.

Thus, in the example above, the resulting C<Text::PO::String> would have its method C<locale> value set to C<ru_RU>, and you could do:

    my $localised = $po->ngettext( 'com.example.auth', '%d comment awaiting moderation', '%d comments awaiting moderation', 12 );
    say "Locale for this string is: ", $localised->locale;

If no locale string was found, C<locale> would be undefined.

=head2 domain

Sets or gets the domain.

    $po->domain( 'com.example.api' );

By doing so, this will call L</textdomain> and load the associated data from file, if any are found.

=head2 exists

Provided with a locale, and this returns true if the locale exists in the current domain, or false otherwise.

=head2 fetchLocale

Given an original string (msgid), this returns an array of <span> html element each for one language and its related localised content. For example:

    my $array = $po->fetchLocale( "Hello!" );
    # Returns:
    <span lang="de-DE">Grüß Gott!</span>
    <span lang="fr-FR">Salut !</span>
    <span lang="ja-JP">今日は!</span>
    <span lang="ko-KR">안녕하세요!</span>

This is designed to be added to the html, and based on C<lang> attribute of the C<html> tag, and using the following css trick, this will automatically display the right localised data:

    [lang=de-DE] [lang=en-GB],
    [lang=de-DE] [lang=fr-FR],
    [lang=de-DE] [lang=ja-JP],
    [lang=de-DE] [lang=ko-KR],
    [lang=en-GB] [lang=de-DE],
    [lang=en-GB] [lang=fr-FR],
    [lang=en-GB] [lang=ja-JP],
    [lang=en-GB] [lang=ko-KR],
    [lang=fr-FR] [lang=de-DE],
    [lang=fr-FR] [lang=en-GB],
    [lang=fr-FR] [lang=ja-JP],
    [lang=fr-FR] [lang=ko-KR],
    [lang=ja-JP] [lang=de-DE],
    [lang=ja-JP] [lang=en-GB]
    [lang=ja-JP] [lang=fr-FR],
    [lang=ja-JP] [lang=ko-KR]
    {
        display: none !important;
        visibility: hidden !important;
    }

=head2 getDataPath

This takes no argument and will check for the environment variables C<TEXTDOMAINDIR>. If found, it will use this in lieu of the I<path> option used during object instantiation.

It returns the value found. This is just a helper method and does not affect the value of the I<path> property set during object instantiation.

=head2 getDaysLong

Returns an array reference containing the 7 days of the week in their long representation.

    my $ref = $po->getDaysLong();
    # Assuming the locale is fr_FR, this would yield
    print $ref->[0], "\n"; # dim.

=head2 getDaysShort

Returns an array reference containing the 7 days of the week in their short representation.

    my $ref = $po->getDaysShort();
    # Assuming the locale is fr_FR, this would yield
    print $ref->[0], "\n"; # dimanche

=head2 getDomainHash

This takes an optional hash of parameters and return the global hash dictionary used by this class to store the localised data.

    # Will use the default domain as set in po.domain
    my $data = $po->getDomainHash();
    # Explicitly specify another domain
    my $data = $po->getDomainHash( domain => "net.example.api" );
    # Specify a domain and a locale
    my $l10n = $po->getDomainHash( domain => "com.example.api", locale => "ja_JP" );

Possible options are:

=over 4

=item * C<domain> The domain for the data, such as C<com.example.api>

=item * C<locale> The locale to return the associated dictionary.

=back

=head2 getLangDataPath

Contrary to its JavaScript equivalent, this takes no parameter. It returns the value of the environment variable C<TEXTLOCALEDIR> if found.

This is used internally during object instantiation when the I<path> parameter is not provided.

=head2 getLanguageDict

Provided with a locale, such as C<ja_JP> and this will return the dictionary for the current domain and the given locale.

=head2 getLocale

Returns the locale set for the current object, such as C<fr_FR> or C<ja_JP>

Locale returned are always formatted for the server-side, which means having an underscore rather than an hyphen like in the web environment.

=head2 getLocales

Provided with a C<msgid> (i.e. an original text) and this will call L</fetchLocale> and return those C<span> tags as a string containing their respective localised content, joined by a new line

=head2 getLocalesf

This is similar to L</getLocale>, except that it does a sprintf internally before returning the resulting value.

=head2 getMetaKeys

Returns an array of the meta field names used.

=head2 getMetaValue

Provided with a meta field name and this returns its corresponding value.

=head2 getMonthsLong

Returns an array reference containing the 12 months in their long representation.

    my $ref = $po->getMonthsLong();
    # Assuming the locale is fr_FR, this would yield
    print $ref->[0], "\n"; # janvier

=head2 getMonthsShort

Returns an array reference containing the 12 months in their short representation.

    my $ref = $po->getMonthsShort();
    # Assuming the locale is fr_FR, this would yield
    print $ref->[0], "\n"; # janv.

=head2 getNumericDict

Returns an hash reference containing the following properties:

    my $ref = $po->getNumericDict();

=over 4

=item * C<currency> string

Contains the usual currency symbol, such as C<€>, or C<$>, or C<¥>

=item * C<decimal> string

Contains the character used to separate decimal. In English speaking countries, this would typically be a dot.

=item * C<int_currency> string

Contains the 3-letters international currency symbol, such as C<USD>, or C<EUR> or C<JPY>

=item * C<negative_sign> string

Contains the negative sign used for negative number

=item * C<precision> integer

An integer whose value represents the fractional precision allowed for monetary context.

For example, in Japanese, this value would be 0 while in many other countries, it would be 2.

=item * C<thousand> string

Contains the character used to group and separate thousands.

For example, in France, it would be a space, such as :

    1 000 000,00

While in English countries, including Japan, it would be a comma :

    1,000,000.00

=back

=head2 getNumericPosixDict

Returns the full hash reference returned by L<POSIX/lconv>. It contains the following properties:

Here the values shown as example are for the locale C<en_US>

=over 4

=item * C<currency_symbol> string

The local currency symbol: C<$>

=item * C<decimal_point> string

The decimal point character, except for currency values, cannot be an empty string: C<.>

=item * C<frac_digits> integer

The number of digits after the decimal point in the local style for currency value: 2

=item * C<grouping>

The sizes of the groups of digits, except for currency values. unpack( "C*", $grouping ) will give the number

=item * C<int_curr_symbol> string

The standardized international currency symbol: C<USD>

=item * C<int_frac_digits> integer

The number of digits after the decimal point in an international-style currency value: 2

=item * C<int_n_cs_precedes> integer

Same as n_cs_precedes, but for internationally formatted monetary quantities: 1

=item * C<int_n_sep_by_space> integer

Same as n_sep_by_space, but for internationally formatted monetary quantities: 1

=item * C<int_n_sign_posn> integer

Same as n_sign_posn, but for internationally formatted monetary quantities: 1

=item * C<int_p_cs_precedes> integer

Same as p_cs_precedes, but for internationally formatted monetary quantities: 1

=item * C<int_p_sep_by_space> integer

Same as p_sep_by_space, but for internationally formatted monetary quantities: 1

=item * C<int_p_sign_posn> integer

Same as p_sign_posn, but for internationally formatted monetary quantities: 1

=item * C<mon_decimal_point> string

The decimal point character for currency values: C<.>

=item * C<mon_grouping>

Like grouping but for currency values.

=item * C<mon_thousands_sep> string

The separator for digit groups in currency values: C<,>

=item * C<n_cs_precedes> integer

Like p_cs_precedes but for negative values: 1

=item * C<n_sep_by_space> integer

Like p_sep_by_space but for negative values: 0

=item * C<n_sign_posn> integer

Like p_sign_posn but for negative currency values: 1

=item * C<negative_sign> string

The character used to denote negative currency values, usually a minus sign: C<->

=item * C<p_cs_precedes> integer

1 if the currency symbol precedes the currency value for nonnegative values, 0 if it follows: 1

=item * C<p_sep_by_space> integer

1 if a space is inserted between the currency symbol and the currency value for nonnegative values, 0 otherwise: 0

=item * C<p_sign_posn> integer

The location of the positive_sign with respect to a nonnegative quantity and the currency_symbol, coded as follows:

    0    Parentheses around the entire string.
    1    Before the string.
    2    After the string.
    3    Just before currency_symbol.
    4    Just after currency_symbol.

=item * C<positive_sign> string

The character used to denote nonnegative currency values, usually the empty string

=item * C<thousands_sep> string

The separator between groups of digits before the decimal point, except for currency values: C<,>

=back

=head2 getPlural

Calls L<Text::PO/plural> and returns an array object (L<Module::Generic::Array>) with 2 elements.

See L<Text::PO/plural> for more details.

=head2 getText

Provided with an original string, and this will return its localised equivalent if it exists, or by default, it will return the original string.

=head2 getTextf

Provided with an original string, and this will get its localised equivalent that wil be used as a template for the sprintf function. The resulting formatted localised content will be returned.

=head2 gettext

Provided with a C<msgid> represented by a string, and this return a localised version of the string, if any is found and is translated, otherwise returns the C<msgid> that was provided.

    $po->gettext( "Hello" );
    # With locale of fr_FR, this would return "Bonjour"

See the global function L</_> for more information.

Note that as of version C<v0.5.0>, this returns a C<Text::PO::String>, which is lightweight and stringifies automatically. It provides the benefit of tagging the string with the locale attached to it.

Thus, in the example above, the resulting C<Text::PO::String> would have its method C<locale> value set to C<fr_FR>, and you could do:

    my $localised = $po->gettext( "Hello" );
    say "Locale for this string is: ", $localised->locale;

If no locale string was found, C<locale> would be undefined.

=head2 gettextf

This is an alias to L</getTextf>

=head2 isSupportedLanguage

Provided with a locale such as C<fr-FR> or C<ja_JP> no matter whether an underscore or a dash is used, and this will return true if the locale has already been loaded and thus is supported. False otherwise.

=head2 language

Returns a string containing the value of the header C<Language>.

    $po->language();

=head2 languageTeam

Returns a string containing the value of the header C<Language-Team>.

    $po->languageTeam();

=head2 lastTranslator

Returns a string containing the value of the header C<Last-Translator>.

    $po->lastTranslator();

=head2 locale

Returns the locale set in the object. if sets, this will trigger the (re)load of po data by calling L</textdomain>

=head2 locale_unix

Provided with a locale, such as C<en-GB> and this will return its equivalent formatted for server-side such as C<en_GB>

=head2 locale_web

Provided with a locale, such as C<en_GB> and this will return its equivalent formatted for the web such as C<en-GB>

=head2 mimeVersion

Returns a string containing the value of the header C<MIME-Version>.

    $po->mimeVersion();

=head2 ngettext

Takes an original string (a.k.a message id), the plural version of that string, and an integer representing the applicable count. For example:

    $po->ngettext( '%d comment awaiting moderation', '%d comments awaiting moderation', 12 );
    # Assuming the locale is ru_RU, this would return:
    # %d комментариев ожидают проверки

=head2 path

Sets or gets the filesystem path to the base directory containing the locale data:

    $po->path( '/locale' ); # /locale contains en_GB/LC_MESSAGES/com.example.api.mo for example

=head2 plural

Sets or gets the definition for plural for the current domain and locale.

It takes and returns an array reference of 2 elements:

=over 4

=item 0. An integer representing the various plural forms available, starting from 1

=item 1. An expression to be evaluated resulting in an offset for the right plural form. For example:

    n>1

or more complex for Russian:

    (n==1) ? 0 : (n%10==1 && n%100!=11) ? 3 : ((n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20)) ? 1 : 2)

=back

=head2 pluralForms

Returns a string containing the value of the header C<Plural-Forms>.

    $po->pluralForms();

=head2 po_object

Returns the L<Text::PO> object used.

=head2 poRevisionDate

Returns a string containing the value of the header C<PO-Revision-Date>.

    $po->poRevisionDate();

=head2 potCreationDate

Returns a string containing the value of the header C<POT-Creation-Date>.

    $po->potCreationDate();

=head2 projectIdVersion

Returns a string containing the value of the header C<Project-Id-Version>.

    $po->projectIdVersion();

=head2 reportBugsTo

Returns a string containing the value of the header C<Report-Msgid-Bugs-To>.

    $po->reportBugsTo();

=head2 textdomain

Given a string representing a domain, such as C<com.example.api> and this will load the C<.json> (if the L</use_json> option is enabled), C<.po> or C<.mo> file found in that order.

=head2 use_json

Takes a boolean and if set, L<Text::PO::Gettext> will use a json po data if it exists, otherwise it will use a C<.po> file or a C<.mo> file in that order of preference.

=head2 _get_po

Returns the L<Text::PO> object used.

=head1 AUTHOR

Jacques Deguest E<lt>F<jack@deguest.jp>E<gt>

=head1 SEE ALSO

L<perl>

=head1 COPYRIGHT & LICENSE

Copyright(c) 2021 DEGUEST Pte. Ltd. DEGUEST Pte. Ltd.

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.