localization-procs.tcl

Does not contain a contract.

Location:
/packages/acs-lang/tcl/localization-procs.tcl

Related Files

[ hide source ] | [ make this the default ]

File Contents

#/packages/lang/tcl/localization-procs.tcl
ad_library {

    Routines for localizing numbers, dates and monetary amounts
    <p>
    This is free software distributed under the terms of the GNU Public
    License.  Full text of the license is available from the GNU Project:
    http://www.fsf.org/copyleft/gpl.html

    @creation-date 30 September 2000
    @author Jeff Davis (davis@xarg.net)
    @author Ashok Argent-Katwala (akatwala@arsdigita.com)
    @cvs-id $Id: localization-procs.tcl,v 1.32 2024/09/11 06:15:48 gustafn Exp $
}


d_proc -public lc_parse_number {
    num
    locale
    {integer_only_p 0}
} {
    Converts a number to its canonical
    representation by stripping everything but the
    decimal separator and trimming left 0's so it
    won't be octal. It can process the following types of numbers:
    <ul>
    <li>Just digits (allows leading zeros).
    <li>Digits with a valid thousands separator, used consistently (leading zeros not allowed)
    <li>Either of the above with a decimal separator plus optional digits after the decimal marker
    </ul>
    The valid separators are taken from the given locale. Does not handle localized signed numbers in this version.
    The sign may only be placed before the number (with/without whitespace).
    Also allows the empty string, returning same.

    @param num      Localized number
    @param locale   Locale
    @param integer_only_p True if only integers returned
    @error          If unsupported locale or not a number
    @return         Canonical form of the number

} {
    if {$num eq ""} {
        return ""
    }

    set dec  [lc_get -locale $locale "decimal_point"]
    set thou [lc_get -locale $locale "mon_thousands_sep"][lc_get -locale $locale "thousands_sep"]
    set neg  [lc_get -locale $locale "negative_sign"]
    set pos  [lc_get -locale $locale "positive_sign"]

    #
    # Sanity check: decimal point must be different from the thousands
    # separators. This test should be really either in regression
    # testing or be forumulated as constraint after changing the
    # message keys.  However, since a violation can lead to incorrect
    # results, the safety check is here as well.
    #
    if {[string first $dec $thou] > -1} {
        error "error in locale $locale: decimal point '$decimal_point' must be different\
                from thousands separator\
                (mon_thousands_sep '[lc_get -locale $locale mon_thousands_sep]'\
                and thousands_sep '[lc_get -locale $locale thousands_sep]')"
    }

    lang::util::escape_vars_if_not_null {dec thou neg pos}

    # Pattern actually looks like this (separators notwithstanding):
    # {^\ *([-]|[+])?\ *([0-9]+|[1-9][0-9]{1,2}([,][0-9]{3})+)([.][0-9]*)?\ *$}

    set pattern "^\\ *($neg|$pos)?\\ *((\[0-9\]+|\[1-9\]\[0-9\]{0,2}($thou\[0-9\]\{3\})+)"

    if {$integer_only_p} {
        append pattern "?)(${dec}0*)?"
    } else {
        append pattern "?($dec\[0-9\]*)?)"
    }

    append pattern "\\ *\$"

    set is_valid_number  [regexp -- $pattern $num match sign number]

    if {!$is_valid_number} {
        error "Not a number $num"
    } else {

        regsub -all $thou $number "" number

        if {!$integer_only_p} {
            regsub -all $dec $number "." number
        }

        set number [util::trim_leading_zeros $number]

        # Last pathological case
        if {"." eq $number } {
            set number 0
        }

        if {[string match "\\\\\\${sign}" $neg]} {
            set number -$number
        }

        return $number
    }
}


