1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
//! # Rocket I18N
//! 
//! A crate to help you internationalize your Rocket applications.
//! 
//! ## Features
//! 
//! - Create `.po` files for locales listed in `po/LINGUAS`, from a POT file
//! - Update `.po` files from the POT file if needed
//! - Compile `.po` files into `.mo` ones
//! - Select the correct locale for each request
//! - Integrates with Tera templates
//! 
//! ## Usage
//! 
//! First add it to your `Cargo.toml`:
//! 
//! ```toml
//! [dependencies]
//! rocket_i18n = "0.1"
//! ```
//! 
//! Then, in your `main.rs`:
//! 
//! ```rust
//! extern crate rocket_i18n;
//! 
//! // ...
//! 
//! fn main() {
//!     rocket::ignite()
//!         // Register the fairing. The parameter is the domain you want to use (the name of your app most of the time)
//!         .attach(rocket_i18n::I18n::new("my_app"))
//!         // Eventually register the Tera filters (only works with the master branch of Rocket)
//!         .attach(rocket_contrib::Template::custom(|engines| {
//!             rocket_i18n::tera(&mut engines.tera);
//!         }))
//!         // Register routes, etc
//! }
//! ```
//! 
//! ## For the developers
//! 
//! ### Using Tera filters
//! 
//! If you called `rocket_i18n::tera`, you'll be able to use two Tera filters to translate your interface.
//! 
//! The first one, `_`, corresponds to the `gettext` function of gettext. It takes a string as input and translate it. Any argument given to the filter can
//! be used in the translated string using the Tera syntax.
//! 
//! ```jinja
//! <p>{{ "Hello, world" | _ }}</p>
//! <p>{{ "Your name is {{ name }}" | _(name=user.name) }}
//! ```
//! 
//! The second one, `_n`, is equivalent to `ngettext`. It takes the plural form as input, and two required arguments in addition to those you may want to use for interpolation:
//! 
//! - `singular`, the singular form of this string
//! - `count`, the number of items, to determine how the string should be pluralized
//! 
//! ```jinja
//! <p>{{ "{{ count }} new messages" | _n(singular="One new message", count=messages.unread_count) }}</p>
//! ```
//! 
//! ### In Rust code
//! 
//! You can also use all the gettext functions in your Rust code.
//! 
//! ```rust
//! use rocket_i18n;
//! 
//! #[get("/")]
//! fn index() -> String {
//!     gettext("Hello, world!")
//! }
//! 
//! #[get("/<name>")]
//! fn hello(name: String) -> String {
//!     format!(gettext("Hello, {}!"), name)
//! }
//! ```
//! 
//! ### Editing the POT
//! 
//! For those strings to be translatable you should also add them to the `po/YOUR_DOMAIN.pot` file. To add a simple message, just do:
//! 
//! ```po
//! msgid "Hello, world" # The string you used with your filter
//! msgstr "" # Always empty
//! ```
//! 
//! For plural forms, the syntax is a bit different:
//! 
//! ```po
//! msgid "You have one new notification" # The singular form
//! msgid_plural "You have {{ count }} new notifications" # The plural one
//! msgstr[0] ""
//! msgstr[1] ""
//! ```
//! 

extern crate gettextrs;
extern crate rocket;
extern crate serde_json;
extern crate tera;

use gettextrs::*;
use rocket::{Data, Request, Rocket, fairing::{Fairing, Info, Kind}};
use std::{
    collections::HashMap,
    env,
    fs,
    io::{BufRead, BufReader},
    path::{Path, PathBuf},
    process::Command
};
use tera::{Tera, Error as TeraError};

const ACCEPT_LANG: &'static str = "Accept-Language";

/// This is the main struct of this crate. You can register it on your Rocket instance as a
/// fairing.
/// 
/// ```rust
/// rocket::ignite()
///     .attach(I18n::new("app"))
/// ```
/// 
/// The parameter you give to [`I18n::new`] is the gettext domain to use. It doesn't really matter what you choose,
/// but it is usually the name of your app.
/// 
/// Once this fairing is registered, it will update your .po files from the POT, compile them into .mo files, and select
/// the requested locale for each request using the `Accept-Language` HTTP header.
pub struct I18n {
    domain: &'static str
}

impl I18n {
    /// Creates a new I18n fairing for the given domain
    pub fn new(domain: &'static str) -> I18n {
        I18n {
            domain: domain
        }
    }
}

