package bogue
Source file b_i18n.ml
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 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349(* This file is part of BOGUE, by San Vu Ngoc *) (* Internationalization module *) (* The rules of the game is to load only the necessary languages, and only on demand. This means (I think) that data cannot be saved in .ml files. We need to dynamically load external files. Of course, given that the current set of i18n files is so small, and so is the current set of translated phrases, it would be a very minimal burden to load them all into the library... But well, a game without rules is not a game, right? On a more serious side, if a user of the library wants to build an app with thousands of words to translate -- without any IA -- then it should scale well. *) open Printf open B_utils module Theme = B_theme module Var = B_var type locale = { language : string; country : string option } (* From the system (OS) we extract a list of locales, for instance via the $LANG variable (but the SDL function get_preferred_locales is more cross-platform, see this PR https://github.com/dbuenzli/tsdl/pull/111). This list is ranked by order of preference; hence when translating a phrase, we first look for the translation into the language of the first locale; if it cannot be found, we turn to the next locale, and so on. *) (* We offer two translation systems. The first one is a set of predefined lazy variables, like I18n.File.cancel which (when forced) will return "Annuler" in French. *) (* The second system is a gettext type of lookup, where we need to know the exact English phrase (string) --- like "cancel". It performs a Hashtbl search, so it is slightly slower than lazy variables once they are cached. *) (* The translations are organized by language, of course, but also by "context". Each Bogue module has its own context; for instance one can translate differently "save as" as "enregistrer sous" or "enregistrer comme" depending on the context. *) (* Translation files are ".conf" files located in the share/locales dir. *) (* Context are modules and are initialized by the [make_context] function (which uses first-class modules). *) let languages = Var.create (Hashtbl.create 15) (* [languages] contains the "db" tables, each for one language. Each [db] table contains "ctxb" context tables. *) let common_context = "Common context" let ctx_tag = "__CONTEXT" let get_ctxb db context = let@ db = Var.with_protect db in match Hashtbl.find_opt db context with | Some table -> table | None -> printd debug_user "Creating I18n context [%s]" context; let table = Hashtbl.create 50 in Hashtbl.add db context table; table let get_country_extension locale = match locale.country with | Some cc -> "_" ^ cc | None -> "" let filename locale = sprintf "locale_%s%s.conf" locale.language (get_country_extension locale) let load_locale locale = let db = Var.create (Hashtbl.create 15) in let ctxb = get_ctxb db common_context in let file = filename locale |> Filename.concat "%locales" |> Theme.get_path in let () = if Sys.file_exists file then try printd debug_io "Loading [%s]..." file; let alist = Theme.load_vars file in let rec loop table = function | [] -> () | (key, t) :: rest -> if key = ctx_tag then loop (get_ctxb db t) rest else begin Hashtbl.replace table key t; loop table rest end in loop ctxb alist with _ -> printd (debug_error + debug_io) "Cannot parse locale file [%s]" file else printd (debug_io + debug_warning) "Locale file [%s] does not exist. Maybe you could contribute to it?" file in db let get_db locale = let@ languages = Var.with_protect languages in match Hashtbl.find_opt languages locale with | Some table -> table | None -> let db = load_locale locale in Hashtbl.add languages locale db; db (* taken from ocaml 4.10 *) let rec find_map f = function | [] -> None | x :: l -> begin match f x with | Some _ as result -> result | None -> find_map f l end let text_from_context context text db = let ctxb = get_ctxb db context in Hashtbl.find_opt ctxb text (* If not found in context, lookup all contexts. *) let text_from_locale ~context text locale = let db = get_db locale in match text_from_context context text db with | None -> let@ db = Var.with_protect db in let ctxlist = Hashtbl.fold (fun _ctxname ctxb list -> ctxb :: list) db [] in find_map (fun ctxb -> Hashtbl.find_opt ctxb text) ctxlist | a -> a let gettext_opt ~context locales text = printd debug_custom "Looking for a translation for: %s" text; find_map (text_from_locale ~context text) locales (* If not found we return the original text *) let gettext ?fallback ~context locales text = let fallback = default fallback text in gettext_opt ~context locales text |> Option.value ~default:fallback (* TODO log error *) let test_gettext () = let fr = { language = "fr(test)"; country = Some "FR" } in let de = { language = "de(test)"; country = None} in let french = get_db fr in let context = "Test" in let test_context = get_ctxb french context in List.iter2 (Hashtbl.add test_context) ["open"; "close"; "save"; "save as"] ["ouvrir(test)"; "fermer"; "enregistrer"; "enregistrer sous"]; assert (gettext_opt ~context [de; fr] "save as" = Some "enregistrer sous"); assert (gettext_opt ~context [fr; de] "open" = Some "ouvrir(test)"); assert (gettext_opt ~context [fr; de] "openn" = None) let add_translation ~context locale text translation = let table = get_ctxb (get_db locale) context in Hashtbl.replace table text translation let save_locale_alist ?domain locale alist = let = match domain with | None -> "." | Some domain -> match Theme.find_share domain "." with | None -> print_endline "Cannot find share directory!"; "." | Some path -> path in let file = Filename.concat share (filename locale) in let outch = try open_out file with _ -> begin printd (debug_error + debug_user) "Cannot open file [%s] for write access." file; stdout end in try output_string outch (sprintf "## BOGUE version %s\n\n" Theme.this_version); List.iter (fun (text, translation) -> output_string outch (sprintf "%s = %s\n" text translation)) alist; close_out outch; printd (debug_io + debug_user) "Wrote [%s]" file with _ -> begin printd (debug_io + debug_error + debug_user) "Error while writing to file [%s.]" file; close_out outch end let ctxb_to_alist table context = Hashtbl.fold (fun k t list -> (k, t) :: list) table [ctx_tag, context] |> List.sort (fun a1 a2 -> Stdlib.compare (fst a1) (fst a2)) let db_to_alist db = Hashtbl.fold (fun context table list -> List.append list (ctxb_to_alist table context)) db [] (* This saves all the bogue's locales (all contexts) plus the additional bindings added by [add_translation]. *) let save_locale ?domain locale = let@ db = Var.with_protect (get_db locale) in db_to_alist db |> save_locale_alist ?domain locale (* Now some functions for detecting the user's locales *) let parse_locale s = if s = "" then None else let ll_CC = List.hd (String.split_on_char '.' s) in if ll_CC = "" then begin printd debug_error "Malformed language variable [%s]." s; None end else match String.split_on_char '_' ll_CC with | [language] -> Some { language; country = None} | [ll; cc] -> Some { language = String.lowercase_ascii ll; country = Some (String.uppercase_ascii cc) } | _ -> printd debug_error "Malformed language variable [%s]." s; None let test_parse_lang () = assert (parse_locale "C" = Some { language = "C"; country = None }); assert (parse_locale "fr_FR" = Some { language = "fr"; country = Some "FR" }); assert (parse_locale "fr_FR.UTF-8" = Some { language = "fr"; country = Some "FR" }) let get_lang () = (* https://www.gnu.org/savannah-checkouts/gnu/gettext/manual/gettext.html#Locale-Environment-Variables *) let lc = (try Sys.getenv "LC_ALL" with | Not_found -> try Sys.getenv "LANG" with | Not_found -> "") |> parse_locale in match lc with | Some { language = "C"; _ } -> [{ language = "C"; country = None}] | _ -> let list = String.split_on_char ':' (try Sys.getenv "LANGUAGE" with Not_found -> "") in List.append (List.map parse_locale list) [lc] |> List.filter_map (fun x -> x) (* Example: # get_lang ();; - : (string * string option) list = [("fr", None); ("fr", Some "FR")] *) let get_preferred_locales () = (* TODO use SDL version of this function *) get_lang () let locales = lazy (get_preferred_locales ()) let tf = Lazy.force let get_locales () = tf locales let str_fmt = Scanf.format_from_string "%s" "%s" let int_fmt = Scanf.format_from_string "%i" "%i" let uint_fmt = Scanf.format_from_string "%u" "%u" let uint2_fmt = Scanf.format_from_string "%u%u" "%u%u" (* We need to help the type checker here, in case these formats are not used elsewhere in this file: *) let _ = sprintf int_fmt 0 let _ = sprintf uint_fmt 0 let _ = sprintf uint2_fmt 0 0 let _ = sprintf str_fmt "" module type ContextInit = sig val gettext : string -> string val gettext_opt : string -> string option val tt : string -> string lazy_t val tf : 'a Lazy.t -> 'a val t_uint : string -> (int -> string) lazy_t val t_int : string -> (int -> string) lazy_t val t_str : string -> (string -> string) lazy_t val t_uint2 : string -> (int -> int -> string) lazy_t val add_translation : locale -> string -> string -> unit end (* Warning: we don't check whether a context with the same name already exist. If this happens, both contexts will share the same database. *) let make_context name = let module C = struct let context = name let gettext text = gettext ~context (tf locales) text let gettext_opt text = gettext_opt ~context (tf locales) text let tt text = lazy (gettext text) let tf = tf let t_str s = lazy (sprintf (Scanf.format_from_string (gettext s) str_fmt)) let t_int s = lazy (sprintf (Scanf.format_from_string (gettext s) int_fmt)) let t_uint s = lazy (sprintf (Scanf.format_from_string (gettext s) uint_fmt)) let t_uint2 s = lazy (sprintf (Scanf.format_from_string (gettext s) uint2_fmt)) let add_translation = add_translation ~context end in (module C : ContextInit) (* Now the predefined contexts *) module File = struct include (val (make_context "File") : ContextInit) let cancel = tt "Cancel" let close = tt "Close" let continue = tt "Continue" let enter_path = tt "Enter path" let modified = tt "Modified" let name = tt "Name" let no_selection = tt "No selection" let one_dir_selected = tt "1 directory selected" let one_file_selected = tt "1 file selected" let open_ = tt "Open" let open_dir = tt "Open dir" let save = tt "Save" let save_as = tt "Save as" let select = tt "Select" let select_directory = tt "Select directory" let select_dirs = tt "Select dirs" let select_file = tt "Select file" let select_files = tt "Select files" let size = tt "Size" let x_dirs_selected = t_uint "%u directories selected" let x_files_selected = t_uint "%u files selected" let x_files_x_dirs_selected = t_uint2 "%u files and %u dirs selected" let test () = let o = Lazy.force open_ in print_endline o end module Menu = struct include (val (make_context "Menu") : ContextInit) let copy = tt "Copy" let edit = tt "Edit" let open_ = tt "Open" let paste = tt "Paste" let save = tt "Save" let save_as = tt "Save as" end module Popup = struct include (val (make_context "Popup") : ContextInit) let cancel = tt "Cancel" let close = tt "Close" let no = tt "No" let yes = tt "Yes" end module Text_input = struct include (val (make_context "Text_input") : ContextInit) let prompt = tt "Enter text" end