d_proc -private lc_sepfmt {
    num
    {grouping {3}}
    {sep ,}
    {num_re {[0-9]}}
} {
    Called by lc_numeric and lc_monetary.
    <p>
    Takes a grouping specifier and
    inserts the given separator into the string.
    Given a separator of :
    and a number of 123456789 it returns:
    <pre>
    grouping         Formatted Value
    {3 -1}               123456:789
    {3}                  123:456:789
    {3 2 -1}             1234:56:789
    {3 2}                12:34:56:789
    {-1}                 123456789
    </pre>

    @param num        Number
    @param grouping   Grouping specifier
    @param sep        Thousands separator
    @param num_re     Regular expression for valid numbers
    @return           Number formatted with thousand separator
} {
    # with empty separator or grouping string we behave
    # posixly
    if {$grouping eq "" || $sep eq "" } {
        return $num
    }

    # we need to sanitize the subspec
    regsub -all -- "(\[&\\\\\])" $sep "\\\\\\1" sep

    set match "^(-?$num_re+)("
    set group [lindex $grouping 0]

    while { $group > 0} {
        set re "$match[string repeat $num_re $group])"
        if { ![regsub -- $re $num "\\1$sep\\2" num] } {
            break
        }
        if {[llength $grouping] > 1} {
            set grouping [lrange $grouping 1 end]
        }
        set group [lindex $grouping 0]
    }

    return $num
}


d_proc -public lc_numeric {
    num
    {fmt {}}
    {locale ""}
} {

    Given a number and a locale return a formatted version of the number
    for that locale.

    @param num      Number in canonical form
    @param fmt      Format string used by the Tcl format
                    command (should be restricted to the form "%.Nf" if present).
    @param locale   Locale
    @return         Localized form of the number

} {
    if {$fmt ne ""} {
        set out [format $fmt $num]
    } else {
        set out $num
    }

    set sep [lc_get -locale $locale "thousands_sep"]
    set dec [lc_get -locale $locale "decimal_point"]
    set grouping [lc_get -locale $locale "grouping"]

    # Fall back on en_US if grouping is not on valid format
    if { $locale ne "en_US" && ![regexp {^[0-9 -]+$} $grouping] } {
        ns_log Warning "lc_numeric: acs-lang.localization-grouping key has " \
            "invalid grouping value '$grouping' for locale '$locale'"
        set sep ,
        set dec .
        set grouping 3

    }

    regsub {\.} $out $dec out
    return [lc_sepfmt $out $grouping $sep]
}

d_proc -deprecated clock_to_ansi {
    seconds
} {
    Convert a time in the Tcl internal clock seconds format to ANSI format, usable by lc_time_fmt.

    DEPRECATED: this proc does not comply with naming convention
    enforced by acs-tcl.naming__proc_naming automated test

    @author Lars Pind (lars@pinds.com)
    @return ANSI (YYYY-MM-DD HH24:MI:SS) formatted date.
    @see lc_time_fmt
    @see lc_clock_to_ansi
} {
    return [lc_clock_to_ansi $seconds]
}

d_proc -public lc_clock_to_ansi {
    seconds
} {
    Convert a time in the Tcl internal clock seconds format to ANSI format, usable by lc_time_fmt.

    @author Lars Pind (lars@pinds.com)
    @return ANSI (YYYY-MM-DD HH24:MI:SS) formatted date.
    @see lc_time_fmt
} {
    return [clock format $seconds -format "%Y-%m-%d %H:%M:%S"]
}

d_proc -public lc_get {
    {-locale ""}
    key
} {
    Get a certain format string for the current locale.

    @param key the key of for the format string you want.
    @return the format string for the current locale.

    @author Lars Pind (lars@pinds.com)
} {
    # All localization message keys have a certain prefix
    set message_key "acs-lang.localization-$key"

    # Set upvar level to 0 so that no attempt is made to interpolate variables
    # into the string
    # Set translator_mode_p to 0 so we don't dress the message up with a link to translate
    return [lang::message::lookup $locale $message_key {} {} 0 0]
}

