001/* 002 * Copyright 2017-2022 Product Mog LLC, 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.lokalized; 018 019import com.lokalized.LocalizedString.LanguageFormTranslation; 020import com.lokalized.LocalizedString.LanguageFormTranslationRange; 021import com.lokalized.MinimalJson.Json; 022import com.lokalized.MinimalJson.JsonArray; 023import com.lokalized.MinimalJson.JsonObject; 024import com.lokalized.MinimalJson.JsonObject.Member; 025import com.lokalized.MinimalJson.JsonValue; 026import org.jspecify.annotations.NonNull; 027import org.jspecify.annotations.Nullable; 028 029import javax.annotation.concurrent.ThreadSafe; 030import java.io.File; 031import java.io.IOException; 032import java.io.InputStream; 033import java.net.JarURLConnection; 034import java.net.URISyntaxException; 035import java.net.URL; 036import java.nio.file.Files; 037import java.nio.file.Path; 038import java.nio.file.Paths; 039import java.util.ArrayList; 040import java.util.Arrays; 041import java.util.Collections; 042import java.util.Enumeration; 043import java.util.HashSet; 044import java.util.LinkedHashMap; 045import java.util.LinkedHashSet; 046import java.util.List; 047import java.util.Locale; 048import java.util.Map; 049import java.util.Set; 050import java.util.TreeMap; 051import java.util.jar.JarEntry; 052import java.util.jar.JarFile; 053import java.util.logging.Logger; 054import java.util.regex.Pattern; 055import java.util.stream.Collectors; 056 057import static java.lang.String.format; 058import static java.nio.charset.StandardCharsets.UTF_8; 059import static java.util.Objects.requireNonNull; 060 061/** 062 * Utility methods for loading localized strings files. 063 * 064 * @author <a href="https://revetkn.com">Mark Allen</a> 065 */ 066@ThreadSafe 067public final class LocalizedStringLoader { 068 @NonNull 069 private static final Map<@NonNull String, @NonNull LanguageForm> SUPPORTED_LANGUAGE_FORMS_BY_NAME; 070 @NonNull 071 private static final Logger LOGGER; 072 @NonNull 073 private static final ExpressionEvaluator EXPRESSION_EVALUATOR; 074 @NonNull 075 private static final Pattern PLACEHOLDER_NAME_PATTERN; 076 @NonNull 077 private static final Pattern LANGUAGE_TAG_PATTERN; 078 @NonNull 079 private static final String JSON_EXTENSION; 080 081 static { 082 LOGGER = Logger.getLogger(LoggerType.LOCALIZED_STRING_LOADER.getLoggerName()); 083 EXPRESSION_EVALUATOR = new ExpressionEvaluator(); 084 085 Set<@NonNull LanguageForm> supportedLanguageForms = new LinkedHashSet<>(); 086 supportedLanguageForms.addAll(Arrays.asList(Gender.values())); 087 supportedLanguageForms.addAll(Arrays.asList(Formality.values())); 088 supportedLanguageForms.addAll(Arrays.asList(Clusivity.values())); 089 supportedLanguageForms.addAll(Arrays.asList(Animacy.values())); 090 supportedLanguageForms.addAll(Arrays.asList(Cardinality.values())); 091 supportedLanguageForms.addAll(Arrays.asList(Ordinality.values())); 092 supportedLanguageForms.addAll(Arrays.asList(Phonetic.values())); 093 094 Map<@NonNull String, @NonNull LanguageForm> supportedLanguageFormsByName = new LinkedHashMap<>(); 095 096 for (LanguageForm languageForm : supportedLanguageForms) { 097 if (!languageForm.getClass().isEnum()) 098 throw new IllegalArgumentException(format("The %s interface must be implemented by enum types. %s is not an enum", 099 LanguageForm.class.getSimpleName(), languageForm.getClass().getSimpleName())); 100 101 String languageFormName = ((Enum<?>) languageForm).name(); 102 LanguageForm existingLanguageForm = supportedLanguageFormsByName.get(languageFormName); 103 104 if (existingLanguageForm != null) 105 throw new IllegalArgumentException(format("There is already a language form %s.%s whose name collides with %s.%s. " + 106 "Language form names must be unique", existingLanguageForm.getClass().getSimpleName(), languageFormName, 107 languageForm.getClass().getSimpleName(), languageFormName)); 108 109 // Massage Cardinality to match file format, e.g. "ONE" -> "CARDINALITY_ONE" 110 if (languageForm instanceof Cardinality) 111 languageFormName = LocalizedStringUtils.localizedStringNameForCardinalityName(languageFormName); 112 113 // Massage Ordinality to match file format, e.g. "ONE" -> "ORDINALITY_ONE" 114 if (languageForm instanceof Ordinality) 115 languageFormName = LocalizedStringUtils.localizedStringNameForOrdinalityName(languageFormName); 116 117 // Massage Gender to match file format, e.g. "MASCULINE" -> "GENDER_MASCULINE" 118 if (languageForm instanceof Gender) 119 languageFormName = LocalizedStringUtils.localizedStringNameForGenderName(languageFormName); 120 121 // Massage Formality to match file format, e.g. "FORMAL" -> "FORMALITY_FORMAL" 122 if (languageForm instanceof Formality) 123 languageFormName = LocalizedStringUtils.localizedStringNameForFormalityName(languageFormName); 124 125 // Massage Clusivity to match file format, e.g. "INCLUSIVE" -> "CLUSIVITY_INCLUSIVE" 126 if (languageForm instanceof Clusivity) 127 languageFormName = LocalizedStringUtils.localizedStringNameForClusivityName(languageFormName); 128 129 // Massage Animacy to match file format, e.g. "ANIMATE" -> "ANIMACY_ANIMATE" 130 if (languageForm instanceof Animacy) 131 languageFormName = LocalizedStringUtils.localizedStringNameForAnimacyName(languageFormName); 132 133 // Massage Phonetic to match file format, e.g. "VOWEL" -> "PHONETIC_VOWEL" 134 if (languageForm instanceof Phonetic) 135 languageFormName = LocalizedStringUtils.localizedStringNameForPhoneticName(languageFormName); 136 137 supportedLanguageFormsByName.put(languageFormName, languageForm); 138 } 139 140 SUPPORTED_LANGUAGE_FORMS_BY_NAME = Collections.unmodifiableMap(supportedLanguageFormsByName); 141 PLACEHOLDER_NAME_PATTERN = Pattern.compile("^[\\p{Alpha}_][\\p{Alnum}_-]*$"); 142 LANGUAGE_TAG_PATTERN = Pattern.compile("^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*$"); 143 JSON_EXTENSION = ".json"; 144 } 145 146 private LocalizedStringLoader() { 147 // Non-instantiable 148 } 149 150 /** 151 * Loads all localized string files present in the specified package on the classpath. 152 * <p> 153 * Filenames must correspond to the IETF BCP 47 language tag format, optionally suffixed with {@code .json}. 154 * <p> 155 * Example filenames: 156 * <ul> 157 * <li>{@code en}</li> 158 * <li>{@code en.json}</li> 159 * <li>{@code es-MX}</li> 160 * <li>{@code es-MX.json}</li> 161 * <li>{@code nan-Hant-TW}</li> 162 * </ul> 163 * <p> 164 * Like any classpath reference, packages are separated using the {@code /} character. 165 * <p> 166 * Example package names: 167 * <ul> 168 * <li>{@code strings} 169 * <li>{@code com/lokalized/strings} 170 * </ul> 171 * <p> 172 * Note: this implementation only scans the specified package, it does not descend into child packages. 173 * 174 * @param classpathPackage location of a package on the classpath, not null 175 * @return per-locale sets of localized strings, not null 176 * @throws LocalizedStringLoadingException if an error occurs while loading localized string files 177 */ 178 @NonNull 179 public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull String classpathPackage) { 180 return loadFromClasspath(LocalizedStringLoader.class.getClassLoader(), classpathPackage); 181 } 182 183 @NonNull 184 static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull ClassLoader classLoader, 185 @NonNull String classpathPackage) { 186 requireNonNull(classpathPackage); 187 requireNonNull(classLoader); 188 189 Enumeration<URL> urls; 190 191 try { 192 urls = classLoader.getResources(classpathPackage); 193 } catch (IOException e) { 194 throw new LocalizedStringLoadingException(format("Unable to search classpath for '%s'", classpathPackage), e); 195 } 196 197 if (!urls.hasMoreElements()) 198 throw new LocalizedStringLoadingException(format("Unable to find package '%s' on the classpath", classpathPackage)); 199 200 Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> mergedByLocale = createLocaleKeyMap(); 201 202 while (urls.hasMoreElements()) { 203 URL url = urls.nextElement(); 204 Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = loadFromUrl(url, classpathPackage); 205 mergeLocalizedStrings(mergedByLocale, localizedStringsByLocale); 206 } 207 208 return toLocalizedStringsByLocale(mergedByLocale); 209 } 210 211 /** 212 * Loads all localized string files present in the specified directory. 213 * <p> 214 * Filenames must correspond to the IETF BCP 47 language tag format, optionally suffixed with {@code .json}. 215 * <p> 216 * Example filenames: 217 * <ul> 218 * <li>{@code en}</li> 219 * <li>{@code en.json}</li> 220 * <li>{@code es-MX}</li> 221 * <li>{@code es-MX.json}</li> 222 * <li>{@code nan-Hant-TW}</li> 223 * </ul> 224 * <p> 225 * Note: this implementation only scans the specified directory, it does not descend into child directories. 226 * 227 * @param directory directory in which to search for localized string files, not null 228 * @return per-locale sets of localized strings, not null 229 * @throws LocalizedStringLoadingException if an error occurs while loading localized string files 230 */ 231 @NonNull 232 public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromFilesystem(@NonNull Path directory) { 233 requireNonNull(directory); 234 return loadFromDirectory(directory.toFile()); 235 } 236 237 // TODO: should we expose methods for loading a single file? 238 239 /** 240 * Loads all localized string files present in the specified directory. 241 * 242 * @param directory directory in which to search for localized string files, not null 243 * @return per-locale sets of localized strings, not null 244 * @throws LocalizedStringLoadingException if an error occurs while loading localized string files 245 */ 246 @NonNull 247 private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromDirectory(@NonNull File directory) { 248 requireNonNull(directory); 249 250 if (!directory.exists()) 251 throw new LocalizedStringLoadingException(format("Location '%s' does not exist", 252 directory)); 253 254 if (!directory.isDirectory()) 255 throw new LocalizedStringLoadingException(format("Location '%s' exists but is not a directory", 256 directory)); 257 258 Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap(); 259 260 File[] files = directory.listFiles(); 261 262 if (files == null) 263 throw new LocalizedStringLoadingException(format("Unable to list files in directory '%s'", directory)); 264 265 if (files != null) { 266 for (File file : files) { 267 if (file.isDirectory()) 268 continue; 269 270 String fileName = file.getName(); 271 String languageTag = languageTagForFileName(fileName); 272 273 if (languageTag != null) { 274 LOGGER.fine(format("Loading localized strings file '%s'...", fileName)); 275 Locale locale = Locale.forLanguageTag(languageTag); 276 277 if (localizedStringsByLocale.containsKey(locale)) 278 throw new LocalizedStringLoadingException(format("Duplicate localized strings file for locale '%s' found at '%s'", 279 locale.toLanguageTag(), file.getPath())); 280 281 localizedStringsByLocale.put(locale, parseLocalizedStringsFile(file)); 282 } else { 283 LOGGER.fine(format("File '%s' does not correspond to a known language tag, skipping...", fileName)); 284 } 285 } 286 } 287 288 return Collections.unmodifiableMap(localizedStringsByLocale); 289 } 290 291 @NonNull 292 private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromUrl(@NonNull URL url, @NonNull String classpathPackage) { 293 requireNonNull(url); 294 requireNonNull(classpathPackage); 295 296 String protocol = url.getProtocol(); 297 298 if ("file".equals(protocol)) { 299 try { 300 return loadFromDirectory(Paths.get(url.toURI()).toFile()); 301 } catch (URISyntaxException e) { 302 throw new LocalizedStringLoadingException(format("Unable to resolve classpath location '%s'", url), e); 303 } 304 } 305 306 if ("jar".equals(protocol)) 307 return loadFromJar(url, classpathPackage); 308 309 throw new LocalizedStringLoadingException(format("Unsupported classpath protocol '%s' for location '%s'", 310 protocol, url)); 311 } 312 313 @NonNull 314 private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromJar(@NonNull URL jarUrl, 315 @NonNull String classpathPackage) { 316 requireNonNull(jarUrl); 317 requireNonNull(classpathPackage); 318 319 Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap(); 320 321 try { 322 JarURLConnection connection = (JarURLConnection) jarUrl.openConnection(); 323 connection.setUseCaches(false); 324 325 try (JarFile jarFile = connection.getJarFile()) { 326 String packagePath = connection.getEntryName(); 327 328 if (packagePath == null || packagePath.isEmpty()) 329 packagePath = classpathPackage; 330 331 if (!packagePath.endsWith("/")) 332 packagePath = packagePath + "/"; 333 334 Enumeration<JarEntry> entries = jarFile.entries(); 335 336 while (entries.hasMoreElements()) { 337 JarEntry entry = entries.nextElement(); 338 339 if (entry.isDirectory()) 340 continue; 341 342 String entryName = entry.getName(); 343 344 if (!entryName.startsWith(packagePath)) 345 continue; 346 347 String relativeName = entryName.substring(packagePath.length()); 348 349 if ("".equals(relativeName) || relativeName.contains("/")) 350 continue; 351 352 String languageTag = languageTagForFileName(relativeName); 353 354 if (languageTag != null) { 355 LOGGER.fine(format("Loading localized strings file '%s' from %s...", relativeName, jarFile.getName())); 356 Locale locale = Locale.forLanguageTag(languageTag); 357 358 if (localizedStringsByLocale.containsKey(locale)) 359 throw new LocalizedStringLoadingException(format("Duplicate localized strings file for locale '%s' found in %s", 360 locale.toLanguageTag(), jarFile.getName())); 361 362 try (InputStream inputStream = jarFile.getInputStream(entry)) { 363 String contents = new String(inputStream.readAllBytes(), UTF_8).trim(); 364 String canonicalPath = format("jar:%s!/%s", jarFile.getName(), entryName); 365 localizedStringsByLocale.put(locale, parseLocalizedStrings(canonicalPath, contents)); 366 } 367 } else { 368 LOGGER.fine(format("File '%s' does not correspond to a known language tag, skipping...", relativeName)); 369 } 370 } 371 } 372 } catch (IOException e) { 373 throw new LocalizedStringLoadingException(format("Unable to load localized strings from '%s'", jarUrl), e); 374 } 375 376 return Collections.unmodifiableMap(localizedStringsByLocale); 377 } 378 379 @NonNull 380 private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> createLocaleMap() { 381 return new TreeMap<>((locale1, locale2) -> locale1.toLanguageTag().compareTo(locale2.toLanguageTag())); 382 } 383 384 @NonNull 385 private static Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> createLocaleKeyMap() { 386 return new TreeMap<>((locale1, locale2) -> locale1.toLanguageTag().compareTo(locale2.toLanguageTag())); 387 } 388 389 private static void mergeLocalizedStrings( 390 @NonNull Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> target, 391 @NonNull Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> source) { 392 requireNonNull(target); 393 requireNonNull(source); 394 395 for (Map.Entry<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> entry : source.entrySet()) { 396 Locale locale = entry.getKey(); 397 Map<@NonNull String, @NonNull LocalizedString> localizedStringsByKey = target.get(locale); 398 399 if (localizedStringsByKey == null) { 400 localizedStringsByKey = new LinkedHashMap<>(); 401 target.put(locale, localizedStringsByKey); 402 } 403 404 for (LocalizedString localizedString : entry.getValue()) { 405 String key = localizedString.getKey(); 406 LocalizedString existing = localizedStringsByKey.get(key); 407 408 if (existing != null) 409 throw new LocalizedStringLoadingException(format("Duplicate localized string key '%s' found for locale '%s' while merging classpath resources", 410 key, locale.toLanguageTag())); 411 412 localizedStringsByKey.put(key, localizedString); 413 } 414 } 415 } 416 417 @NonNull 418 private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> toLocalizedStringsByLocale( 419 @NonNull Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> localizedStringsByKeyByLocale) { 420 requireNonNull(localizedStringsByKeyByLocale); 421 422 Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap(); 423 424 for (Map.Entry<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> entry : localizedStringsByKeyByLocale.entrySet()) { 425 localizedStringsByLocale.put(entry.getKey(), 426 Collections.unmodifiableSet(new LinkedHashSet<>(entry.getValue().values()))); 427 } 428 429 return Collections.unmodifiableMap(localizedStringsByLocale); 430 } 431 432 private static boolean isLanguageTag(@NonNull String languageTag) { 433 requireNonNull(languageTag); 434 435 if (!LANGUAGE_TAG_PATTERN.matcher(languageTag).matches()) 436 return false; 437 438 Locale locale = Locale.forLanguageTag(languageTag); 439 if (!"".equals(locale.getLanguage())) 440 return true; 441 442 return languageTag.toLowerCase(Locale.ROOT).startsWith("x-"); 443 } 444 445 @Nullable 446 private static String languageTagForFileName(@NonNull String fileName) { 447 requireNonNull(fileName); 448 449 String languageTag = fileName; 450 451 if (fileName.toLowerCase(Locale.ROOT).endsWith(JSON_EXTENSION)) 452 languageTag = fileName.substring(0, fileName.length() - JSON_EXTENSION.length()); 453 454 return isLanguageTag(languageTag) ? languageTag : null; 455 } 456 457 /** 458 * Parses out a set of localized strings from the given file. 459 * 460 * @param file the file to parse, not null 461 * @return the set of localized strings contained in the file, not null 462 * @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file 463 */ 464 @NonNull 465 private static Set<@NonNull LocalizedString> parseLocalizedStringsFile(@NonNull File file) { 466 requireNonNull(file); 467 468 String canonicalPath; 469 470 try { 471 canonicalPath = file.getCanonicalPath(); 472 } catch (IOException e) { 473 throw new LocalizedStringLoadingException( 474 format("Unable to determine canonical path for localized strings file %s", file), e); 475 } 476 477 if (!Files.isRegularFile(file.toPath())) 478 throw new LocalizedStringLoadingException(format("%s is not a regular file", canonicalPath)); 479 480 String localizedStringsFileContents; 481 482 try { 483 localizedStringsFileContents = new String(Files.readAllBytes(file.toPath()), UTF_8).trim(); 484 } catch (IOException e) { 485 throw new LocalizedStringLoadingException(format("Unable to load localized strings file contents for %s", 486 canonicalPath), e); 487 } 488 489 return parseLocalizedStrings(canonicalPath, localizedStringsFileContents); 490 } 491 492 @NonNull 493 private static Set<@NonNull LocalizedString> parseLocalizedStrings(@NonNull String canonicalPath, 494 @NonNull String localizedStringsFileContents) { 495 requireNonNull(canonicalPath); 496 requireNonNull(localizedStringsFileContents); 497 498 if ("".equals(localizedStringsFileContents)) 499 return Collections.emptySet(); 500 501 Set<@NonNull LocalizedString> localizedStrings = new HashSet<>(); 502 JsonValue outerJsonValue; 503 504 try { 505 outerJsonValue = Json.parse(localizedStringsFileContents); 506 } catch (MinimalJson.ParseException e) { 507 throw new LocalizedStringLoadingException( 508 format("%s: unable to parse localized strings file", canonicalPath), e); 509 } 510 511 if (!outerJsonValue.isObject()) 512 throw new LocalizedStringLoadingException(format("%s: a localized strings file must be comprised of a single JSON object", canonicalPath)); 513 514 JsonObject outerJsonObject = outerJsonValue.asObject(); 515 Set<String> keys = new HashSet<>(); 516 517 for (Member member : outerJsonObject) { 518 String key = member.getName(); 519 520 if (!keys.add(key)) 521 throw new LocalizedStringLoadingException(format("%s: duplicate localized string key '%s' encountered", canonicalPath, key)); 522 523 JsonValue value = member.getValue(); 524 localizedStrings.add(parseLocalizedString(canonicalPath, key, value, null)); 525 } 526 527 return Collections.unmodifiableSet(localizedStrings); 528 } 529 530 /** 531 * Parses "toplevel" localized string data. 532 * <p> 533 * Operates recursively if alternatives are encountered. 534 * 535 * @param canonicalPath the unique path to the file (or URL) being parsed, used for error reporting. not null 536 * @param key the toplevel translation key, not null 537 * @param jsonValue the toplevel translation value - might be a simple string, might be a complex object. not null 538 * @return a localized string instance, not null 539 * @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file 540 */ 541 @NonNull 542 private static LocalizedString parseLocalizedString(@NonNull String canonicalPath, @NonNull String key, @NonNull JsonValue jsonValue, 543 @Nullable List<@NonNull Token> expressionTokens) { 544 requireNonNull(canonicalPath); 545 requireNonNull(key); 546 requireNonNull(jsonValue); 547 548 LocalizedString.Builder localizedStringBuilder = new LocalizedString.Builder(key).expressionTokens(expressionTokens); 549 550 if (jsonValue.isString()) { 551 // Simple case - just a key and a value, no translation rules 552 // 553 // Example format: 554 // 555 // { 556 // "Hello, world!" : "Приветствую, мир" 557 // } 558 559 String translation = jsonValue.asString(); 560 561 if (translation == null) 562 throw new LocalizedStringLoadingException(format("%s: a translation is required for key '%s'", canonicalPath, key)); 563 564 return localizedStringBuilder.translation(translation).build(); 565 } else if (jsonValue.isObject()) { 566 // More complex case, there can be placeholders and alternatives. 567 // 568 // Example format: 569 // 570 // { 571 // "I read {{bookCount}} books" : { 572 // "translation" : "I read {{bookCount}} {{books}}", 573 // "commentary" : "Message shown when user achieves her book-reading goal for the month", 574 // "placeholders" : { 575 // "books" : { 576 // "value" : "bookCount", 577 // "translations" : { 578 // "ONE" : "book", 579 // "OTHER" : "books" 580 // } 581 // } 582 // }, 583 // "alternatives" : [ 584 // { 585 // "bookCount == 0" : { 586 // "translation" : "I haven't read any books" 587 // } 588 // } 589 // ] 590 // } 591 // } 592 593 JsonObject localizedStringObject = jsonValue.asObject(); 594 595 String translation = null; 596 597 JsonValue translationJsonValue = localizedStringObject.get("translation"); 598 599 if (translationJsonValue != null && !translationJsonValue.isNull()) { 600 if (!translationJsonValue.isString()) 601 throw new LocalizedStringLoadingException(format("%s: translation must be a string for key '%s'", canonicalPath, key)); 602 603 translation = translationJsonValue.asString(); 604 } 605 606 String commentary = null; 607 608 JsonValue commentaryJsonValue = localizedStringObject.get("commentary"); 609 610 if (commentaryJsonValue != null && !commentaryJsonValue.isNull()) { 611 if (!commentaryJsonValue.isString()) 612 throw new LocalizedStringLoadingException(format("%s: commentary must be a string for key '%s'", canonicalPath, key)); 613 614 commentary = commentaryJsonValue.asString(); 615 } 616 617 Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder = new LinkedHashMap<>(); 618 619 JsonValue placeholdersJsonValue = localizedStringObject.get("placeholders"); 620 621 if (placeholdersJsonValue != null && !placeholdersJsonValue.isNull()) { 622 if (!placeholdersJsonValue.isObject()) 623 throw new LocalizedStringLoadingException(format("%s: the placeholders value must be an object. Key is '%s'", canonicalPath, key)); 624 625 JsonObject placeholdersJsonObject = placeholdersJsonValue.asObject(); 626 627 for (Member placeholderMember : placeholdersJsonObject) { 628 String placeholderKey = placeholderMember.getName(); 629 JsonValue placeholderJsonValue = placeholderMember.getValue(); 630 String value = null; 631 LanguageFormTranslationRange rangeValue = null; 632 633 ensureValidPlaceholderName(canonicalPath, key, placeholderKey, "placeholder"); 634 635 if (!placeholderJsonValue.isObject()) 636 throw new LocalizedStringLoadingException(format("%s: the placeholder value must be an object. Key is '%s'", canonicalPath, key)); 637 638 JsonObject placeholderJsonObject = placeholderJsonValue.asObject(); 639 640 JsonValue valueJsonValue = placeholderJsonObject.get("value"); 641 JsonValue rangeJsonValue = placeholderJsonObject.get("range"); 642 boolean hasValue = valueJsonValue != null && !valueJsonValue.isNull(); 643 boolean hasRangeValue = rangeJsonValue != null && !rangeJsonValue.isNull(); 644 645 if (!hasValue && !hasRangeValue) 646 throw new LocalizedStringLoadingException(format("%s: a placeholder translation value or range is required. Key is '%s'", canonicalPath, key)); 647 648 if (hasValue && hasRangeValue) 649 throw new LocalizedStringLoadingException(format("%s: a placeholder translation cannot have both a value and a range. Key is '%s'", canonicalPath, key)); 650 651 if (hasRangeValue) { 652 if (!rangeJsonValue.isObject()) 653 throw new LocalizedStringLoadingException(format("%s: the placeholder translation range must be an object. Key is '%s'", canonicalPath, key)); 654 655 JsonObject rangeJsonObject = rangeJsonValue.asObject(); 656 JsonValue rangeValueStartJsonValue = rangeJsonObject.get("start"); 657 JsonValue rangeValueEndJsonValue = rangeJsonObject.get("end"); 658 659 if (rangeValueStartJsonValue == null || rangeValueStartJsonValue.isNull()) 660 throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start is required. Key is '%s'", canonicalPath, key)); 661 662 if (rangeValueEndJsonValue == null || rangeValueEndJsonValue.isNull()) 663 throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end is required. Key is '%s'", canonicalPath, key)); 664 665 if (!rangeValueStartJsonValue.isString()) 666 throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start must be a string. Key is '%s'", canonicalPath, key)); 667 668 if (!rangeValueEndJsonValue.isString()) 669 throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end must be a string. Key is '%s'", canonicalPath, key)); 670 671 String rangeStartValue = rangeValueStartJsonValue.asString(); 672 String rangeEndValue = rangeValueEndJsonValue.asString(); 673 674 ensureValidPlaceholderName(canonicalPath, key, rangeStartValue, "range start"); 675 ensureValidPlaceholderName(canonicalPath, key, rangeEndValue, "range end"); 676 677 rangeValue = new LanguageFormTranslationRange(rangeStartValue, rangeEndValue); 678 } else { 679 if (!valueJsonValue.isString()) 680 throw new LocalizedStringLoadingException(format("%s: a placeholder translation value must be a string. Key is '%s'", canonicalPath, key)); 681 682 value = valueJsonValue.asString(); 683 ensureValidPlaceholderName(canonicalPath, key, value, "placeholder value"); 684 } 685 686 JsonValue translationsJsonValue = placeholderJsonObject.get("translations"); 687 688 if (translationsJsonValue == null || translationsJsonValue.isNull()) 689 throw new LocalizedStringLoadingException(format("%s: placeholder translations are required. Key is '%s'", canonicalPath, key)); 690 691 if (!translationsJsonValue.isObject()) 692 throw new LocalizedStringLoadingException(format("%s: the placeholder translations value must be an object. Key is '%s'", canonicalPath, key)); 693 694 Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm = new LinkedHashMap<>(); 695 696 JsonObject translationsJsonObject = translationsJsonValue.asObject(); 697 698 for (Member translationMember : translationsJsonObject) { 699 String languageFormTranslationKey = translationMember.getName(); 700 JsonValue languageFormTranslationJsonValue = translationMember.getValue(); 701 LanguageForm languageForm = SUPPORTED_LANGUAGE_FORMS_BY_NAME.get(languageFormTranslationKey); 702 703 if (languageForm == null) 704 throw new LocalizedStringLoadingException(format("%s: unexpected placeholder translation language form encountered. Key is '%s'. " + 705 "You provided '%s', valid values are [%s]", canonicalPath, key, languageFormTranslationKey, 706 SUPPORTED_LANGUAGE_FORMS_BY_NAME.keySet().stream().collect(Collectors.joining(", ")))); 707 708 if (!languageFormTranslationJsonValue.isString()) 709 throw new LocalizedStringLoadingException(format("%s: the placeholder translation value must be a string. Key is '%s'", canonicalPath, key)); 710 711 translationsByLanguageForm.put(languageForm, languageFormTranslationJsonValue.asString()); 712 } 713 714 if (translationsByLanguageForm.isEmpty()) 715 throw new LocalizedStringLoadingException(format("%s: placeholder translations are required. Key is '%s'", canonicalPath, key)); 716 717 Set<Class<?>> languageFormTypes = new HashSet<>(); 718 719 for (LanguageForm languageForm : translationsByLanguageForm.keySet()) 720 languageFormTypes.add(languageForm.getClass()); 721 722 if (languageFormTypes.size() > 1) 723 throw new LocalizedStringLoadingException(format("%s: you cannot mix-and-match language forms in placeholder translations. " + 724 "Placeholder is '%s' for key '%s'", canonicalPath, placeholderKey, key)); 725 726 if (rangeValue != null) { 727 boolean hasNonCardinality = translationsByLanguageForm.keySet().stream() 728 .anyMatch(languageForm -> !(languageForm instanceof Cardinality)); 729 730 if (hasNonCardinality) 731 throw new LocalizedStringLoadingException(format("%s: range-based translations only support %s. Placeholder is '%s' for key '%s'", 732 canonicalPath, Cardinality.class.getSimpleName(), placeholderKey, key)); 733 } 734 735 LanguageFormTranslation languageFormTranslation = rangeValue != null 736 ? new LanguageFormTranslation(rangeValue, translationsByLanguageForm) 737 : new LanguageFormTranslation(value, translationsByLanguageForm); 738 739 languageFormTranslationsByPlaceholder.put(placeholderKey, languageFormTranslation); 740 } 741 } 742 743 List<@NonNull LocalizedString> alternatives = new ArrayList<>(); 744 745 JsonValue alternativesJsonValue = localizedStringObject.get("alternatives"); 746 747 if (alternativesJsonValue != null && !alternativesJsonValue.isNull()) { 748 if (!alternativesJsonValue.isArray()) 749 throw new LocalizedStringLoadingException(format("%s: alternatives must be an array. Key is '%s'", canonicalPath, key)); 750 751 JsonArray alternativesJsonArray = alternativesJsonValue.asArray(); 752 753 for (JsonValue alternativeJsonValue : alternativesJsonArray) { 754 if (alternativeJsonValue == null || alternativeJsonValue.isNull()) 755 continue; 756 757 if (!alternativeJsonValue.isObject()) 758 throw new LocalizedStringLoadingException(format("%s: alternative value must be an object. Key is '%s'", canonicalPath, key)); 759 760 JsonObject outerJsonObject = alternativeJsonValue.asObject(); 761 762 for (Member member : outerJsonObject) { 763 String alternativeKey = member.getName(); 764 JsonValue alternativeValue = member.getValue(); 765 List<@NonNull Token> alternativeTokens = parseExpressionTokens(canonicalPath, alternativeKey); 766 alternatives.add(parseLocalizedString(canonicalPath, alternativeKey, alternativeValue, alternativeTokens)); 767 } 768 } 769 } 770 771 if (translation == null && alternatives.isEmpty()) 772 throw new LocalizedStringLoadingException(format("%s: either a translation or at least one alternative expression is required for key '%s'", 773 canonicalPath, key)); 774 775 return localizedStringBuilder.translation(translation) 776 .commentary(commentary) 777 .languageFormTranslationsByPlaceholder(languageFormTranslationsByPlaceholder) 778 .alternatives(alternatives) 779 .build(); 780 } else { 781 throw new LocalizedStringLoadingException(format("%s: either a translation string or object value is required for key '%s'", 782 canonicalPath, key)); 783 } 784 } 785 786 @NonNull 787 private static List<@NonNull Token> parseExpressionTokens(@NonNull String canonicalPath, @NonNull String expression) { 788 requireNonNull(canonicalPath); 789 requireNonNull(expression); 790 791 try { 792 List<@NonNull Token> tokens = EXPRESSION_EVALUATOR.getExpressionTokenizer().extractTokens(expression); 793 List<@NonNull Token> rpnTokens = EXPRESSION_EVALUATOR.convertTokensToReversePolishNotation(tokens); 794 EXPRESSION_EVALUATOR.validateReversePolishNotationTokens(rpnTokens); 795 return rpnTokens; 796 } catch (ExpressionEvaluationException e) { 797 throw new LocalizedStringLoadingException( 798 format("%s: unable to parse alternative expression '%s'", canonicalPath, expression), e); 799 } 800 } 801 802 private static void ensureValidPlaceholderName(@NonNull String canonicalPath, @NonNull String key, 803 @NonNull String placeholderName, @NonNull String description) { 804 requireNonNull(canonicalPath); 805 requireNonNull(key); 806 requireNonNull(placeholderName); 807 requireNonNull(description); 808 809 if (!PLACEHOLDER_NAME_PATTERN.matcher(placeholderName).matches()) 810 throw new LocalizedStringLoadingException(format("%s: invalid %s '%s'. Placeholder names must start with a letter or underscore " + 811 "and contain only letters, digits, underscores, or hyphens. Key is '%s'", canonicalPath, description, placeholderName, key)); 812 } 813}