1 /* 2 * Copyright (c) 2017-2019 sel-project 3 * 4 * Permission is hereby granted, free of charge, to any person obtaining a copy 5 * of this software and associated documentation files (the "Software"), to deal 6 * in the Software without restriction, including without limitation the rights 7 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 * copies of the Software, and to permit persons to whom the Software is 9 * furnished to do so, subject to the following conditions: 10 * 11 * The above copyright notice and this permission notice shall be included in all 12 * copies or substantial portions of the Software. 13 * 14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 * SOFTWARE. 21 * 22 */ 23 /** 24 * Copyright: Copyright (c) 2017-2019 sel-project 25 * License: MIT 26 * Authors: Kripth 27 * Source: $(HTTP github.com/sel-project/selery/source/selery/lang.d, selery/lang.d) 28 */ 29 module selery.lang; 30 31 import std.algorithm : canFind; 32 import std.array : Appender; 33 import std.conv : to, ConvException; 34 import std.file : exists, read; 35 import std.json : parseJSON; 36 import std.path : dirSeparator; 37 import std.string : toUpper, toLower, endsWith, split, indexOf, strip; 38 39 import selery.config : Files; 40 import selery.plugin : Plugin; 41 42 deprecated("Use LanguageManager instead") alias Lang = LanguageManager; 43 44 /** 45 * Stores translatable strings in various languages and provides 46 * methods to translate them with the provided arguments. 47 */ 48 class LanguageManager { 49 50 private const Files files; 51 public immutable string[] acceptedLanguages; 52 public immutable string language; 53 54 private string[string] defaults; 55 56 private TranslationManager[string][string] messages; 57 public string[string][string] raw; // used for web admin 58 59 public this(inout Files files, string language) { 60 this.files = files; 61 string[] accepted; 62 bool languageAccepted = false; 63 foreach(lang, countries; parseJSON(cast(string)files.readAsset("lang/languages.json")).object) { 64 foreach(i, country; countries.array) { 65 immutable code = lang ~ "_" ~ country.str.toUpper; 66 accepted ~= code; 67 if(i == 0) this.defaults[lang] = code; 68 } 69 } 70 this.acceptedLanguages = accepted.idup; 71 this.language = this.best(language); 72 } 73 74 public inout string best(string language) { 75 language = language.toLower; 76 // return full language matching full language (en_GB : en_GB) 77 foreach(lang ; this.acceptedLanguages) { 78 if(language == lang.toLower) return lang; 79 } 80 // return full language matching language only (en : en_GB) 81 if(language.length >= 2) { 82 auto d = language[0..2] in this.defaults; 83 if(d) return *d; 84 } 85 // return server's language 86 return this.language; 87 } 88 89 /** 90 * Loads languages in assets/lang/system and assets/lang/messages. 91 * Throws: RangeError if one of the given languages is not supported by the software. 92 */ 93 public inout void load() { 94 foreach(type ; ["system", "messages"]) { 95 foreach(lang ; acceptedLanguages) { 96 immutable file = "lang/" ~ type ~ "/" ~ lang ~ ".lang"; 97 if(this.files.hasAsset(file)) this.add(lang, this.parseFile(cast(string)this.files.readAsset(file))); 98 } 99 } 100 } 101 102 /** 103 * Loads languages from plugin's assets files, located in plugins/$plugin/assets/lang. 104 */ 105 public inout string[string][string] loadPlugin(Plugin plugin) { 106 immutable folder = "lang" ~ dirSeparator; 107 string[string][string] ret; 108 bool loadImpl(string lang, string file) { 109 if(this.files.hasPluginAsset(plugin, file)) { 110 ret[lang] = this.parseFile(cast(string)this.files.readPluginAsset(plugin, file)); 111 return true; 112 } else { 113 return false; 114 } 115 } 116 foreach(lang ; acceptedLanguages) { 117 if(!loadImpl(lang, folder ~ lang ~ ".lang")) loadImpl(lang, folder ~ lang[0..2] ~ ".lang"); 118 } 119 return ret; 120 } 121 122 private inout string[string] parseFile(string data) { 123 string[string] ret; 124 foreach(string line ; split(data, "\n")) { 125 immutable equals = line.indexOf("="); 126 if(equals != -1) { 127 immutable message = line[0..equals].strip; 128 immutable text = line[equals+1..$].strip; 129 if(message.length && message[0] != '#') ret[message] = text; 130 } 131 } 132 return ret; 133 } 134 135 /** 136 * Adds messages using the given associative array of message:text. 137 */ 138 public void add(string language, string[string] messages) { 139 foreach(message, text; messages) { 140 this.raw[language][message] = text; 141 Element[] elements; 142 string next; 143 ptrdiff_t index = -1; 144 foreach(i, c; text) { 145 if(index >= 0) { 146 if(c == '}') { 147 try { 148 auto num = to!size_t(text[index+1..i]); 149 if(next.length) { 150 elements ~= Element(next); 151 next.length = 0; 152 } 153 elements ~= Element(num); 154 } catch(ConvException) { 155 next ~= text[index..i+1]; 156 } 157 index = -1; 158 } 159 } else { 160 if(c == '{') { 161 index = i; 162 } else { 163 next ~= c; 164 } 165 } 166 } 167 if(index >= 0) next ~= text[index..$]; 168 if(next.length) elements ~= Element(next); 169 if(elements.length) this.messages[language][message] = TranslationManager(elements); 170 } 171 } 172 173 /// ditto 174 public const void add(string language, string[string] messages) { 175 (cast()this).add(language, messages); 176 } 177 178 /** 179 * Translates a message in the given language with the given parameters. 180 * If the language is omitted the message is translated using the default 181 * language. 182 * Returns: the translated message if the language and the message exist or the message if not 183 */ 184 public inout string translate(inout string message, inout(string)[] params, string language) { 185 auto lang = language in this.messages; 186 if(lang) { 187 auto translatable = message in *lang; 188 if(translatable) { 189 return (*translatable).build(params); 190 } 191 } 192 return message; 193 } 194 195 /// ditto 196 public inout string translate(string message, string lang) { 197 return this.translate(message, [], language); 198 } 199 200 /// ditto 201 public inout string translate(string message, string[] params=[]) { 202 return this.translate(message, params, this.language); 203 } 204 205 /// ditto 206 public inout string translate(inout Translation translation, string language) { 207 return this.translate(translation.translatable.default_, translation.parameters, language); 208 } 209 210 /// ditto 211 public inout string translate(inout Translation translation) { 212 return this.translate(translation, this.language); 213 } 214 215 private void loadImpl(string language, void[] data) { 216 foreach(string line ; split(cast(string)data, "\n")) { 217 immutable equals = line.indexOf("="); 218 if(equals != -1) { 219 immutable message = line[0..equals].strip; 220 immutable text = line[equals+1..$].strip; 221 if(message.length) { 222 this.raw[language][message] = text; 223 immutable comment = text.indexOf("##"); 224 Element[] elements; 225 string next; 226 ptrdiff_t index = -1; 227 foreach(i, c; text[0..comment==-1?$:comment]) { 228 if(index >= 0) { 229 if(c == '}') { 230 try { 231 auto num = to!size_t(text[index+1..i]); 232 if(next.length) { 233 elements ~= Element(next); 234 next.length = 0; 235 } 236 elements ~= Element(num); 237 } catch(ConvException) { 238 next ~= text[index..i+1]; 239 } 240 index = -1; 241 } 242 } else { 243 if(c == '{') { 244 index = i; 245 } else { 246 next ~= c; 247 } 248 } 249 } 250 if(index >= 0) next ~= text[index..$]; 251 if(next.length) elements ~= Element(next); 252 if(elements.length) this.messages[language][message] = TranslationManager(elements); 253 } 254 } 255 } 256 } 257 258 private static struct TranslationManager { 259 260 Element[] elements; 261 262 public inout string build(inout(string)[] args) { 263 Appender!string ret; 264 foreach(element ; this.elements) { 265 if(element.isString) { 266 ret.put(element.data); 267 } else if(element.index < args.length) { 268 ret.put(args[element.index]); 269 } else { 270 ret.put("{"); 271 ret.put(to!string(element.index)); 272 ret.put("}"); 273 } 274 } 275 return ret.data; 276 } 277 278 } 279 280 private static struct Element { 281 282 union { 283 string data; 284 size_t index; 285 } 286 287 public bool isString; 288 289 public this(string data) { 290 this.data = data; 291 this.isString = true; 292 } 293 294 public this(size_t index) { 295 this.index = index; 296 this.isString = false; 297 } 298 299 } 300 301 } 302 303 struct Translation { 304 305 public Translatable translatable; 306 public string[] parameters; 307 308 public this(E...)(Translatable translatable, E parameters) { 309 this.translatable = translatable; 310 foreach(param ; parameters) { 311 static if(is(typeof(param) : string) || is(typeof(param) == string[])) this.parameters ~= param; 312 else this.parameters ~= param.to!string; 313 } 314 } 315 316 public this(E...)(string default_, E parameters) { 317 this(Translatable.all(default_), parameters); 318 } 319 320 public static Translation server(E...)(string default_, E parameters) { 321 return Translation(Translatable(default_), parameters); 322 } 323 324 } 325 326 /** 327 * Translation container for a multi-platform translation. 328 * The `default_` translation should never be empty and it should be a string that can be 329 * loaded from a language file. 330 * The `minecraft` and `bedrock` strings can either be a client-side translated message 331 * or empty. In that case the `default_` string is translated server-side and sent 332 * to the client. 333 * Example: 334 * --- 335 * // server-side string 336 * Translatable("example.test"); 337 * 338 * // server-side for minecraft and client-side for bedrock 339 * Translatable("description.help", "", "commands.help.description"); 340 * --- 341 */ 342 struct Translatable { 343 344 //TODO move somewhere else 345 enum MULTIPLAYER_JOINED = all("multiplayer.player.joined"); 346 enum MULTIPLAYER_LEFT = all("multiplayer.player.left"); 347 348 public static nothrow @safe @nogc Translatable all(inout string translation) { 349 return Translatable(translation, translation, translation); 350 } 351 352 public static nothrow @safe @nogc Translatable fromJava(inout string translation) { 353 return Translatable(translation, translation, ""); 354 } 355 356 public static nothrow @safe @nogc Translatable fromBedrock(inout string translation) { 357 return Translatable(translation, "", translation); 358 } 359 360 /// Values. 361 public string default_, java, bedrock; 362 363 }