d_proc -private lc_datetime_to_clock {
    datetime
} {
    Converts a datetime in one of the supported formats to a clock
    value.

    @param datetime A time string in one of the following formats as
                    from clock tcl command specifications: "%Y-%m-%d
                    %H:%M:%S""%Y-%m-%d %H:%M" and
                    "%Y-%m-%d". Database timestamps such as
                    "2019-12-16 12:50:14.049896+01" are also
                    tolerated, by normalizing them to "2019-12-16
                    12:50:14". Note that in this case all information
                    about timezone and fractions of second will be
                    discarded.

    @see https://www.tcl.tk/man/tcl/TclCmd/clock.html#M25

    @return integer
} {
    set datetime [string range [string trim $datetime] 0 18]
    foreach format {
        "%Y-%m-%d %H:%M:%S"
        "%Y-%m-%d %H:%M"
        "%Y-%m-%d"
    } {
        set invalid_format_p [catch {
            set date_clock [clock scan $datetime -format $format]
        }]
        if {!$invalid_format_p} {
            break
        }
    }
    if {$invalid_format_p} {
        error "Invalid date: $datetime"
    }

    return $date_clock
}

d_proc -public lc_time_fmt {
    datetime
    fmt
    {locale ""}
} {
    Formats a time for the specified locale.

    @param datetime A datetime in one of the supported formats. See
                    lc_datetime_to_clock.

    @param fmt An ISO 14652 LC_TIME style formatting string.  The
               <b>highlighted</b> functions localize automatically
               based on the user's locale; other strings will use
               locale-specific text but not necessarily
               locale-specific formatting.
    <pre>
      %a           FDCC-set's abbreviated weekday name.
      %A           FDCC-set's full weekday name.
      %b           FDCC-set's abbreviated month name.
      %B           FDCC-set's full month name.
      <b>%c           FDCC-set's appropriate date and time
                   representation.</b>
      %C           Century (a year divided by 100 and truncated to
                   integer) as decimal number (00-99).
      %d           Day of the month as a decimal number (01-31).
      %D           Date in the format mm/dd/yy.
      %e           Day of the month as a decimal number (1-31 in at
                   two-digit field with leading <space> fill).
      %E           Month number as a decimal number (1-12 in at
                   two-digit field with leading <space> fill).
      %f           Weekday as a decimal number (1(Monday)-7).
      %F           is replaced by the date in the format YYYY-MM-DD
                   (ISO 8601 format)
      %h           A synonym for %b.
      %H           Hour (24-hour clock) as a decimal number (00-23).
      %I           Hour (12-hour clock) as a decimal number (01-12).
      %j           Day of the year as a decimal number (001-366).
      %m           Month as a decimal number (01-13).
      %M           Minute as a decimal number (00-59).
      %n           A <newline> character.
      %p           FDCC-set's equivalent of either AM or PM.
      %r           Hours and minutes using 12-hour clock AM/PM
                   notation, e.g. '06:12 AM'.
      <b>%q           Long date without weekday (OpenACS addition to the standard)</b>
      <b>%Q           Long date with weekday (OpenACS addition to the standard)</b>
      %S           Seconds as a decimal number (00-61).
      %t           A <tab> character.
      %T           24-hour clock time in the format HH:MM:SS.
      %u           Week number of the year as a decimal number with
                   two digits and leading zero, according to "week"
                   keyword.
      %U           Week number of the year (Sunday as the first day of
                   the week) as a decimal number (00-53).
      %w           Weekday as a decimal number (0(Sunday)-6).
      %W           Week number of the year (Monday as the first day of
                   the week) as a decimal number (00-53).
      <b>%x           FDCC-set's appropriate date representation.</b>
      <b>%X           FDCC-set's appropriate time representation.</b>
      %y           Year (offset from %C) as a decimal number (00-99).
      %Y           Year with century as a decimal number.
      %Z           The connection's timezone, e.g. 'America/New_York'.
      %%           A <percent-sign> character.
    </pre>

    @param locale          Locale identifier must be in the locale database
    @error Fails if given a non-existent locale or a malformed
           datetime. Impossible dates will be treated as per clock
           scan behavior and e.g. 29 Feb 1999 will be translated to
           1st March, Monday, as it wasn't a leap year. The clock api
           takes care of the proper handling of Julian/Gregorian
           dates.

    @see lc_datetime_to_clock
    @see http://www.tondering.dk/claus/calendar.html
    @see man strftime on a UNIX shell prompt for more date format abbreviations.

    @return A date formatted for a locale
} {
    if { $datetime eq "" } {
        return ""
    }

    if { $locale eq "" } {
        set locale [ad_conn locale]
    }

    set date_clock [::lc_datetime_to_clock $datetime]

    set date_tokens [list]
    foreach token [clock format $date_clock -format "%Y %m %d %H %M %S %w"] {
        lappend date_tokens [util::trim_leading_zeros $token]
    }

    lassign $date_tokens \
        lc_time_year \
        lc_time_month \
        lc_time_days \
        lc_time_hours \
        lc_time_minutes \
        lc_time_seconds \
        lc_time_day_no

    #
    # Keep the results of lc_time_fmt_compile in the per-thread cache
    # (namespaced variable)
    #
    return [subst [acs::per_thread_cache eval -key acs-lang.lc_time_fmt_compile($fmt,$locale) {
        lc_time_fmt_compile $fmt $locale
    }]]
}

