001/* 002 * Copyright 2017 Product Mog 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; 021 022import javax.annotation.Nonnull; 023import javax.annotation.Nullable; 024import javax.annotation.concurrent.Immutable; 025import javax.annotation.concurrent.NotThreadSafe; 026import javax.annotation.concurrent.ThreadSafe; 027import java.util.ArrayList; 028import java.util.Collections; 029import java.util.HashMap; 030import java.util.HashSet; 031import java.util.LinkedHashSet; 032import java.util.List; 033import java.util.Locale; 034import java.util.Locale.LanguageRange; 035import java.util.Map; 036import java.util.Map.Entry; 037import java.util.Objects; 038import java.util.Optional; 039import java.util.Set; 040import java.util.concurrent.ConcurrentHashMap; 041import java.util.function.Supplier; 042import java.util.logging.Level; 043import java.util.logging.Logger; 044import java.util.stream.Collectors; 045 046import static java.lang.String.format; 047import static java.util.Objects.requireNonNull; 048 049/** 050 * Default implementation of a localized string provider. 051 * <p> 052 * It is recommended to use a single instance of this class across your entire application. 053 * <p> 054 * In multi-tenant systems like a web application where each user might have a different locale, 055 * your {@code localeSupplier} might return the locale specified by current request. 056 * 057 * @author <a href="https://revetkn.com">Mark Allen</a> 058 */ 059@ThreadSafe 060public class DefaultStrings implements Strings { 061 @Nonnull 062 private final String fallbackLanguageCode; 063 @Nonnull 064 private final Map<Locale, Set<LocalizedString>> localizedStringsByLocale; 065 @Nullable 066 private final Supplier<Locale> localeSupplier; 067 @Nullable 068 private final Supplier<List<LanguageRange>> languageRangesSupplier; 069 @Nonnull 070 private final FailureMode failureMode; 071 @Nonnull 072 private final Locale fallbackLocale; 073 @Nonnull 074 private final StringInterpolator stringInterpolator; 075 @Nonnull 076 private final ExpressionEvaluator expressionEvaluator; 077 @Nonnull 078 private final Logger logger; 079 080 /** 081 * Cache of localized strings by key by locale. 082 * <p> 083 * This is our "master" reference localized string storage that other data structures will point to. 084 */ 085 @Nonnull 086 private final Map<Locale, Map<String, LocalizedString>> localizedStringsByKeyByLocale; 087 088 /** 089 * Cache of best-matching strings for the given locale (populated on-demand per request at runtime). 090 * <p> 091 * List elements are ordered by most to least specific, e.g. if your locale is {@code en-US}, the first list element 092 * might be {@code en-US} strings and the second would be {@code en} strings. 093 * <p> 094 * There will always be at least one element in the list - the fallback locale. 095 */ 096 @Nonnull 097 private final ConcurrentHashMap<Locale, List<LocalizedStringSource>> localizedStringSourcesByLocale; 098 099 /** 100 * Constructs a localized string provider with builder-supplied data. 101 * <p> 102 * The fallback language code must be an ISO 639 alpha-2 or alpha-3 language code. 103 * When a language has both an alpha-2 code and an alpha-3 code, the alpha-2 code must be used. 104 * 105 * @param fallbackLanguageCode fallback language code, not null 106 * @param localizedStringSupplier supplier of localized strings, not null 107 * @param localeSupplier locale supplier, may be null 108 * @param languageRangesSupplier language ranges supplier, may be null 109 * @param failureMode strategy for dealing with lookup failures, may be null 110 */ 111 protected DefaultStrings(@Nonnull String fallbackLanguageCode, 112 @Nonnull Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier, 113 @Nullable Supplier<Locale> localeSupplier, 114 @Nullable Supplier<List<LanguageRange>> languageRangesSupplier, 115 @Nullable FailureMode failureMode) { 116 requireNonNull(fallbackLanguageCode); 117 requireNonNull(localizedStringSupplier); 118 119 this.logger = Logger.getLogger(LoggerType.STRINGS.getLoggerName()); 120 121 Map<Locale, ? extends Iterable<LocalizedString>> suppliedLocalizedStringsByLocale = localizedStringSupplier.get(); 122 123 if (suppliedLocalizedStringsByLocale == null) 124 suppliedLocalizedStringsByLocale = Collections.emptyMap(); 125 126 // Defensive copy of iterator to unmodifiable set 127 Map<Locale, Set<LocalizedString>> localizedStringsByLocale = suppliedLocalizedStringsByLocale.entrySet().stream() 128 .collect(Collectors.toMap( 129 entry -> entry.getKey(), 130 entry -> { 131 Set<LocalizedString> localizedStrings = new LinkedHashSet<>(); 132 entry.getValue().forEach(localizedStrings::add); 133 return Collections.unmodifiableSet(localizedStrings); 134 } 135 )); 136 137 this.fallbackLocale = Locale.forLanguageTag(fallbackLanguageCode); 138 this.fallbackLanguageCode = fallbackLanguageCode; 139 this.localizedStringsByLocale = Collections.unmodifiableMap(localizedStringsByLocale); 140 this.languageRangesSupplier = languageRangesSupplier; 141 this.failureMode = failureMode == null ? FailureMode.USE_FALLBACK : failureMode; 142 this.stringInterpolator = new StringInterpolator(); 143 this.expressionEvaluator = new ExpressionEvaluator(); 144 145 this.localizedStringsByKeyByLocale = Collections.unmodifiableMap(localizedStringsByLocale.entrySet().stream() 146 .collect(Collectors.toMap( 147 entry1 -> entry1.getKey(), 148 entry1 -> 149 Collections.unmodifiableMap(entry1.getValue().stream() 150 .collect(Collectors.toMap( 151 entry2 -> entry2.getKey(), 152 entry2 -> entry2 153 ) 154 ))))); 155 156 this.localizedStringSourcesByLocale = new ConcurrentHashMap<>(); 157 158 if (!localizedStringsByLocale.containsKey(getFallbackLocale())) 159 throw new IllegalArgumentException(format("Specified fallback language code is '%s' but no matching " + 160 "localized strings locale was found. Known locales: [%s]", fallbackLanguageCode, 161 localizedStringsByLocale.keySet().stream() 162 .map(locale -> locale.toLanguageTag()) 163 .sorted() 164 .collect(Collectors.joining(", ")))); 165 166 if (localeSupplier != null && languageRangesSupplier != null) 167 throw new IllegalArgumentException(format("You cannot provide both a localeSupplier " + 168 "and a languageRangesSupplier when building an instance of %s - you must pick one of the two.", getClass().getSimpleName())); 169 170 if (localeSupplier == null && languageRangesSupplier == null) 171 this.localeSupplier = () -> getFallbackLocale(); 172 else 173 this.localeSupplier = localeSupplier; 174 } 175 176 @Nonnull 177 @Override 178 public String get(@Nonnull String key) { 179 requireNonNull(key); 180 return get(key, null, null); 181 } 182 183 @Nonnull 184 @Override 185 public String get(@Nonnull String key, @Nullable Locale locale) { 186 requireNonNull(key); 187 return get(key, null, locale); 188 } 189 190 @Nonnull 191 @Override 192 public String get(@Nonnull String key, @Nullable Map<String, Object> placeholders) { 193 requireNonNull(key); 194 return get(key, placeholders, null); 195 } 196 197 @Nonnull 198 @Override 199 public String get(@Nonnull String key, @Nullable Map<String, Object> placeholders, @Nullable Locale locale) { 200 requireNonNull(key); 201 202 if (placeholders == null) 203 placeholders = Collections.emptyMap(); 204 205 if (locale == null) 206 locale = getImplicitLocale(); 207 208 String translation = null; 209 Map<String, Object> mutableContext = new HashMap<>(placeholders); 210 Map<String, Object> immutableContext = Collections.unmodifiableMap(placeholders); 211 List<LocalizedStringSource> localizedStringSources = getLocalizedStringSourcesForLocale(locale); 212 213 for (LocalizedStringSource localizedStringSource : localizedStringSources) { 214 LocalizedString localizedString = localizedStringSource.getLocalizedStringsByKey().get(key); 215 216 if (localizedString == null) { 217 logger.finer(format("No match for '%s' was found in '%s'", key, localizedStringSource.getLocale().toLanguageTag())); 218 } else { 219 logger.finer(format("A match for '%s' was found in '%s'", key, localizedStringSource.getLocale().toLanguageTag())); 220 translation = getInternal(key, localizedString, mutableContext, immutableContext, localizedStringSource.getLocale()).orElse(null); 221 break; 222 } 223 } 224 225 if (translation == null) { 226 logger.finer(format("No match for '%s' was found in any strings file.", key)); 227 translation = stringInterpolator.interpolate(key, mutableContext); 228 } 229 230 return translation; 231 } 232 233 /** 234 * Recursive method which attempts to translate a localized string. 235 * 236 * @param key the toplevel translation key (always the same regardless of recursion depth), not null 237 * @param localizedString the localized string on which to operate, not null 238 * @param mutableContext the mutable context for the translation, not null 239 * @param immutableContext the original user-supplied translation context, not null 240 * @param locale the locale to use for evaluation, not null 241 * @return the translation, if possible (may not be possible if no translation value specified and no alternative expressions match), not null 242 */ 243 @Nonnull 244 protected Optional<String> getInternal(@Nonnull String key, @Nonnull LocalizedString localizedString, 245 @Nonnull Map<String, Object> mutableContext, @Nonnull Map<String, Object> immutableContext, 246 @Nonnull Locale locale) { 247 requireNonNull(key); 248 requireNonNull(localizedString); 249 requireNonNull(mutableContext); 250 requireNonNull(immutableContext); 251 requireNonNull(locale); 252 253 // First, see if any alternatives match by evaluating them 254 for (LocalizedString alternative : localizedString.getAlternatives()) { 255 if (getExpressionEvaluator().evaluate(alternative.getKey(), mutableContext, locale)) { 256 logger.finer(format("An alternative match for '%s' was found for key '%s' and context %s", alternative.getKey(), key, mutableContext)); 257 258 // If we have a matching alternative, recurse into it 259 return getInternal(key, alternative, mutableContext, immutableContext, locale); 260 } 261 } 262 263 if (!localizedString.getTranslation().isPresent()) 264 return Optional.empty(); 265 266 String translation = localizedString.getTranslation().get(); 267 268 for (Entry<String, LanguageFormTranslation> entry : localizedString.getLanguageFormTranslationsByPlaceholder().entrySet()) { 269 String placeholderName = entry.getKey(); 270 LanguageFormTranslation languageFormTranslation = entry.getValue(); 271 Object value = null; 272 Object rangeStart = null; 273 Object rangeEnd = null; 274 Map<Cardinality, String> translationsByCardinality = new HashMap<>(); 275 Map<Ordinality, String> translationsByOrdinality = new HashMap<>(); 276 Map<Gender, String> translationsByGender = new HashMap<>(); 277 278 if (languageFormTranslation.getRange().isPresent()) { 279 LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get(); 280 rangeStart = immutableContext.get(languageFormTranslationRange.getStart()); 281 rangeEnd = immutableContext.get(languageFormTranslationRange.getEnd()); 282 } else { 283 value = immutableContext.get(languageFormTranslation.getValue().get()); 284 } 285 286 for (Entry<LanguageForm, String> translationEntry : languageFormTranslation.getTranslationsByLanguageForm().entrySet()) { 287 LanguageForm languageForm = translationEntry.getKey(); 288 String translatedLanguageForm = translationEntry.getValue(); 289 290 if (languageForm instanceof Cardinality) 291 translationsByCardinality.put((Cardinality) languageForm, translatedLanguageForm); 292 else if (languageForm instanceof Ordinality) 293 translationsByOrdinality.put((Ordinality) languageForm, translatedLanguageForm); 294 else if (languageForm instanceof Gender) 295 translationsByGender.put((Gender) languageForm, translatedLanguageForm); 296 else 297 throw new IllegalArgumentException(format("Encountered unrecognized language form %s", languageForm)); 298 } 299 300 int distinctLanguageForms = (translationsByCardinality.size() > 0 ? 1 : 0) + 301 (translationsByOrdinality.size() > 0 ? 1 : 0) + 302 (translationsByGender.size() > 0 ? 1 : 0); 303 304 if (distinctLanguageForms > 1) 305 throw new IllegalArgumentException(format("You cannot mix-and-match language forms. Offending localized string was %s", localizedString)); 306 307 if (distinctLanguageForms == 0) 308 continue; 309 310 // Handle plural cardinalities 311 if (translationsByCardinality.size() > 0) { 312 // Special case: calculate range from min and max if this is a range-driven cardinality 313 if (languageFormTranslation.getRange().isPresent()) { 314 if (rangeStart == null) 315 rangeStart = 0; 316 if (rangeEnd == null) 317 rangeEnd = 0; 318 319 if (!(rangeStart instanceof Number)) { 320 logger.warning(format("Range start '%s' for '%s' is not a number, falling back to 0.", 321 rangeStart, languageFormTranslation.getValue())); 322 rangeStart = 0; 323 } 324 325 if (!(rangeEnd instanceof Number)) { 326 logger.warning(format("Range value end '%s' for '%s' is not a number, falling back to 0.", 327 rangeEnd, languageFormTranslation.getValue())); 328 rangeEnd = 0; 329 } 330 331 Cardinality startCardinality = Cardinality.forNumber((Number) rangeStart, locale); 332 Cardinality endCardinality = Cardinality.forNumber((Number) rangeEnd, locale); 333 Cardinality rangeCardinality = Cardinality.forRange(startCardinality, endCardinality, locale); 334 335 String cardinalityTranslation = translationsByCardinality.get(rangeCardinality); 336 337 if (cardinalityTranslation == null) 338 logger.warning(format("Unable to find %s translation for range cardinality %s (start was %s, end was %s). Localized string was %s", 339 Cardinality.class.getSimpleName(), rangeCardinality.name(), startCardinality.name(), endCardinality.name(), localizedString)); 340 341 mutableContext.put(placeholderName, cardinalityTranslation); 342 } else { 343 // Normal "non-range" cardinality 344 if (value == null) 345 value = 0; 346 347 if (!(value instanceof Number)) { 348 logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.", 349 value, languageFormTranslation.getValue())); 350 value = 0; 351 } 352 353 Cardinality cardinality = Cardinality.forNumber((Number) value, locale); 354 String cardinalityTranslation = translationsByCardinality.get(cardinality); 355 356 if (cardinalityTranslation == null) 357 logger.warning(format("Unable to find %s translation for %s. Localized string was %s", 358 Cardinality.class.getSimpleName(), cardinality.name(), localizedString)); 359 360 mutableContext.put(placeholderName, cardinalityTranslation); 361 } 362 } 363 364 // Handle plural ordinalities 365 if (translationsByOrdinality.size() > 0) { 366 if (value == null) 367 value = 0; 368 369 if (!(value instanceof Number)) { 370 logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.", 371 value, languageFormTranslation.getValue())); 372 value = 0; 373 } 374 375 Ordinality ordinality = Ordinality.forNumber((Number) value, locale); 376 String ordinalityTranslation = translationsByOrdinality.get(ordinality); 377 378 if (ordinalityTranslation == null) 379 logger.warning(format("Unable to find %s translation for %s. Localized string was %s", 380 Ordinality.class.getSimpleName(), ordinality.name(), localizedString)); 381 382 mutableContext.put(placeholderName, ordinalityTranslation); 383 } 384 385 // Handle genders 386 if (translationsByGender.size() > 0) { 387 if (value == null) { 388 logger.warning(format("Value '%s' for '%s' is null. No replacement will be performed.", value, 389 languageFormTranslation.getValue())); 390 continue; 391 } 392 393 if (!(value instanceof Gender)) { 394 logger.warning(format("Value '%s' for '%s' is not a %s. No replacement will be performed.", value, 395 languageFormTranslation.getValue(), Gender.class.getSimpleName())); 396 continue; 397 } 398 399 Gender gender = (Gender) value; 400 String genderTranslation = translationsByGender.get(gender); 401 402 if (genderTranslation == null) 403 logger.warning(format("Unable to find %s translation for %s. Localized string was %s", 404 Gender.class.getSimpleName(), gender.name(), localizedString)); 405 406 mutableContext.put(placeholderName, genderTranslation); 407 } 408 } 409 410 translation = stringInterpolator.interpolate(translation, mutableContext); 411 412 return Optional.of(translation); 413 } 414 415 @Nonnull 416 protected List<LocalizedStringSource> getLocalizedStringSourcesForLocale(@Nonnull Locale locale) { 417 requireNonNull(locale); 418 419 return getLocalizedStringSourcesByLocale().computeIfAbsent(locale, (ignored) -> { 420 String language = LocaleUtils.normalizedLanguage(locale).orElse(null); 421 String script = locale.getScript(); 422 String country = locale.getCountry(); 423 String variant = locale.getVariant(); 424 Set<Character> extensionKeys = locale.hasExtensions() ? locale.getExtensionKeys() : Collections.emptySet(); 425 Set<LocalizedString> localizedStrings; 426 Set<Locale> matchingLocales = new HashSet<>(5); 427 List<LocalizedStringSource> localizedStringSources = new ArrayList<>(5); 428 429 if (logger.isLoggable(Level.FINER)) 430 logger.finer(format("Finding strings files that match locale '%s'...", locale.toLanguageTag())); 431 432 // Try most specific (matches all 5 criteria) and move back to least specific 433 Locale.Builder extensionsLocaleBuilder = 434 new Locale.Builder().setLanguage(language).setScript(script).setRegion(country).setVariant(variant); 435 436 for (Character extensionKey : extensionKeys) 437 extensionsLocaleBuilder.setExtension(extensionKey, locale.getExtension(extensionKey)); 438 439 Locale extensionsLocale = extensionsLocaleBuilder.build(); 440 matchingLocales.add(extensionsLocale); 441 localizedStrings = getLocalizedStringsByLocale().get(extensionsLocale); 442 443 if (localizedStrings != null) { 444 localizedStringSources.add(new LocalizedStringSource(extensionsLocale, getLocalizedStringsByKeyByLocale().get(extensionsLocale))); 445 446 if (logger.isLoggable(Level.FINER)) 447 logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(), 448 extensionsLocale.toLanguageTag())); 449 } 450 451 // Variant (4) 452 Locale variantLocale = 453 new Locale.Builder().setLanguage(language).setScript(script).setRegion(country).setVariant(variant) 454 .build(); 455 456 if (!matchingLocales.contains(variantLocale)) { 457 matchingLocales.add(variantLocale); 458 459 localizedStrings = getLocalizedStringsByLocale().get(variantLocale); 460 461 if (localizedStrings != null) { 462 localizedStringSources.add(new LocalizedStringSource(variantLocale, getLocalizedStringsByKeyByLocale().get(variantLocale))); 463 464 if (logger.isLoggable(Level.FINER)) 465 logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(), 466 variantLocale.toLanguageTag())); 467 } 468 } 469 470 // Region (3) 471 Locale regionLocale = new Locale.Builder().setLanguage(language).setScript(script).setRegion(country).build(); 472 473 if (!matchingLocales.contains(regionLocale)) { 474 matchingLocales.add(regionLocale); 475 476 localizedStrings = getLocalizedStringsByLocale().get(regionLocale); 477 478 if (localizedStrings != null) { 479 localizedStringSources.add(new LocalizedStringSource(regionLocale, getLocalizedStringsByKeyByLocale().get(regionLocale))); 480 481 if (logger.isLoggable(Level.FINER)) 482 logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(), 483 regionLocale.toLanguageTag())); 484 } 485 } 486 487 // Script (2) 488 Locale scriptLocale = new Locale.Builder().setLanguage(language).setScript(script).build(); 489 490 if (!matchingLocales.contains(scriptLocale)) { 491 matchingLocales.add(scriptLocale); 492 493 localizedStrings = getLocalizedStringsByLocale().get(scriptLocale); 494 495 if (localizedStrings != null) { 496 localizedStringSources.add(new LocalizedStringSource(scriptLocale, getLocalizedStringsByKeyByLocale().get(scriptLocale))); 497 498 if (logger.isLoggable(Level.FINER)) 499 logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(), 500 scriptLocale.toLanguageTag())); 501 } 502 } 503 504 // Language (1) 505 Locale languageLocale = new Locale.Builder().setLanguage(language).build(); 506 507 if (!matchingLocales.contains(languageLocale)) { 508 matchingLocales.add(languageLocale); 509 510 localizedStrings = getLocalizedStringsByLocale().get(languageLocale); 511 512 if (localizedStrings != null) { 513 localizedStringSources.add(new LocalizedStringSource(languageLocale, getLocalizedStringsByKeyByLocale().get(languageLocale))); 514 515 if (logger.isLoggable(Level.FINER)) 516 logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(), 517 languageLocale.toLanguageTag())); 518 } 519 } 520 521 // Finally, add the default locale if necessary 522 Locale fallbackLocale = getFallbackLocale(); 523 524 if (!matchingLocales.contains(fallbackLocale)) { 525 matchingLocales.add(fallbackLocale); 526 527 localizedStrings = getLocalizedStringsByLocale().get(fallbackLocale); 528 529 if (localizedStrings != null) { 530 localizedStringSources.add(new LocalizedStringSource(fallbackLocale, getLocalizedStringsByKeyByLocale().get(fallbackLocale))); 531 532 if (logger.isLoggable(Level.FINER)) 533 logger.finer(format("A matching strings file for locale '%s' is fallback '%s'", 534 locale.toLanguageTag(), fallbackLocale.toLanguageTag())); 535 } 536 } 537 538 return Collections.unmodifiableList(localizedStringSources); 539 }); 540 } 541 542 /** 543 * Gets the fallback language code. 544 * 545 * @return the fallback language code, not null 546 */ 547 @Nonnull 548 public String getFallbackLanguageCode() { 549 return fallbackLanguageCode; 550 } 551 552 /** 553 * Gets the set of localized strings for each locale. 554 * 555 * @return the set of localized strings for each locale, not null 556 */ 557 @Nonnull 558 public Map<Locale, Set<LocalizedString>> getLocalizedStringsByLocale() { 559 return localizedStringsByLocale; 560 } 561 562 /** 563 * Gets the locale supplier. 564 * 565 * @return the locale supplier, not null 566 */ 567 @Nonnull 568 protected Optional<Supplier<Locale>> getLocaleSupplier() { 569 return Optional.ofNullable(localeSupplier); 570 } 571 572 /** 573 * Gets the language ranges supplier. 574 * 575 * @return the language ranges supplier, not null 576 */ 577 @Nonnull 578 protected Optional<Supplier<List<LanguageRange>>> getLanguageRangesSupplier() { 579 return Optional.ofNullable(languageRangesSupplier); 580 } 581 582 /** 583 * Gets the strategy for handling string lookup failures. 584 * 585 * @return the strategy for handling string lookup failures, not null 586 */ 587 @Nonnull 588 public FailureMode getFailureMode() { 589 return failureMode; 590 } 591 592 /** 593 * Gets the fallback locale. 594 * 595 * @return the fallback locale, not null 596 */ 597 @Nonnull 598 protected Locale getFallbackLocale() { 599 return fallbackLocale; 600 } 601 602 /** 603 * Gets the locale to use if one was not explicitly provided. 604 * 605 * @return the implicit locale to use, not null 606 */ 607 @Nonnull 608 protected Locale getImplicitLocale() { 609 Locale locale = null; 610 611 if (getLocaleSupplier().isPresent()) { 612 locale = getLocaleSupplier().get().get(); 613 } else if (getLanguageRangesSupplier().isPresent()) { 614 List<LanguageRange> languageRanges = getLanguageRangesSupplier().get().get(); 615 616 if (languageRanges != null) 617 locale = Locale.lookup(languageRanges, getLocalizedStringsByLocale().keySet()); 618 } 619 620 return locale == null ? getFallbackLocale() : locale; 621 } 622 623 /** 624 * Gets the string interpolator used to merge placeholders into translations. 625 * 626 * @return the string interpolator, not null 627 */ 628 @Nonnull 629 protected StringInterpolator getStringInterpolator() { 630 return stringInterpolator; 631 } 632 633 /** 634 * Gets the expression evaluator used to determine if alternative expressions match the evaluation context. 635 * 636 * @return the expression evaluator, not null 637 */ 638 @Nonnull 639 protected ExpressionEvaluator getExpressionEvaluator() { 640 return expressionEvaluator; 641 } 642 643 /** 644 * Gets our "master" cache of localized strings by key by locale. 645 * 646 * @return the cache of localized strings by key by locale, not null 647 */ 648 @Nonnull 649 protected Map<Locale, Map<String, LocalizedString>> getLocalizedStringsByKeyByLocale() { 650 return localizedStringsByKeyByLocale; 651 } 652 653 /** 654 * Get the "runtime" generated map of locales to localized string sources. 655 * 656 * @return the map of locales to localized string sources, not null 657 */ 658 @Nonnull 659 protected ConcurrentHashMap<Locale, List<LocalizedStringSource>> getLocalizedStringSourcesByLocale() { 660 return localizedStringSourcesByLocale; 661 } 662 663 /** 664 * Data structure which holds a locale and the localized strings for it, with the strings mapped by key for fast access. 665 * 666 * @author <a href="https://revetkn.com">Mark Allen</a> 667 */ 668 @Immutable 669 static class LocalizedStringSource { 670 @Nonnull 671 private final Locale locale; 672 @Nonnull 673 private final Map<String, LocalizedString> localizedStringsByKey; 674 675 /** 676 * Constructs a localized string source with the given locale and map of keys to localized strings. 677 * 678 * @param locale the locale for these localized strings, not null 679 * @param localizedStringsByKey localized strings by translation key, not null 680 */ 681 public LocalizedStringSource(@Nonnull Locale locale, @Nonnull Map<String, LocalizedString> localizedStringsByKey) { 682 requireNonNull(locale); 683 requireNonNull(localizedStringsByKey); 684 685 this.locale = locale; 686 this.localizedStringsByKey = localizedStringsByKey; 687 } 688 689 /** 690 * Generates a {@code String} representation of this object. 691 * 692 * @return a string representation of this object, not null 693 */ 694 @Override 695 @Nonnull 696 public String toString() { 697 return format("%s{locale=%s, localizedStringsByKey=%s", getClass().getSimpleName(), getLocale(), getLocalizedStringsByKey()); 698 } 699 700 /** 701 * Checks if this object is equal to another one. 702 * 703 * @param other the object to check, null returns false 704 * @return true if this is equal to the other object, false otherwise 705 */ 706 @Override 707 public boolean equals(@Nullable Object other) { 708 if (this == other) 709 return true; 710 711 if (other == null || !getClass().equals(other.getClass())) 712 return false; 713 714 LocalizedStringSource localizedStringSource = (LocalizedStringSource) other; 715 716 return Objects.equals(getLocale(), localizedStringSource.getLocale()) 717 && Objects.equals(getLocalizedStringsByKey(), localizedStringSource.getLocalizedStringsByKey()); 718 } 719 720 /** 721 * A hash code for this object. 722 * 723 * @return a suitable hash code 724 */ 725 @Override 726 public int hashCode() { 727 return Objects.hash(getLocale(), getLocalizedStringsByKey()); 728 } 729 730 @Nonnull 731 public Locale getLocale() { 732 return locale; 733 } 734 735 @Nonnull 736 public Map<String, LocalizedString> getLocalizedStringsByKey() { 737 return localizedStringsByKey; 738 } 739 } 740 741 /** 742 * Strategies for handling localized string lookup failures. 743 */ 744 public enum FailureMode { 745 /** 746 * The system will attempt a series of fallbacks in order to not throw an exception at runtime. 747 * <p> 748 * This mode is useful for production, where we often want program execution to continue in the face of 749 * localization errors. 750 */ 751 USE_FALLBACK, 752 /** 753 * The system will throw an exception if a localization is missing for the specified locale. 754 * <p> 755 * This mode is useful for testing, since problems are uncovered right away when execution halts. 756 */ 757 FAIL_FAST 758 } 759 760 /** 761 * Builder used to construct instances of {@link DefaultStrings}. 762 * <p> 763 * You cannot provide both a {@code localeSupplier} and a {@code languageRangesSupplier} - you must choose one or neither. 764 * <p> 765 * This class is intended for use by a single thread. 766 * 767 * @author <a href="https://revetkn.com">Mark Allen</a> 768 */ 769 @NotThreadSafe 770 public static class Builder { 771 @Nonnull 772 private final String fallbackLanguageCode; 773 @Nonnull 774 private final Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier; 775 @Nullable 776 private Supplier<Locale> localeSupplier; 777 @Nullable 778 private Supplier<List<LanguageRange>> languageRangesSupplier; 779 @Nullable 780 private FailureMode failureMode; 781 782 /** 783 * Constructs a strings builder with a default language code and localized string supplier. 784 * <p> 785 * The fallback language code must be an ISO 639 alpha-2 or alpha-3 language code. 786 * When a language has both an alpha-2 code and an alpha-3 code, the alpha-2 code must be used. 787 * 788 * @param fallbackLanguageCode fallback language code, not null 789 * @param localizedStringSupplier supplier of localized strings, not null 790 */ 791 public Builder(@Nonnull String fallbackLanguageCode, @Nonnull Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier) { 792 requireNonNull(fallbackLanguageCode); 793 requireNonNull(localizedStringSupplier); 794 795 this.fallbackLanguageCode = fallbackLanguageCode; 796 this.localizedStringSupplier = localizedStringSupplier; 797 } 798 799 /** 800 * Applies a locale supplier to this builder. 801 * 802 * @param localeSupplier locale supplier, may be null 803 * @return this builder instance, useful for chaining. not null 804 */ 805 @Nonnull 806 public Builder localeSupplier(@Nullable Supplier<Locale> localeSupplier) { 807 this.localeSupplier = localeSupplier; 808 return this; 809 } 810 811 /** 812 * Applies a supplier of language ranges to this builder. 813 * 814 * @param languageRangesSupplier language ranges supplier, may be null 815 * @return this builder instance, useful for chaining. not null 816 */ 817 @Nonnull 818 public Builder languageRangesSupplier(@Nullable Supplier<List<LanguageRange>> languageRangesSupplier) { 819 this.languageRangesSupplier = languageRangesSupplier; 820 return this; 821 } 822 823 /** 824 * Applies a failure mode to this builder. 825 * 826 * @param failureMode strategy for dealing with lookup failures, may be null 827 * @return this builder instance, useful for chaining. not null 828 */ 829 @Nonnull 830 public Builder failureMode(@Nullable FailureMode failureMode) { 831 this.failureMode = failureMode; 832 return this; 833 } 834 835 /** 836 * Constructs an instance of {@link DefaultStrings}. 837 * 838 * @return an instance of {@link DefaultStrings}, not null 839 */ 840 @Nonnull 841 public DefaultStrings build() { 842 return new DefaultStrings(fallbackLanguageCode, localizedStringSupplier, localeSupplier, languageRangesSupplier, failureMode); 843 } 844 } 845}