Add support for files with no plural forms

This commit is contained in:
Mikko Ahlroth 2024-05-24 11:21:29 +03:00
parent 5827bbda39
commit 3c9736f8c3
9 changed files with 376 additions and 13 deletions

11
CHANGELOG.txt Normal file
View file

@ -0,0 +1,11 @@
2.0.0
-----
+ Added support for translation files with no plural forms. These can be used for singular
translations.
+ Added more documentation and examples.
1.0.0
-----
* Initial release.

View file

@ -77,6 +77,12 @@ of items in a translatable string, but it's not enforced and has no special mean
the translated message will contain the `%s` unchanged, and it is up to you to replace it with the the translated message will contain the `%s` unchanged, and it is up to you to replace it with the
appropriate number, taking into account the target locale's number format. appropriate number, taking into account the target locale's number format.
### Plural-Forms header
Kielet requires a compiled translation file to contain a `Plural-Forms` header to use plurals. There is
no builtin database of plural form algorithms, so if such a header does not exist, all attempts at
translating plurals will fail. Such a file can be used to translate singular messages, however.
## Gettext limitations ## Gettext limitations
Due to how Gettext is built, the source language (the language used in your source files) can only be a Due to how Gettext is built, the source language (the language used in your source files) can only be a

View file

@ -1,5 +1,5 @@
name = "kielet" name = "kielet"
version = "1.0.0" version = "2.0.0"
# Fill out these fields if you intend to generate HTML documentation or publish # Fill out these fields if you intend to generate HTML documentation or publish
# your project to the Hex package manager. # your project to the Hex package manager.

View file