d_proc -private lc_time_fmt_compile {
    fmt
    locale
} {
    Compiles ISO 14652 LC_TIME style formatting string to variable substitutions and proc calls.

    @param fmt             An ISO 14652 LC_TIME style formatting string.
    @param locale          Locale identifier must be in the locale database
    @return                A string that should be subst'ed in the lc_time_fmt proc
                           after local variables have been set.
} {
    set to_process $fmt

    set compiled_string ""
    while {[regexp -- {^(.*?)%(.)(.*)$} $to_process match done_portion percent_modifier remaining]} {

        switch -exact -- $percent_modifier {
            x {
                append compiled_string $done_portion
                set to_process "[lc_get -locale $locale d_fmt]$remaining"
            }
            X {
                append compiled_string $done_portion
                set to_process "[lc_get -locale $locale t_fmt]$remaining"
            }
            c {
                append compiled_string $done_portion
                set to_process "[lc_get -locale $locale d_t_fmt]$remaining"
            }
            q {
                append compiled_string $done_portion
                set to_process "[lc_get -locale $locale dlong_fmt]$remaining"
            }
            Q {
                append compiled_string $done_portion
                set to_process "[lc_get -locale $locale dlongweekday_fmt]$remaining"
            }
            default {
                append compiled_string "${done_portion}$::lang::util::percent_match($percent_modifier)"
                set to_process $remaining
            }
        }
    }

    # What is left to_process must be (%.)-less, so it should be included without transformation.
    append compiled_string $to_process

    return $compiled_string
}

d_proc -public lc_time_utc_to_local {
    time_value
    {tz ""}
} {
    Converts a Universal Time to local time for the specified timezone.

    @param time_value        UTC time in the ISO datetime format.
    @param tz                Timezone that must exist in tz_data table.
    @return                  Local time
} {
    if { $tz eq "" } {
        set tz [lang::conn::timezone]
    }

    set local_time [lc_time_tz_convert -from UTC -to $tz -time_value $time_value]

    if {$local_time eq ""} {
        #
        # An empty result normally means a broken date or timezone. We
        # throw a warning in this case.
        #
        ns_log warning "lc_time_utc_to_local: Timezone adjustment in ad_localization.tcl found no conversion to UTC for $time_value $tz"
    }

    return $local_time
}

d_proc -public lc_time_local_to_utc {
    time_value
    {tz ""}
} {
    Converts a local time to a UTC time for the specified timezone.

    @param time_value        Local time in the ISO datetime format, YYYY-MM-DD HH24:MI:SS
    @param tz                Valid timezone as supported by the Tcl Clock command or
                             must exist in tz_data table.
    @return                  UTC time.
} {
    if { $tz eq "" } {
        set tz [lang::conn::timezone]
    }

    set utc_time [lc_time_tz_convert -from $tz -to UTC -time_value $time_value]

    if {$utc_time eq ""} {
        #
        # An empty result normally means a broken date or timezone. We
        # throw a warning in this case.
        #
        ns_log warning "lc_time_local_to_utc: Timezone adjustment in ad_localization.tcl found no conversion to local time for $time_value $tz"
    }

    return $utc_time
}




