1 /*
2  * Copyright (c) 2017-2018 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: 2017-2018 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;
38 
39 import selery.config : Files;
40 
41 deprecated("Use LanguageManager instead") alias Lang = LanguageManager;
42 
43 /**
44  * Stores translatable strings in various languages and provides
45  * methods to translate them with the provided arguments.
46  */
47 class LanguageManager {
48 
49 	private const Files files;
50 	public immutable string[] acceptedLanguages;
51 	public immutable string language;
52 
53 	private string[string] defaults;
54 
55 	private TranslationManager[string][string] messages;
56 	public string[string][string] raw; // used for web admin
57 
58 	public this(inout Files files, string language) {
59 		this.files = files;
60 		string[] accepted;
61 		bool languageAccepted = false;
62 		foreach(lang, countries; parseJSON(cast(string)files.readAsset("lang/languages.json")).object) {
63 			foreach(i, country; countries.array) {
64 				immutable code = lang ~ "_" ~ country.str.toUpper;
65 				accepted ~= code;
66 				if(i == 0) this.defaults[lang] = code;
67 			}
68 		}
69 		this.acceptedLanguages = accepted.idup;
70 		this.language = this.best(language);
71 	}
72 
73 	public inout string best(string language) {
74 		language = language.toLower;
75 		// return full language matching full language (en_GB : en_GB)
76 		foreach(lang ; this.acceptedLanguages) {
77 			if(language == lang.toLower) return lang;
78 		}
79 		// return full language matching language only (en : en_GB)
80 		if(language.length >= 2) {
81 			auto d = language[0..2] in this.defaults;
82 			if(d) return *d;
83 		}
84 		// return server's language
85 		return this.language;
86 	}
87 
88 	/**
89 	 * Loads languages in lang/system and lang/messages.
90 	 * Throws: RangeError if one of the given languages is not supported by the software.
91 	 */
92 	public inout void load() {
93 		foreach(type ; ["system", "messages"]) {
94 			foreach(lang ; acceptedLanguages) {
95 				immutable file = "lang/" ~ type ~ "/" ~ lang ~ ".lang";
96 				if(this.files.hasAsset(file)) this.add(lang, this.parseFile(cast(string)this.files.readAsset(file)));
97 			}
98 		}
99 	}
100 
101 	public inout string[string][string] parseFolder(string folder) {
102 		if(!folder.endsWith(dirSeparator)) folder ~= dirSeparator;
103 		string[string][string] ret;
104 		bool loadImpl(string lang, string file) {
105 			if(exists(file)) {
106 				ret[lang] = this.parseFile(cast(string)read(file));
107 				return true;
108 			} else {
109 				return false;
110 			}
111 		}
112 		foreach(lang ; acceptedLanguages) {
113 			if(!loadImpl(lang, folder ~ lang ~ ".lang")) loadImpl(lang, folder ~ lang[0..2] ~ ".lang");
114 		}
115 		return ret;
116 	}
117 
118 	private inout string[string] parseFile(string data) {
119 		string[string] ret;
120 		foreach(string line ; split(data, "\n")) {
121 			immutable equals = line.indexOf("=");
122 			if(equals != -1) {
123 				immutable message = line[0..equals].strip;
124 				immutable text = line[equals+1..$].strip;
125 				if(message.length && message[0] != '#') ret[message] = text;
126 			}
127 		}
128 		return ret;
129 	}
130 
131 	/**
132 	 * Adds messages using the given associative array of message:text.
133 	 */
134 	public void add(string language, string[string] messages) {
135 		foreach(message, text; messages) {
136 			this.raw[language][message] = text;
137 			Element[] elements;
138 			string next;
139 			ptrdiff_t index = -1;
140 			foreach(i, c; text) {
141 				if(index >= 0) {
142 					if(c == '}') {
143 						try {
144 							auto num = to!size_t(text[index+1..i]);
145 							if(next.length) {
146 								elements ~= Element(next);
147 								next.length = 0;
148 							}
149 							elements ~= Element(num);
150 						} catch(ConvException) {
151 							next ~= text[index..i+1];
152 						}
153 						index = -1;
154 					}
155 				} else {
156 					if(c == '{') {
157 						index = i;
158 					} else {
159 						next ~= c;
160 					}
161 				}
162 			}
163 			if(index >= 0) next ~= text[index..$];
164 			if(next.length) elements ~= Element(next);
165 			if(elements.length) this.messages[language][message] = TranslationManager(elements);
166 		}
167 	}
168 
169 	/// ditto
170 	public const void add(string language, string[string] messages) {
171 		(cast()this).add(language, messages);
172 	}
173 
174 	/**
175 	 * Translates a message in the given language with the given parameters.
176 	 * If the language is omitted the message is translated using the default
177 	 * language.
178 	 * Returns: the translated message if the language and the message exist or the message if not
179 	 */
180 	public inout string translate(inout string message, inout(string)[] params, string language) {
181 		auto lang = language in this.messages;
182 		if(lang) {
183 			auto translatable = message in *lang;
184 			if(translatable) {
185 				return (*translatable).build(params);
186 			}
187 		}
188 		return message;
189 	}
190 
191 	/// ditto
192 	public inout string translate(string message, string lang) {
193 		return this.translate(message, [], language);
194 	}
195 
196 	/// ditto
197 	public inout string translate(string message, string[] params=[]) {
198 		return this.translate(message, params, this.language);
199 	}
200 
201 	/// ditto
202 	public inout string translate(inout Translation translation, string language) {
203 		return this.translate(translation.translatable.default_, translation.parameters, language);
204 	}
205 
206 	/// ditto
207 	public inout string translate(inout Translation translation) {
208 		return this.translate(translation, this.language);
209 	}
210 
211 	private void loadImpl(string language, void[] data) {
212 		foreach(string line ; split(cast(string)data, "\n")) {
213 			immutable equals = line.indexOf("=");
214 			if(equals != -1) {
215 				immutable message = line[0..equals].strip;
216 				immutable text = line[equals+1..$].strip;
217 				if(message.length) {
218 					this.raw[language][message] = text;
219 					immutable comment = text.indexOf("##");
220 					Element[] elements;
221 					string next;
222 					ptrdiff_t index = -1;
223 					foreach(i, c; text[0..comment==-1?$:comment]) {
224 						if(index >= 0) {
225 							if(c == '}') {
226 								try {
227 									auto num = to!size_t(text[index+1..i]);
228 									if(next.length) {
229 										elements ~= Element(next);
230 										next.length = 0;
231 									}
232 									elements ~= Element(num);
233 								} catch(ConvException) {
234 									next ~= text[index..i+1];
235 								}
236 								index = -1;
237 							}
238 						} else {
239 							if(c == '{') {
240 								index = i;
241 							} else {
242 								next ~= c;
243 							}
244 						}
245 					}
246 					if(index >= 0) next ~= text[index..$];
247 					if(next.length) elements ~= Element(next);
248 					if(elements.length) this.messages[language][message] = TranslationManager(elements);
249 				}
250 			}
251 		}
252 	}
253 
254 	private static struct TranslationManager {
255 
256 		Element[] elements;
257 
258 		public inout string build(inout(string)[] args) {
259 			Appender!string ret;
260 			foreach(element ; this.elements) {
261 				if(element.isString) {
262 					ret.put(element.data);
263 				} else if(element.index < args.length) {
264 					ret.put(args[element.index]);
265 				} else {
266 					ret.put("{");
267 					ret.put(to!string(element.index));
268 					ret.put("}");
269 				}
270 			}
271 			return ret.data;
272 		}
273 
274 	}
275 
276 	private static struct Element {
277 
278 		union {
279 			string data;
280 			size_t index;
281 		}
282 
283 		public bool isString;
284 
285 		public this(string data) {
286 			this.data = data;
287 			this.isString = true;
288 		}
289 
290 		public this(size_t index) {
291 			this.index = index;
292 			this.isString = false;
293 		}
294 
295 	}
296 	
297 }
298 
299 struct Translation {
300 	
301 	public Translatable translatable;
302 	public string[] parameters;
303 	
304 	public this(E...)(Translatable translatable, E parameters) {
305 		this.translatable = translatable;
306 		foreach(param ; parameters) {
307 			static if(is(typeof(param) : string) || is(typeof(param) == string[])) this.parameters ~= param;
308 			else this.parameters ~= param.to!string;
309 		}
310 	}
311 	
312 	public this(E...)(string default_, E parameters) {
313 		this(Translatable.all(default_), parameters);
314 	}
315 
316 	public static Translation server(E...)(string default_, E parameters) {
317 		return Translation(Translatable(default_), parameters);
318 	}
319 	
320 }
321 
322 /**
323  * Translation container for a multi-platform translation.
324  * The `default_` translation should never be empty and it should be a string that can be
325  * loaded from a language file.
326  * The `minecraft` and `bedrock` strings can either be a client-side translated message
327  * or empty. In that case the `default_` string is translated server-side and sent
328  * to the client.
329  * Example:
330  * ---
331  * // server-side string
332  * Translatable("example.test");
333  * 
334  * // server-side for minecraft and client-side for bedrock
335  * Translatable("description.help", "", "commands.help.description");
336  * ---
337  */
338 struct Translatable {
339 	
340 	//TODO move somewhere else	
341 	enum MULTIPLAYER_JOINED = all("multiplayer.player.joined");
342 	enum MULTIPLAYER_LEFT = all("multiplayer.player.left");
343 	
344 	public static nothrow @safe @nogc Translatable all(inout string translation) {
345 		return Translatable(translation, translation, translation);
346 	}
347 	
348 	public static nothrow @safe @nogc Translatable fromJava(inout string translation) {
349 		return Translatable(translation, translation, "");
350 	}
351 	
352 	public static nothrow @safe @nogc Translatable fromBedrock(inout string translation) {
353 		return Translatable(translation, "", translation);
354 	}
355 	
356 	/// Values.
357 	public string default_, java, bedrock;
358 	
359 }