@ -2,6 +2,22 @@ import kielet/context
import kielet/database import kielet/database
/// Translate the given singular message. /// Translate the given singular message.
///
/// If there is a failure to translate the message, the given message is
/// returned as-is. Causes for such a failure are:
///
/// - if no translations have been loaded for the language,
/// - if there is no translation for this message for the language, or
/// - if the translation for this message is plural.
///
/// Example:
///
/// ```gleam
/// // Imported with kielet.{gettext as g_}
/// io.println(
/// g_(ctx, "Sleep tight in a new light through another warning call")
/// )
/// ```
pub fn gettext(context: context.Context, msgid: String) -> String { pub fn gettext(context: context.Context, msgid: String) -> String {
database.translate_singular(context.database, msgid, context.language) database.translate_singular(context.database, msgid, context.language)
} }
@ -9,6 +25,43 @@ pub fn gettext(context: context.Context, msgid: String) -> String {
/// Translate the given plural message. `n` is the amount of countable items /// Translate the given plural message. `n` is the amount of countable items
/// in the message. For example for the English language, from `"%s bunny"` and /// in the message. For example for the English language, from `"%s bunny"` and
/// `"%s bunnies"`, the latter would be returned when `n` is anything except 1. /// `"%s bunnies"`, the latter would be returned when `n` is anything except 1.
///
/// Note that this function does no replacing of any placeholder. It is only
/// convention to use `%s` in place of the amount in the message, and it will
/// not be altered by this function. Replacing of the amount is left to the
/// user.
///
/// If there is a failure to translate the message, the given message is
/// returned, in singular or plural, using the English pluralisation rules.
/// Causes for such a failure are:
///
/// - if no translations have been loaded for the language,
/// - if there is no translation for this message for the language,
/// - if the translation for this message is singular,
/// - if the plural form algorithm returned a form that does not exist in the
/// translation, or
/// - if the translation file did not have a `Plural-Forms` header.
///
/// Example:
///
/// ```gleam
/// // Imported with kielet.{ngettext as n_}
///
/// let n = 100
///
/// io.println(
/// string.replace(
/// n_(
/// ctx,
/// "That's better than a rabbit",
/// "That's better than %s rabbits",
/// n
/// ),
/// "%s",
/// int.to_string(n)
/// )
/// )
/// ```
pub fn ngettext( pub fn ngettext(
context: context.Context, context: context.Context,
singular: String, singular: String,

View file

@ -35,8 +35,9 @@ pub fn translate_singular(db: Database, msgid: String, language_code: String) {
/// Translate a plural message using the database. /// Translate a plural message using the database.
/// ///
/// If the language is not found, does not have a translation for the message, /// If the language is not found, does not have a translation for the message,
/// or does not have the correct plural for the given `n`, the given plural /// does not have the correct plural for the given `n`, or does not have a
/// message is returned. /// plural forms header at all, the plural message given as the argument is
/// returned instead.
pub fn translate_plural( pub fn translate_plural(
db: Database, db: Database,
msgid: String, msgid: String,

View file

@ -2,6 +2,7 @@
//// used to translate messages. //// used to translate messages.
import gleam/dict import gleam/dict
import gleam/option
import gleam/result import gleam/result
import kielet/mo import kielet/mo
import kielet/plurals import kielet/plurals
@ -29,13 +30,19 @@ pub type TranslateError {
/// The plural algorithm returned a form that is out of bounds for the amount /// The plural algorithm returned a form that is out of bounds for the amount
/// of plural forms. /// of plural forms.
PluralOutOfBounds(requested: Int, but_max_is: Int) PluralOutOfBounds(requested: Int, but_max_is: Int)
/// The translation file had no plural forms or the plural forms header was
/// missing.
LanguageHasNoPlurals
} }
pub opaque type Language { pub opaque type Language {
Language( Language(
code: String, code: String,
translations: mo.Translations, translations: mo.Translations,
plurals: plurals.Plurals, /// A translation file may not have any plurals and no plural-forms header.
/// In that case this value is `None` and plural translations will fail.
plurals: option.Option(plurals.Plurals),
) )
} }
@ -45,12 +52,13 @@ pub fn load(code: String, mo_file: BitArray) {
mo.parse(mo_file) mo.parse(mo_file)
|> result.map_error(MoParseError), |> result.map_error(MoParseError),
) )
use plurals <- result.try(
plurals.load_from_mo(mo)
|> result.map_error(PluralFormsLoadError),
)
Ok(Language(code, mo.translations, plurals)) case plurals.load_from_mo(mo) {
Ok(p) -> Ok(Language(code, mo.translations, option.Some(p)))
Error(plurals.NoPluralFormsHeader) ->
Ok(Language(code, mo.translations, option.None))
Error(err) -> Error(PluralFormsLoadError(err))
}
} }
/// Get the language's language code. /// Get the language's language code.
@ -72,11 +80,12 @@ pub fn get_singular_translation(lang: Language, msgid: String) {
/// Translate a plural message. /// Translate a plural message.
pub fn get_plural_translation(lang: Language, msgid: String, n: Int) { pub fn get_plural_translation(lang: Language, msgid: String, n: Int) {
case dict.get(lang.translations, msgid) { case lang.plurals, dict.get(lang.translations, msgid) {
Ok(mostring) -> option.None, _ -> Error(LanguageHasNoPlurals)
option.Some(p), Ok(mostring) ->
case mostring { case mostring {
mo.Plural(content: c, ..) -> { mo.Plural(content: c, ..) -> {
let index = plurals.evaluate(lang.plurals, n) let index = plurals.evaluate(p, n)
case dict.get(c, index) { case dict.get(c, index) {
Ok(msg) -> Ok(msg) Ok(msg) -> Ok(msg)
Error(_) -> Error(_) ->
@ -88,6 +97,6 @@ pub fn get_plural_translation(lang: Language, msgid: String, n: Int) {
} }
_ -> Error(MsgIsSingular(msgid)) _ -> Error(MsgIsSingular(msgid))
} }
_ -> Error(MsgNotFound(msgid)) option.Some(_), _ -> Error(MsgNotFound(msgid))
} }
} }

View file

@ -105,6 +105,34 @@ pub fn uk_plural_test() {
) )
} }
pub fn no_plural_forms_test() {
let mo_file = "./test/locale/no-plural-forms.mo"
let assert Ok(mo_data) = simplifile.read_bits(mo_file)
let assert Ok(lang) = language.load("fi-no-plurals", mo_data)
let db =
database.new()
|> database.add_language(lang)
let ctx = context.Context(db, "fi-no-plurals")
should.equal(
language.get_plural_translation(lang, "Wibble", 1),
Error(language.LanguageHasNoPlurals),
)
should.equal(
n_(ctx, "I biked one kilometre", "I biked %s kilometres", 1),
"I biked one kilometre",
)
should.equal(
n_(ctx, "I biked one kilometre", "I biked %s kilometres", 2),
"I biked %s kilometres",
)
// Singular should work
should.equal(g_(ctx, "Read more…"), "Lue lisää…")
}
fn load_languages() { fn load_languages() {
database.new() database.new()
|> database.add_language(load_language("fi")) |> database.add_language(load_language("fi"))

Binary file not shown.

View file

@ -0,0 +1,255 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR Mikko Ahlroth
# This file is distributed under the same license as the Scriptorium package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Scriptorium 2.0.0\n"
"Report-Msgid-Bugs-To: mikko@ahlroth.fi\n"
"POT-Creation-Date: 2024-05-23 23:58+0300\n"
"PO-Revision-Date: 2024-05-24 00:08+0300\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: fi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.4.4\n"
#: src/scriptorium/parser.gleam:119
msgid "Menu parsing failed: {err}"
msgstr "Valikon jäsentäminen epäonnistui: {err}"
#: src/scriptorium/renderer.gleam:177
msgid "Archives for {year}"
msgstr "Arkisto: vuosi {year}"
#: src/scriptorium/renderer.gleam:206
msgid "Archives for {month} {year}"
msgstr "Arkisto: {month} {year}"
#: src/scriptorium/rendering/views/list_page.gleam:22
msgid "Pages"
msgstr "Sivut"
#: src/scriptorium/rendering/views/nav.gleam:20
msgid "Previous"
msgstr "Edellinen"
#: src/scriptorium/rendering/views/nav.gleam:38
msgid "current page"
msgstr "tämä sivu"
#: src/scriptorium/rendering/views/nav.gleam:64
msgid "Next"
msgstr "Seuraava"
#: src/scriptorium/rendering/views/single_post.gleam:51
msgid "Tags"
msgstr "Tagit"
#: src/scriptorium/rendering/views/single_post.gleam:74
msgid "Read more…"
msgstr "Lue lisää…"
#: src/scriptorium/rendering/views/single_post.gleam:92
msgid "{date}, {time}"
msgstr "{date}, {time}"
#: src/scriptorium/utils/date.gleam:126
msgid "January"
msgstr "tammikuu"
#: src/scriptorium/utils/date.gleam:127
msgid "February"
msgstr "helmikuu"
#: src/scriptorium/utils/date.gleam:128
msgid "March"
msgstr "maaliskuu"
#: src/scriptorium/utils/date.gleam:129
msgid "April"
msgstr "huhtikuu"
#: src/scriptorium/utils/date.gleam:130 src/scriptorium/utils/date.gleam:148
msgid "May"
msgstr "toukokuu"
#: src/scriptorium/utils/date.gleam:131
msgid "June"
msgstr "kesäkuu"
#: src/scriptorium/utils/date.gleam:132
msgid "July"
msgstr "heinäkuu"
#: src/scriptorium/utils/date.gleam:133
msgid "August"
msgstr "elokuu"
#: src/scriptorium/utils/date.gleam:134
msgid "September"
msgstr "syyskuu"
#: src/scriptorium/utils/date.gleam:135
msgid "October"
msgstr "lokakuu"
#: src/scriptorium/utils/date.gleam:136
msgid "November"
msgstr "marraskuu"
#: src/scriptorium/utils/date.gleam:137
msgid "December"
msgstr "joulukuu"
#: src/scriptorium/utils/date.gleam:144
msgid "Jan"
msgstr "tammi"
#: src/scriptorium/utils/date.gleam:145
msgid "Feb"
msgstr "helmi"
#: src/scriptorium/utils/date.gleam:146
msgid "Mar"
msgstr "maalis"
#: src/scriptorium/utils/date.gleam:147
msgid "Apr"
msgstr "huhti"
#: src/scriptorium/utils/date.gleam:149
msgid "Jun"
msgstr "touko"
#: src/scriptorium/utils/date.gleam:150
msgid "Jul"
msgstr "kesä"
#: src/scriptorium/utils/date.gleam:151
msgid "Aug"
msgstr "heinä"
#: src/scriptorium/utils/date.gleam:152
msgid "Sep"
msgstr "syys"
#: src/scriptorium/utils/date.gleam:153
msgid "Oct"
msgstr "loka"
#: src/scriptorium/utils/date.gleam:154
msgid "Nov"
msgstr "marras"
#: src/scriptorium/utils/date.gleam:155
msgid "Dec"
msgstr "joulu"
#: src/scriptorium/utils/date.gleam:161
msgid "{day} {month_short_str} {year}"
msgstr "{day} {month_str} {year}"
#: src/scriptorium/utils/time.gleam:44
msgid "{h24_0}:{min_0}"
msgstr "{h24_0}:{min_0}"
#: src/scriptorium/utils/time.gleam:77
msgid "<00> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:78
msgid "<01> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:79
msgid "<02> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:80
msgid "<03> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:81
msgid "<04> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:82
msgid "<05> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:83
msgid "<06> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:84
msgid "<07> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:85
msgid "<08> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:86
msgid "<09> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:87
msgid "<10> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:88
msgid "<11> am"
msgstr ""
#: src/scriptorium/utils/time.gleam:89
msgid "<12> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:90
msgid "<13> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:91
msgid "<14> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:92
msgid "<15> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:93
msgid "<16> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:94
msgid "<17> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:95
msgid "<18> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:96
msgid "<19> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:97
msgid "<20> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:98
msgid "<21> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:99
msgid "<22> pm"
msgstr ""
#: src/scriptorium/utils/time.gleam:100
msgid "<23> pm"
msgstr ""