d_proc -public lc_time_system_to_conn {
    time_value
} {
    Converts a date from the system (database) to the connection's timezone,
    using the OpenACS timezone setting and user's preference

    @param time_value        Timestamp from the database in the ISO datetime format.
    @return                  Timestamp in conn's local time, also in ISO datetime format.
} {
    if { ![ns_conn isconnected] } {
        return $time_value
    }

    set system_tz [lang::system::timezone]
    set conn_tz [lang::conn::timezone]

    if { $conn_tz eq "" || $system_tz eq $conn_tz } {
        return $time_value
    }

    return [lc_time_tz_convert -from $system_tz -to $conn_tz -time_value $time_value]
}

d_proc -public lc_time_conn_to_system {
    time_value
} {
    Converts a date from the connection's timezone to the system (database) timezone,
    using the OpenACS timezone setting and user's preference

    @param time_value        Timestamp from conn input in the ISO datetime format.
    @return                  Timestamp in the database's timezone, also in ISO datetime format.
} {
    if { ![ns_conn isconnected] } {
        return $time_value
    }

    set system_tz [lang::system::timezone]
    set conn_tz [lang::conn::timezone]

    if { $conn_tz eq "" || $system_tz eq $conn_tz } {
        return $time_value
    }

    return [lc_time_tz_convert -from $conn_tz -to $system_tz -time_value $time_value]
}


d_proc -public lc_time_tz_convert {
    {-from:required}
    {-to:required}
    {-time_value:required}
} {
    Converts a date from one timezone to another.

    @param time_value        A datetime in one of the supported formats. See
                             lc_datetime_to_clock.

    @return                  Timestamp in the 'to' timezone, also in ISO datetime
                             format, or the empty string when
                             'time_value' or one of the timezones are
                             invalid, or when it is otherwise
                             impossible to determine the right
                             conversion.

    @see lc_datetime_to_clock
} {
    #
    # Here we enforce that the timestamp format is correct and
    # apply Tcl clock date normalization (e.g. 2000-00-00 00:00:00
    # -> 1999-11-30 00:00:00) so that the behavior is consistent
    # across DBMSs)
    #
    try {
        set clock_value [::lc_datetime_to_clock $time_value]
    } on error {errmsg} {
        ad_log warning "lc_time_tz_convert: invalid date '$time_value'"
        return ""
    }

    set time_value [clock format $clock_value -format {%Y-%m-%d %H:%M:%S}]

    try {
        #
        # Tcl-based conversion
        #
        # Tcl clock api can perform timezone conversion fairly easy,
        # with the advantage that we do not have to maintain a local
        # timezones database, including daylight savings, to get a
        # correct and consistent result.
        #
        set clock_local [clock scan $time_value -format {%Y-%m-%d %H:%M:%S} -timezone $from]
        set clock_gmt [clock scan $clock_local -format %s -gmt 1]
        set date_to [clock format $clock_gmt -format {%Y-%m-%d %H:%M:%S} -timezone $to]
    } on error {errmsg} {
        ns_log notice \
            "lc_time_tz_convert: '$time_value' from '$from' to '$to' via Tcl returned:" \
            $errmsg "- use DB-based conversion"

        #
        # DB-based conversion
        #
        # The typical Tcl installation will not deal with
        # non-canonical timezones, but we may have this
        # information in the ref-timezones datamodel. When the Tcl
        # conversion fails, we try this approach instead.
        #
        set date_to [db_string tz_convert {
            with gmt as
            (
             select cast(:time_value as timestamp) -
                    cast(r.gmt_offset || ' seconds' as interval) as time
               from timezones t, timezone_rules r
              where t.tz_id = r.tz_id
                and :time_value between r.local_start and r.local_end
                and t.tz = :from
             )
            select to_char(gmt.time + cast(r.gmt_offset || ' seconds' as interval),
                           'YYYY-MM-DD HH24:MI:SS')
              from timezones t, timezone_rules r, gmt
             where t.tz_id = r.tz_id
               and gmt.time between r.utc_start and r.utc_end
               and t.tz = :to
        } -default ""]
    }

    return $date_to
}