impl Fairing for I18n {
    fn info(&self) -> Info {
        Info {
            name: "Gettext I18n",
            kind: Kind::Attach | Kind::Request
        }
    }

    fn on_attach(&self, rocket: Rocket) -> Result<Rocket, Rocket> {
        update_po(self.domain);
        compile_po(self.domain);

        bindtextdomain(self.domain, fs::canonicalize(&PathBuf::from("./translations/")).unwrap().to_str().unwrap());
        textdomain(self.domain);
        Ok(rocket)
    }

    fn on_request(&self, request: &mut Request, _: &Data) {
        let lang = request
            .headers()
            .get_one(ACCEPT_LANG)
            .unwrap_or("en")
            .split(",")
            .nth(0)
            .unwrap_or("en");
        
        // We can't use setlocale(LocaleCategory::LcAll, lang), because it only accepts system-wide installed
        // locales (and most of the time there are only a few of them).
        // But, when we set the LANGUAGE environment variable, and an empty string as a second parameter to
        // setlocale, gettext will be smart enough to find a matching locale in the locally installed ones.
        env::set_var("LANGUAGE", lang);
        setlocale(LocaleCategory::LcAll, "");
    }
}

fn update_po(domain: &str) {
    let pot_path = Path::new("po").join(format!("{}.pot", domain));

    for lang in get_locales() {
        let po_path = Path::new("po").join(format!("{}.po", lang.clone()));
        if po_path.exists() && po_path.is_file() {
            println!("Updating {}", lang.clone());
            // Update it
            Command::new("msgmerge")
                .arg("-U")
                .arg(po_path.to_str().unwrap())
                .arg(pot_path.to_str().unwrap())
                .spawn()
                .expect("Couldn't update PO file");
        } else {
            println!("Creating {}", lang.clone());
            // Create it from the template
            Command::new("msginit")
                .arg(format!("--input={}", pot_path.to_str().unwrap()))
                .arg(format!("--output-file={}", po_path.to_str().unwrap()))
                .arg("-l")
                .arg(lang)
                .arg("--no-translator")
                .spawn()
                .expect("Couldn't init PO file");
        }
    }
}

fn compile_po(domain: &str) {
    for lang in get_locales() {
        let po_path = Path::new("po").join(format!("{}.po", lang.clone()));
        let mo_dir = Path::new("translations")
            .join(lang.clone())
            .join("LC_MESSAGES");
        fs::create_dir_all(mo_dir.clone()).expect("Couldn't create MO directory");
        let mo_path = mo_dir.join(format!("{}.mo", domain));

        Command::new("msgfmt")
            .arg(format!("--output-file={}", mo_path.to_str().unwrap()))
            .arg(po_path)
            .spawn()
            .expect("Couldn't compile translations");
    }
}

fn get_locales() -> Vec<String> {
    let linguas_file = fs::File::open(Path::new("po").join("LINGUAS")).expect("Couldn't find po/LINGUAS file");
    let linguas = BufReader::new(&linguas_file);
    linguas.lines().map(Result::unwrap).collect()
}

fn tera_gettext(msg: serde_json::Value, ctx: HashMap<String, serde_json::Value>) -> Result<serde_json::Value, TeraError> {
    let trans = gettext(msg.as_str().unwrap());
    Ok(serde_json::Value::String(Tera::one_off(trans.as_ref(), &ctx, false).unwrap_or(String::from(""))))
}

fn tera_ngettext(msg: serde_json::Value, ctx: HashMap<String, serde_json::Value>) -> Result<serde_json::Value, TeraError> {
    let trans = ngettext(
        ctx.get("singular").unwrap().as_str().unwrap(),
        msg.as_str().unwrap(),
        ctx.get("count").unwrap().as_u64().unwrap() as u32
    );
    Ok(serde_json::Value::String(Tera::one_off(trans.as_ref(), &ctx, false).unwrap_or(String::from(""))))
}

/// Register translation filters on your Tera instance
/// 
/// ```rust
/// rocket::ignite()
///     .attach(rocket_contrib::Template::custom(|engines| {
///         rocket_i18n::tera(&mut engines.tera);
///     }))
/// ```
/// 
/// The two registered filters are `_` and `_n`. For example use, see the crate documentation,
/// or the project's README.
pub fn tera(t: &mut Tera) {
    t.register_filter("_", tera_gettext);
    t.register_filter("_n", tera_ngettext);
}