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 }