ad_proc -public lc_list_all_timezones { } {
    @return list of pairs containing all timezone names and offsets.
    Data drawn from acs-reference package timezones table
} {
    return [db_list_of_lists all_timezones {}]
}



ad_proc -private lc_time_drop_meridian { hours } {
    Converts HH24 to HH12.
} {
    if {$hours > 12} {
        incr hours -12
    } elseif {$hours == 0} {
        set hours 12
    }
    return $hours
}

ad_proc -private lc_wrap_sunday { day_no } {
    To go from 0(Sun) - 6(Sat)
    to 1(Mon) - 7(Sun)
} {
    if {$day_no==0} {
        return 7
    } else {
        return $day_no
    }
}

ad_proc -private lc_time_name_meridian { locale hours } {
    Returns locale data depending on AM or PM.
} {
    if {$hours > 11} {
        return [lc_get -locale $locale "pm_str"]
    } else {
        return [lc_get -locale $locale "am_str"]
    }
}

ad_proc -private lc_leading_space {num} {
    Inserts a leading space for numbers less than 10.
} {
    if {$num < 10} {
        return $num"
    } else {
        return $num
    }
}


d_proc -private lc_leading_zeros {
    the_integer
    n_desired_digits
} {
    Adds leading zeros to an integer to give it the desired number of digits
} {
    return [format "%0${n_desired_digits}d" $the_integer]
}


d_proc -public lc_content_size_pretty {
    {-size "0"}
    {-precision "1"}
    {-standard "decimal"}
} {

    Transforms data size, provided in nonnegative bytes, to KB, MB... up to YB.

    @param size       Size in bytes
    @param precision  Numbers in the fractional part
    @param standard   Standard to use for binary prefix. Three standards are
                      supported currently by this proc:
                        - decimal (default): SI (base-10, 1000 bytes = 1kB)
                        - binary: IEC           (base-2,  1024 bytes = 1KiB)
                        - legacy: JEDEC         (base-2,  1024 bytes = 1KB)

    @return Size in given standard units (e.g. '5.2 MB')

    @author Héctor Romojaro <hector.romojaro@gmail.com>
    @creation-date 2019-06-25

} {
    #
    # Localized byte/s
    #
    set bytes [lc_get "bytes"]
    set byte  [lc_get "byte"]

    switch $standard {
        decimal {
            #
            # SI (base-10, 1000 bytes = 1KB)
            #
            set div 1000
            set units [list $bytes kB MB GB TB PB EB ZB YB]
        }
        binary {
            #
            # IEC (base-2, 1024 bytes = 1KiB)
            #
            set div 1024
            set units [list $bytes KiB MiB GiB TiB PiB EiB ZiB YiB]
        }
        legacy {
            #
            # JEDEC (base-2, 1024 bytes = 1KB)
            #
            set div 1024
            set units [list $bytes KB MB GB TB PB EB ZB YB]
        }
        default {
            return "Unknown value $standard for -standard option"
        }
    }
    #
    # For empty size, we assume 0
    #
    if {$size eq ""} {
        set size 0
    }

    set len [string length $size]

    if {$size < $div} {
        #
        # 1 byte or n bytes
        #
        if {$size == 1} {
            set size_pretty [format "%s $byte" $size]
        } else {
            set size_pretty [format "%s $bytes" $size]
        }
    } else {
        #
        # > 1K
        #
        set unit [expr {($len - 1) / 3}]
        set size_pretty [format "%.${precision}f %s" [expr {$size / pow($div,$unit)}] [lindex $units $unit]]
    }
    #
    # Localize dot/comma just before return
    #
    set size_pretty "[lc_numeric [lindex $size_pretty 0]] [lindex $size_pretty 1]"

    return $size_pretty
}

# Local variables:
#    mode: tcl
#    tcl-indent-level: 4
#    indent-tabs-mode: nil
# End: