1 /** 2 Internationalization/translation support for the web interface module. 3 4 Copyright: © 2014-2015 RejectedSoftware e.K. 5 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. 6 Authors: Sönke Ludwig 7 */ 8 module hb.web.i18n; 9 10 import vibe.http.server : HTTPServerRequest; 11 import vibe.templ.parsertools; 12 13 import std.algorithm : canFind, min, startsWith; 14 15 16 /** 17 Annotates an interface method or class with translation information. 18 19 The translation context contains information about supported languages 20 and the translated strings. Any translations will be automatically 21 applied to Diet templates, as well as strings passed to 22 $(D hb.web.web.trWeb). 23 24 By default, the "Accept-Language" header of the incoming request will be 25 used to determine the language used. To override this behavior, add a 26 static method $(D determineLanguage) to the translation context, which 27 takes the request and returns a language string (see also the second 28 example). 29 */ 30 @property TranslationContextAttribute!CONTEXT translationContext(CONTEXT)() { return TranslationContextAttribute!CONTEXT.init; } 31 32 /// 33 unittest { 34 struct TranslationContext { 35 import std.typetuple; 36 alias languages = TypeTuple!("en_US", "de_DE", "fr_FR"); 37 //mixin translationModule!"app"; 38 //mixin translationModule!"somelib"; 39 } 40 41 @translationContext!TranslationContext 42 class MyWebInterface { 43 void getHome() 44 { 45 //render!("home.dt") 46 } 47 } 48 } 49 50 /// Defining a custom function for determining the language. 51 unittest { 52 import vibe.http.server; 53 54 struct TranslationContext { 55 import std.typetuple; 56 alias languages = TypeTuple!("en_US", "de_DE", "fr_FR"); 57 //mixin translationModule!"app"; 58 //mixin translationModule!"somelib"; 59 60 // use language settings from the session instead of using the 61 // "Accept-Language" header 62 static string determineLanguage(scope HTTPServerRequest req) 63 { 64 if (!req.session) return null; // use default language 65 return req.session.get("language", ""); 66 } 67 } 68 69 @translationContext!TranslationContext 70 class MyWebInterface { 71 void getHome() 72 { 73 //render!("home.dt") 74 } 75 } 76 } 77 78 79 struct TranslationContextAttribute(CONTEXT) { 80 alias Context = CONTEXT; 81 } 82 83 /* 84 doctype 5 85 html 86 body 87 p& Hello, World! 88 p& This is a translated version of #{appname}. 89 html 90 p(class="Sasdasd")&. 91 This is a complete paragraph of translated text. 92 */ 93 94 /** Makes a set of PO files available to a web interface class. 95 96 This mixin template needs to be mixed in at the class scope. It will parse all 97 translation files with the specified file name prefix and make their 98 translations available. 99 100 Params: 101 FILENAME = Base name of the set of PO files to mix in. A file with the 102 name "<FILENAME>.<LANGUAGE>.po" must be available as a string import 103 for each language defined in the translation context. 104 105 Bugs: 106 `FILENAME` should not contain (back)slash characters, as string imports 107 from sub directories will currently fail on Windows. See 108 $(LINK https://issues.dlang.org/show_bug.cgi?id=14349). 109 110 See_Also: `translationContext` 111 */ 112 mixin template translationModule(string FILENAME) 113 { 114 import std.string : tr; 115 enum NAME = FILENAME.tr(`/.-\`, "____"); 116 private mixin template file_mixin(size_t i) { 117 static if (i < languages.length) { 118 enum components = extractDeclStrings(import(FILENAME~"."~languages[i]~".po")); 119 mixin("enum "~languages[i]~"_"~NAME~" = components;"); 120 //mixin decls_mixin!(languages[i], 0); 121 mixin file_mixin!(i+1); 122 } 123 } 124 125 mixin file_mixin!0; 126 } 127 128 /** 129 Performs the string translation for a statically given language. 130 131 The second overload takes a plural form and a number to select from a set 132 of translations based on the plural forms of the target language. 133 */ 134 template tr(CTX, string LANG) 135 { 136 string tr(string key, string context = null) 137 { 138 return tr!(CTX, LANG)(key, null, 0, context); 139 } 140 141 string tr(string key, string key_plural, int n, string context = null) 142 { 143 static assert([CTX.languages].canFind(LANG), "Unknown language: "~LANG); 144 145 foreach (i, mname; __traits(allMembers, CTX)) { 146 static if (mname.startsWith(LANG~"_")) { 147 enum langComponents = __traits(getMember, CTX, mname); 148 foreach (entry; langComponents.messages) { 149 if ((context is null) == (entry.context is null)) { 150 if (context is null || entry.context == context) { 151 if (entry.key == key) { 152 if (key_plural !is null) { 153 if (entry.pluralKey !is null && entry.pluralKey == key_plural) { 154 static if (langComponents.nplurals_expr !is null && langComponents.plural_func_expr !is null) { 155 mixin("int nplurals = "~langComponents.nplurals_expr~";"); 156 if (nplurals > 0) { 157 mixin("int index = "~langComponents.plural_func_expr~";"); 158 return entry.pluralValues[index]; 159 } 160 return entry.value; 161 } 162 assert(false, "Plural translations are not supported when the po file does not contain an entry for Plural-Forms."); 163 } 164 } else { 165 return entry.value; 166 } 167 } 168 } 169 } 170 } 171 } 172 } 173 174 static if (is(typeof(CTX.enforceExistingKeys)) && CTX.enforceExistingKeys) { 175 if (key_plural !is null) { 176 if (context is null) { 177 assert(false, "Missing translation keys for "~LANG~": "~key~"&"~key_plural); 178 } 179 assert(false, "Missing translation key for "~LANG~"; "~context~": "~key~"&"~key_plural); 180 } 181 182 if (context is null) { 183 assert(false, "Missing translation key for "~LANG~": "~key); 184 } 185 assert(false, "Missing translation key for "~LANG~"; "~context~": "~key); 186 } else { 187 return n == 1 || !key_plural.length ? key : key_plural; 188 } 189 } 190 } 191 192 package string determineLanguage(alias METHOD)(scope HTTPServerRequest req) 193 { 194 import std.string : indexOf; 195 import std.array; 196 197 alias CTX = GetTranslationContext!METHOD; 198 199 static if (!is(CTX == void)) { 200 static if (is(typeof(CTX.determineLanguage(req)))) { 201 static assert(is(typeof(CTX.determineLanguage(req)) == string), 202 "determineLanguage in a translation context must return a language string."); 203 return CTX.determineLanguage(req); 204 } else { 205 auto accept_lang = req.headers.get("Accept-Language", null); 206 207 size_t csidx = 0; 208 while (accept_lang.length) { 209 auto cidx = accept_lang[csidx .. $].indexOf(','); 210 if (cidx < 0) cidx = accept_lang.length; 211 auto entry = accept_lang[csidx .. csidx + cidx]; 212 auto sidx = entry.indexOf(';'); 213 if (sidx < 0) sidx = entry.length; 214 auto entrylang = entry[0 .. sidx]; 215 216 foreach (lang; CTX.languages) { 217 if (entrylang == replace(lang, "_", "-")) return lang; 218 if (entrylang == split(lang, "_")[0]) return lang; // FIXME: ensure that only one single-lang entry exists! 219 } 220 221 if (cidx >= accept_lang.length) break; 222 accept_lang = accept_lang[cidx+1 .. $]; 223 } 224 225 return null; 226 } 227 } else return null; 228 } 229 230 unittest { // make sure that the custom determineLanguage is called 231 static struct CTX { 232 static string determineLanguage(Object a) { return "test"; } 233 } 234 @translationContext!CTX 235 static class Test { 236 void test() 237 { 238 } 239 } 240 auto test = new Test; 241 assert(determineLanguage!(test.test)(null) == "test"); 242 } 243 244 package template GetTranslationContext(alias METHOD) 245 { 246 import vibe.internal.meta.uda; 247 248 alias PARENT = typeof(__traits(parent, METHOD).init); 249 enum FUNCTRANS = findFirstUDA!(TranslationContextAttribute, METHOD); 250 enum PARENTTRANS = findFirstUDA!(TranslationContextAttribute, PARENT); 251 static if (FUNCTRANS.found) alias GetTranslationContext = FUNCTRANS.value.Context; 252 else static if (PARENTTRANS.found) alias GetTranslationContext = PARENTTRANS.value.Context; 253 else alias GetTranslationContext = void; 254 } 255 256 257 private struct DeclString { 258 string context; 259 string key; 260 string pluralKey; 261 string value; 262 string[] pluralValues; 263 } 264 265 private struct LangComponents { 266 DeclString[] messages; 267 string nplurals_expr; 268 string plural_func_expr; 269 } 270 271 // Example po header 272 /* 273 * # Translation of kstars.po into Spanish. 274 * # This file is distributed under the same license as the kdeedu package. 275 * # Pablo de Vicente <pablo@foo.com>, 2005, 2006, 2007, 2008. 276 * # Eloy Cuadra <eloy@bar.net>, 2007, 2008. 277 * msgid "" 278 * msgstr "" 279 * "Project-Id-Version: kstars\n" 280 * "Report-Msgid-Bugs-To: http://bugs.kde.org\n" 281 * "POT-Creation-Date: 2008-09-01 09:37+0200\n" 282 * "PO-Revision-Date: 2008-07-22 18:13+0200\n" 283 * "Last-Translator: Eloy Cuadra <eloy@bar.net>\n" 284 * "Language-Team: Spanish <kde-l10n-es@kde.org>\n" 285 * "MIME-Version: 1.0\n" 286 * "Content-Type: text/plain; charset=UTF-8\n" 287 * "Content-Transfer-Encoding: 8bit\n" 288 * "Plural-Forms: nplurals=2; plural=n != 1;\n" 289 */ 290 291 // PO format notes 292 /* 293 * # - Translator comment 294 * #: - source reference 295 * #. - extracted comments 296 * #, - flags such as "c-format" to indicate what king of substitutions may be present 297 * #| msgid - previous string comment 298 * #~ - obsolete message 299 * msgctxt - disabmbiguating context, like variable scope (optional, defaults to null) 300 * msgid - key to translate from (required) 301 * msgid_plural - plural form of the msg id (optional) 302 * msgstr - value to translate to (required) 303 * msgstr[0] - indexed translation for handling the various plural forms 304 * msgstr[1] - ditto 305 * msgstr[2] - ditto and etc... 306 */ 307 308 LangComponents extractDeclStrings(string text) 309 { 310 DeclString[] declStrings; 311 string nplurals_expr; 312 string plural_func_expr; 313 314 size_t i = 0; 315 while (true) { 316 i = skipToDirective(i, text); 317 if (i >= text.length) break; 318 319 string context = null; 320 321 // msgctxt is an optional field 322 if (text.length - i >= 7 && text[i .. i+7] == "msgctxt") { 323 i = skipWhitespace(i+7, text); 324 325 auto icntxt = skipString(i, text); 326 context = dstringUnescape(wrapText(text[i+1 .. icntxt-1])); 327 i = skipToDirective(icntxt, text); 328 } 329 330 // msgid is a required field 331 assert(text.length - i >= 5 && text[i .. i+5] == "msgid", "Expected 'msgid', got '"~text[i .. min(i+10, $)]~"'."); 332 i += 5; 333 334 i = skipWhitespace(i, text); 335 336 auto iknext = skipString(i, text); 337 auto key = dstringUnescape(wrapText(text[i+1 .. iknext-1])); 338 i = iknext; 339 340 i = skipToDirective(i, text); 341 342 // msgid_plural is an optional field 343 string key_plural = null; 344 if (text.length - i >= 12 && text[i .. i+12] == "msgid_plural") { 345 i = skipWhitespace(i+12, text); 346 auto iprl = skipString(i, text); 347 key_plural = dstringUnescape(wrapText(text[i+1 .. iprl-1])); 348 i = skipToDirective(iprl, text); 349 } 350 351 // msgstr is a required field 352 assert(text.length - i >= 6 && text[i .. i+6] == "msgstr", "Expected 'msgstr', got '"~text[i .. min(i+10, $)]~"'."); 353 i += 6; 354 355 i = skipWhitespace(i, text); 356 auto ivnext = skipString(i, text); 357 auto value = dstringUnescape(wrapText(text[i+1 .. ivnext-1])); 358 i = ivnext; 359 i = skipToDirective(i, text); 360 361 // msgstr[n] is a required field when msgid_plural is not null, and ignored otherwise 362 string[] value_plural; 363 if (key_plural !is null) { 364 while (text.length - i >= 6 && text[i .. i+6] == "msgstr") { 365 i = skipIndex(i+6, text); 366 i = skipWhitespace(i, text); 367 auto ims = skipString(i, text); 368 369 string plural = dstringUnescape(wrapText(text[i+1 .. ims-1])); 370 i = skipLine(ims, text); 371 372 // Is it safe to assume that the entries are always sequential? 373 value_plural ~= plural; 374 } 375 } 376 377 // Add the translation for the current language 378 if (key == "") { 379 nplurals_expr = parse_nplurals(value); 380 plural_func_expr = parse_plural_expression(value); 381 } 382 383 declStrings ~= DeclString(context, key, key_plural, value, value_plural); 384 } 385 386 return LangComponents(declStrings, nplurals_expr, plural_func_expr); 387 } 388 389 // Verify that two simple messages can be read and parsed correctly 390 unittest { 391 auto str = ` 392 # first string 393 msgid "ordinal.1" 394 msgstr "first" 395 396 # second string 397 msgid "ordinal.2" 398 msgstr "second"`; 399 400 auto components = extractDeclStrings(str); 401 auto ds = components.messages; 402 assert(2 == ds.length, "Not enough DeclStrings have been processed"); 403 assert(ds[0].key == "ordinal.1", "The first key is not right."); 404 assert(ds[0].value == "first", "The first value is not right."); 405 assert(ds[1].key == "ordinal.2", "The second key is not right."); 406 assert(ds[1].value == "second", "The second value is not right."); 407 } 408 409 // Verify that the fields cannot be defined out of order 410 unittest { 411 import core.exception : AssertError; 412 import std.exception : assertThrown; 413 414 auto str1 = ` 415 # unexpected field ahead 416 msgstr "world" 417 msgid "hello"`; 418 419 assertThrown!AssertError(extractDeclStrings(str1)); 420 } 421 422 // Verify that string wrapping is handled correctly 423 unittest { 424 auto str = ` 425 # The following text is wrapped 426 msgid "" 427 "This is an example of text that " 428 "has been wrapped on two lines." 429 msgstr "" 430 "It should not matter where it takes place, " 431 "the strings should all be concatenated properly."`; 432 433 auto ds = extractDeclStrings(str).messages; 434 assert(1 == ds.length, "Expected one DeclString to have been processed."); 435 assert(ds[0].key == "This is an example of text that has been wrapped on two lines.", "Failed to properly wrap the key"); 436 assert(ds[0].value == "It should not matter where it takes place, the strings should all be concatenated properly.", "Failed to properly wrap the key"); 437 } 438 439 // Verify that string wrapping and unescaping is handled correctly on example of PO headers 440 unittest { 441 auto str = ` 442 # English translations for ThermoWebUI package. 443 # This file is put in the public domain. 444 # Automatically generated, 2015. 445 # 446 msgid "" 447 msgstr "" 448 "Project-Id-Version: PROJECT VERSION\n" 449 "Report-Msgid-Bugs-To: developer@example.com\n" 450 "POT-Creation-Date: 2015-04-13 17:55+0600\n" 451 "PO-Revision-Date: 2015-04-13 14:13+0600\n" 452 "Last-Translator: Automatically generated\n" 453 "Language-Team: none\n" 454 "Language: en\n" 455 "MIME-Version: 1.0\n" 456 "Content-Type: text/plain; charset=UTF-8\n" 457 "Content-Transfer-Encoding: 8bit\n" 458 "Plural-Forms: nplurals=2; plural=(n != 1);\n" 459 `; 460 auto expected = `Project-Id-Version: PROJECT VERSION 461 Report-Msgid-Bugs-To: developer@example.com 462 POT-Creation-Date: 2015-04-13 17:55+0600 463 PO-Revision-Date: 2015-04-13 14:13+0600 464 Last-Translator: Automatically generated 465 Language-Team: none 466 Language: en 467 MIME-Version: 1.0 468 Content-Type: text/plain; charset=UTF-8 469 Content-Transfer-Encoding: 8bit 470 Plural-Forms: nplurals=2; plural=(n != 1); 471 `; 472 473 auto ds = extractDeclStrings(str).messages; 474 assert(1 == ds.length, "Expected one DeclString to have been processed."); 475 assert(ds[0].key == "", "Failed to properly wrap or unescape the key"); 476 assert(ds[0].value == expected, "Failed to properly wrap or unescape the value"); 477 } 478 479 // Verify that the message context is properly parsed 480 unittest { 481 auto str1 = ` 482 # "C" is for cookie 483 msgctxt "food" 484 msgid "C" 485 msgstr "C is for cookie, that's good enough for me."`; 486 487 auto ds1 = extractDeclStrings(str1).messages; 488 assert(1 == ds1.length, "Expected one DeclString to have been processed."); 489 assert(ds1[0].context == "food", "Expected a context of food"); 490 assert(ds1[0].key == "C", "Expected to find the letter C for the msgid."); 491 assert(ds1[0].value == "C is for cookie, that's good enough for me.", "Unexpected value encountered for the msgstr."); 492 493 auto str2 = ` 494 # No context validation 495 msgid "alpha" 496 msgstr "First greek letter."`; 497 498 auto ds2 = extractDeclStrings(str2).messages; 499 assert(1 == ds2.length, "Expected one DeclString to have been processed."); 500 assert(ds2[0].context is null, "Expected the context to be null when it is not defined."); 501 } 502 503 unittest { 504 enum str = ` 505 # "C" is for cookie 506 msgctxt "food" 507 msgid "C" 508 msgstr "C is for cookie, that's good enough for me." 509 510 # "C" is for language 511 msgctxt "lang" 512 msgid "C" 513 msgstr "Catalan" 514 515 # Just "C" 516 msgid "C" 517 msgstr "Third letter" 518 `; 519 520 enum components = extractDeclStrings(str); 521 522 struct TranslationContext { 523 import std.typetuple; 524 enum enforceExistingKeys = true; 525 alias languages = TypeTuple!("en_US"); 526 527 // Note that this is normally handled by mixing in an external file. 528 enum en_US_unittest = components; 529 } 530 531 auto newTr(string msgid, string msgcntxt = null) { 532 return tr!(TranslationContext, "en_US")(msgid, msgcntxt); 533 } 534 535 assert(newTr("C", "food") == "C is for cookie, that's good enough for me.", "Unexpected translation based on context."); 536 assert(newTr("C", "lang") == "Catalan", "Unexpected translation based on context."); 537 assert(newTr("C") == "Third letter", "Unexpected translation based on context."); 538 } 539 540 unittest { 541 enum str = `msgid "" 542 msgstr "" 543 "Project-Id-Version: kstars\\n" 544 "Plural-Forms: nplurals=2; plural=n != 1;\\n" 545 546 msgid "One file was deleted." 547 msgid_plural "Files were deleted." 548 msgstr "One file was deleted." 549 msgstr[0] "1 file was deleted." 550 msgstr[1] "%d files were deleted." 551 552 msgid "One file was created." 553 msgid_plural "Several files were created." 554 msgstr "One file was created." 555 msgstr[0] "1 file was created" 556 msgstr[1] "%d files were created." 557 `; 558 559 import std.stdio; 560 enum components = extractDeclStrings(str); 561 562 struct TranslationContext { 563 import std.typetuple; 564 enum enforceExistingKeys = true; 565 alias languages = TypeTuple!("en_US"); 566 567 // Note that this is normally handled by mixing in an external file. 568 enum en_US_unittest2 = components; 569 } 570 571 auto newTr(string msgid, string msgid_plural, int count, string msgcntxt = null) { 572 return tr!(TranslationContext, "en_US")(msgid, msgid_plural, count, msgcntxt); 573 } 574 575 string expected = "1 file was deleted."; 576 auto actual = newTr("One file was deleted.", "Files were deleted.", 1); 577 assert(expected == actual, "Expected: '"~expected~"' but got '"~actual~"'"); 578 579 expected = "%d files were deleted."; 580 actual = newTr("One file was deleted.", "Files were deleted.", 42); 581 assert(expected == actual, "Expected: '"~expected~"' but got '"~actual~"'"); 582 } 583 584 private size_t skipToDirective(size_t i, ref string text) 585 { 586 while (i < text.length) { 587 i = skipWhitespace(i, text); 588 if (i < text.length && text[i] == '#') i = skipLine(i, text); 589 else break; 590 } 591 return i; 592 } 593 594 private size_t skipWhitespace(size_t i, ref string text) 595 { 596 while (i < text.length && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r')) 597 i++; 598 return i; 599 } 600 601 private size_t skipLine(size_t i, ref string text) 602 { 603 while (i < text.length && text[i] != '\r' && text[i] != '\n') i++; 604 if (i+1 < text.length && (text[i+1] == '\r' || text[i+1] == '\n') && text[i] != text[i+1]) i++; 605 return i+1; 606 } 607 608 private size_t skipString(size_t i, ref string text) 609 { 610 import std.conv : to; 611 assert(text[i] == '"', "Expected to encounter the start of a string at position: "~to!string(i)); 612 i++; 613 while (true) { 614 assert(i < text.length, "Missing closing '\"' for string: "~text[i .. min($, 10)]); 615 if (text[i] == '"') { 616 if (i+1 < text.length) { 617 auto j = skipWhitespace(i+1, text); 618 if (j<text.length && text[j] == '"') return skipString(j, text); 619 } 620 return i+1; 621 } 622 if (text[i] == '\\') i += 2; 623 else i++; 624 } 625 } 626 627 private size_t skipIndex(size_t i, ref string text) { 628 import std.conv : to; 629 assert(text[i] == '[', "Expected to encounter a plural form of msgstr at position: "~to!string(i)); 630 for (; i<text.length; ++i) { 631 if (text[i] == ']') { 632 return i+1; 633 } 634 } 635 assert(false, "Missing a ']' for a msgstr in a translation file."); 636 } 637 638 private string wrapText(string str) 639 { 640 string ret; 641 bool wrapped = false; 642 643 for (size_t i=0; i<str.length; ++i) { 644 if (str[i] == '\\') { 645 assert(i+1 < str.length, "The string ends with the escape char: " ~ str); 646 ret ~= str[i..i+2]; 647 ++i; 648 } else if (str[i] == '"') { 649 wrapped = true; 650 size_t j = skipWhitespace(i+1, str); 651 if (j < str.length && str[j] == '"') { 652 i=j; 653 } 654 } else ret ~= str[i]; 655 } 656 657 if (wrapped) return ret; 658 return str; 659 } 660 661 private string parse_nplurals(string msgstr) 662 in { assert(msgstr, "An empty string cannot be parsed for Plural-Forms."); } 663 body { 664 import std.string : indexOf, CaseSensitive; 665 666 auto start = msgstr.indexOf("Plural-Forms:", CaseSensitive.no); 667 if (start > -1) { 668 auto beg = msgstr.indexOf("nplurals=", start+13, CaseSensitive.no); 669 if (beg > -1) { 670 auto end = msgstr.indexOf(';', beg+9, CaseSensitive.no); 671 if (end > -1) { 672 return msgstr[beg+9 .. end]; 673 } 674 return msgstr[beg+9 .. $]; 675 } 676 } 677 678 return null; 679 } 680 681 unittest { 682 auto res = parse_nplurals("Plural-Forms: nplurals=2; plural=n != 1;\n"); 683 assert(res == "2", "Failed to parse the correct number of plural forms for a language."); 684 } 685 686 private string parse_plural_expression(string msgstr) 687 in { assert(msgstr, "An empty string cannot be parsed for Plural-Forms."); } 688 body { 689 import std.string : indexOf, CaseSensitive; 690 691 auto start = msgstr.indexOf("Plural-Forms:", CaseSensitive.no); 692 if (start > -1) { 693 auto beg = msgstr.indexOf("plural=", start+13, CaseSensitive.no); 694 if (beg > -1) { 695 auto end = msgstr.indexOf(';', beg+7, CaseSensitive.no); 696 if (end > -1) { 697 return msgstr[beg+7 .. end]; 698 } 699 return msgstr[beg+7 .. $]; 700 } 701 } 702 703 return null; 704 } 705 706 unittest { 707 auto res = parse_plural_expression("Plural-Forms: nplurals=2; plural=n != 1;\n"); 708 assert(res == "n != 1", "Failed to parse the plural expression for a language."); 709 }