001/* 002 * Copyright 2017-2022 Product Mog LLC, 2022-2025 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; 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.Comparator; 030import java.util.HashMap; 031import java.util.HashSet; 032import java.util.LinkedHashSet; 033import java.util.List; 034import java.util.Locale; 035import java.util.Locale.LanguageRange; 036import java.util.Map; 037import java.util.Map.Entry; 038import java.util.Objects; 039import java.util.Optional; 040import java.util.Set; 041import java.util.function.Function; 042import java.util.function.Supplier; 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 the current request, e.g. 056 * from a set of {@link LanguageRange} parsed from the {@code Accept-Language} header. 057 * 058 * @author <a href="https://revetkn.com">Mark Allen</a> 059 */ 060@ThreadSafe 061public class DefaultStrings implements Strings { 062 @Nonnull 063 private final Map<Locale, Set<LocalizedString>> localizedStringsByLocale; 064 @Nullable 065 private final Function<LocaleMatcher, Locale> localeSupplier; 066 @Nonnull 067 private final Map<String, List<Locale>> tiebreakerLocalesByLanguageCode; 068 @Nonnull 069 private final FailureMode failureMode; 070 @Nonnull 071 private final Locale fallbackLocale; 072 @Nonnull 073 private final StringInterpolator stringInterpolator; 074 @Nonnull 075 private final ExpressionEvaluator expressionEvaluator; 076 @Nonnull 077 private final Logger logger; 078 079 /** 080 * Cache of localized strings by key by locale. 081 * <p> 082 * This is our "master" reference localized string storage that other data structures will point to. 083 */ 084 @Nonnull 085 private final Map<Locale, Map<String, LocalizedString>> localizedStringsByKeyByLocale; 086 087 /** 088 * Vends a builder suitable for constructing {@link DefaultStrings) instances. 089 * <p> 090 * This method is package-private and designed to be invoked via {@link Strings#withFallbackLocale(Locale)}. 091 * 092 * @param fallbackLocale the fallback locale used if no others match, not null 093 * @return the builder, not null 094 */ 095 @Nonnull 096 static Builder withFallbackLocale(@Nonnull Locale fallbackLocale) { 097 requireNonNull(fallbackLocale); 098 return new Builder(fallbackLocale); 099 } 100 101 /** 102 * Constructs a localized string provider with builder-supplied data. 103 * 104 * @param fallbackLocale fallback locale, not null 105 * @param localizedStringSupplier supplier of localized strings, not null 106 * @param localeSupplier locale supplier, may not be null 107 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallbacks, may be null 108 * @param failureMode strategy for dealing with lookup failures, may be null 109 */ 110 protected DefaultStrings(@Nonnull Locale fallbackLocale, 111 @Nonnull Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier, 112 @Nonnull Function<LocaleMatcher, Locale> localeSupplier, 113 @Nullable Map<String, List<Locale>> tiebreakerLocalesByLanguageCode, 114 @Nullable FailureMode failureMode) { 115 requireNonNull(fallbackLocale); 116 requireNonNull(localizedStringSupplier, format("You must specify a 'localizedStringSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName())); 117 requireNonNull(localeSupplier, format("You must specify a 'localeSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName())); 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 = fallbackLocale; 138 this.localizedStringsByLocale = Collections.unmodifiableMap(localizedStringsByLocale); 139 140 // Make our own mapping of tiebreakers based on the provided mapping. 141 // First, defensive copy, then add to the map as needed below. 142 Map<String, List<Locale>> internalTiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode == null ? new HashMap<>() : new HashMap<>(tiebreakerLocalesByLanguageCode); 143 144 // Verify tiebreakers are provided to support locale resolution when ambiguity exists. 145 // For each language code, if there is more than 1 localized strings file that matches it, tiebreakers must be provided. 146 Map<String, Set<Locale>> supportedLocalesByLanguageCode = new HashMap<>(localizedStringsByLocale.size()); 147 148 for (Locale supportedLocale : localizedStringsByLocale.keySet()) { 149 String languageCode = supportedLocale.getLanguage(); 150 Set<Locale> locales = supportedLocalesByLanguageCode.get(languageCode); 151 152 if (locales == null) { 153 locales = new HashSet<>(); 154 supportedLocalesByLanguageCode.put(languageCode, locales); 155 } 156 157 locales.add(supportedLocale); 158 } 159 160 for (Entry<String, Set<Locale>> entry : supportedLocalesByLanguageCode.entrySet()) { 161 String languageCode = entry.getKey(); 162 List<Locale> locales = entry.getValue().stream() 163 .sorted(Comparator.comparing(Locale::toLanguageTag)) 164 .collect(Collectors.toList()); 165 166 if (locales.size() == 1) { 167 // If there is exactly 1 locale for the language code, it's its own "identity" tiebreaker. 168 internalTiebreakerLocalesByLanguageCode.put(languageCode, locales); 169 } else if (locales.size() > 1) { 170 // We need to provide tiebreakers if a locale has more than 1 strings file. 171 List<Locale> providedTiebreakerLocales = internalTiebreakerLocalesByLanguageCode.get(languageCode); 172 173 if (providedTiebreakerLocales == null || providedTiebreakerLocales.size() == 0) { 174 throw new IllegalArgumentException(format("You must specify tiebreaker locales via 'tiebreakerLocalesByLanguageCode' to resolve ambiguity for language code '%s' because localized strings exist for the following locale[s]: %s", 175 languageCode, locales.stream().map(locale -> locale.toLanguageTag()).collect(Collectors.toList()))); 176 } else { 177 // First, verify that all tiebreakers actually exist 178 Set<Locale> supportedLocales = localizedStringsByLocale.keySet(); 179 180 for (Locale providedTiebreakerLocale : providedTiebreakerLocales) 181 if (!supportedLocales.contains(providedTiebreakerLocale)) 182 throw new IllegalArgumentException(format("Tiebreaker locale '%s' specified in 'tiebreakerLocalesByLanguageCode' does not have a localized strings file. Supported locales are: %s", 183 providedTiebreakerLocale.toLanguageTag(), supportedLocales.stream().map(supportedLocale -> supportedLocale.toLanguageTag()).sorted().collect(Collectors.toList()))); 184 185 // Next, verify that tiebreakers are exhaustively specified 186 List<Locale> missingLocales = new ArrayList<>(locales.size()); 187 188 for (Locale locale : locales) 189 if (!providedTiebreakerLocales.contains(locale)) 190 missingLocales.add(locale); 191 192 if (missingLocales.size() > 0) 193 throw new IllegalArgumentException(format("Your 'tiebreakerLocalesByLanguageCode' specifies locale[s] %s for language code '%s', but you are missing entries for the following locale[s]: %s", 194 providedTiebreakerLocales.stream().map(providedTiebreakerLocale -> providedTiebreakerLocale.toLanguageTag()).sorted().collect(Collectors.toList()), 195 languageCode, 196 missingLocales.stream().map(missingLocale -> missingLocale.toLanguageTag()).sorted().collect(Collectors.toList()))); 197 } 198 199 internalTiebreakerLocalesByLanguageCode.put(languageCode, locales); 200 } else { 201 // Should never occur 202 throw new IllegalStateException("No locales match language code"); 203 } 204 } 205 206 this.tiebreakerLocalesByLanguageCode = Collections.unmodifiableMap(internalTiebreakerLocalesByLanguageCode); 207 208 this.failureMode = failureMode == null ? FailureMode.USE_FALLBACK : failureMode; 209 this.stringInterpolator = new StringInterpolator(); 210 this.expressionEvaluator = new ExpressionEvaluator(); 211 212 this.localizedStringsByKeyByLocale = Collections.unmodifiableMap(localizedStringsByLocale.entrySet().stream() 213 .collect(Collectors.toMap( 214 entry1 -> entry1.getKey(), 215 entry1 -> 216 Collections.unmodifiableMap(entry1.getValue().stream() 217 .collect(Collectors.toMap( 218 entry2 -> entry2.getKey(), 219 entry2 -> entry2 220 ) 221 ))))); 222 223 if (!localizedStringsByLocale.containsKey(getFallbackLocale())) 224 throw new IllegalArgumentException(format("Specified fallback locale is '%s' but no matching " + 225 "localized strings locale was found. Known locales: [%s]", fallbackLocale.toLanguageTag(), 226 localizedStringsByLocale.keySet().stream() 227 .map(locale -> locale.toLanguageTag()) 228 .sorted() 229 .collect(Collectors.joining(", ")))); 230 231 this.localeSupplier = localeSupplier; 232 } 233 234 @Nonnull 235 @Override 236 public String get(@Nonnull String key) { 237 requireNonNull(key); 238 return get(key, null, getLocaleSupplier().apply(this)); 239 } 240 241 @Nonnull 242 @Override 243 public String get(@Nonnull String key, 244 @Nullable Map<String, Object> placeholders) { 245 requireNonNull(key); 246 return get(key, placeholders, getLocaleSupplier().apply(this)); 247 } 248 249 @Nonnull 250 protected String get(@Nonnull String key, 251 @Nullable Map<String, Object> placeholders, 252 @Nonnull Locale locale) { 253 requireNonNull(key); 254 requireNonNull(locale); 255 256 if (placeholders == null) 257 placeholders = Collections.emptyMap(); 258 259 Locale finalLocale = locale; 260 Map<String, Object> mutableContext = new HashMap<>(placeholders); 261 Map<String, Object> immutableContext = Collections.unmodifiableMap(placeholders); 262 263 Map<String, LocalizedString> localizedStrings = getLocalizedStringsByKeyByLocale().get(locale); 264 265 if (localizedStrings == null) { 266 finalLocale = getFallbackLocale(); 267 localizedStrings = getLocalizedStringsByKeyByLocale().get(getFallbackLocale()); 268 } 269 270 // Should never occur 271 if (localizedStrings == null) 272 throw new IllegalStateException(format("Unable to find strings file for both '%s' and fallback locale '%s'", 273 locale.toLanguageTag(), getFallbackLocale().toLanguageTag())); 274 275 LocalizedString localizedString = localizedStrings.get(key); 276 String translation = null; 277 278 if (localizedString != null) 279 translation = getInternal(key, localizedString, mutableContext, immutableContext, finalLocale).orElse(null); 280 281 if (translation == null) { 282 String message = format("No match for '%s' was found for locale '%s'.", key, locale.toLanguageTag()); 283 logger.finer(message); 284 285 if (getFailureMode() == FailureMode.FAIL_FAST) 286 throw new MissingTranslationException(message, key, placeholders, locale); 287 288 // Not fail-fast? Merge against the key itself in hopes that the key is a meaningful natural-language value 289 translation = getStringInterpolator().interpolate(key, mutableContext); 290 } 291 292 return translation; 293 } 294 295 /** 296 * Recursive method which attempts to translate a localized string. 297 * 298 * @param key the toplevel translation key (always the same regardless of recursion depth), not null 299 * @param localizedString the localized string on which to operate, not null 300 * @param mutableContext the mutable context for the translation, not null 301 * @param immutableContext the original user-supplied translation context, not null 302 * @param locale the locale to use for evaluation, not null 303 * @return the translation, if possible (may not be possible if no translation value specified and no alternative expressions match), not null 304 */ 305 @Nonnull 306 protected Optional<String> getInternal(@Nonnull String key, @Nonnull LocalizedString localizedString, 307 @Nonnull Map<String, Object> mutableContext, @Nonnull Map<String, Object> immutableContext, 308 @Nonnull Locale locale) { 309 requireNonNull(key); 310 requireNonNull(localizedString); 311 requireNonNull(mutableContext); 312 requireNonNull(immutableContext); 313 requireNonNull(locale); 314 315 // First, see if any alternatives match by evaluating them 316 for (LocalizedString alternative : localizedString.getAlternatives()) { 317 if (getExpressionEvaluator().evaluate(alternative.getKey(), mutableContext, locale)) { 318 logger.finer(format("An alternative match for '%s' was found for key '%s' and context %s", alternative.getKey(), key, mutableContext)); 319 320 // If we have a matching alternative, recurse into it 321 return getInternal(key, alternative, mutableContext, immutableContext, locale); 322 } 323 } 324 325 if (!localizedString.getTranslation().isPresent()) 326 return Optional.empty(); 327 328 String translation = localizedString.getTranslation().get(); 329 330 for (Entry<String, LanguageFormTranslation> entry : localizedString.getLanguageFormTranslationsByPlaceholder().entrySet()) { 331 String placeholderName = entry.getKey(); 332 LanguageFormTranslation languageFormTranslation = entry.getValue(); 333 Object value = null; 334 Object rangeStart = null; 335 Object rangeEnd = null; 336 Map<Cardinality, String> translationsByCardinality = new HashMap<>(); 337 Map<Ordinality, String> translationsByOrdinality = new HashMap<>(); 338 Map<Gender, String> translationsByGender = new HashMap<>(); 339 340 if (languageFormTranslation.getRange().isPresent()) { 341 LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get(); 342 rangeStart = immutableContext.get(languageFormTranslationRange.getStart()); 343 rangeEnd = immutableContext.get(languageFormTranslationRange.getEnd()); 344 } else { 345 value = immutableContext.get(languageFormTranslation.getValue().get()); 346 } 347 348 for (Entry<LanguageForm, String> translationEntry : languageFormTranslation.getTranslationsByLanguageForm().entrySet()) { 349 LanguageForm languageForm = translationEntry.getKey(); 350 String translatedLanguageForm = translationEntry.getValue(); 351 352 if (languageForm instanceof Cardinality) 353 translationsByCardinality.put((Cardinality) languageForm, translatedLanguageForm); 354 else if (languageForm instanceof Ordinality) 355 translationsByOrdinality.put((Ordinality) languageForm, translatedLanguageForm); 356 else if (languageForm instanceof Gender) 357 translationsByGender.put((Gender) languageForm, translatedLanguageForm); 358 else 359 throw new IllegalArgumentException(format("Encountered unrecognized language form %s", languageForm)); 360 } 361 362 int distinctLanguageForms = (translationsByCardinality.size() > 0 ? 1 : 0) + 363 (translationsByOrdinality.size() > 0 ? 1 : 0) + 364 (translationsByGender.size() > 0 ? 1 : 0); 365 366 if (distinctLanguageForms > 1) 367 throw new IllegalArgumentException(format("You cannot mix-and-match language forms. Offending localized string was %s", localizedString)); 368 369 if (distinctLanguageForms == 0) 370 continue; 371 372 // Handle plural cardinalities 373 if (translationsByCardinality.size() > 0) { 374 // Special case: calculate range from min and max if this is a range-driven cardinality 375 if (languageFormTranslation.getRange().isPresent()) { 376 if (rangeStart == null) 377 rangeStart = 0; 378 if (rangeEnd == null) 379 rangeEnd = 0; 380 381 if (!(rangeStart instanceof Number)) { 382 logger.warning(format("Range start '%s' for '%s' is not a number, falling back to 0.", 383 rangeStart, languageFormTranslation.getValue())); 384 rangeStart = 0; 385 } 386 387 if (!(rangeEnd instanceof Number)) { 388 logger.warning(format("Range value end '%s' for '%s' is not a number, falling back to 0.", 389 rangeEnd, languageFormTranslation.getValue())); 390 rangeEnd = 0; 391 } 392 393 Cardinality startCardinality = Cardinality.forNumber((Number) rangeStart, locale); 394 Cardinality endCardinality = Cardinality.forNumber((Number) rangeEnd, locale); 395 Cardinality rangeCardinality = Cardinality.forRange(startCardinality, endCardinality, locale); 396 397 String cardinalityTranslation = translationsByCardinality.get(rangeCardinality); 398 399 if (cardinalityTranslation == null) 400 logger.warning(format("Unable to find %s translation for range cardinality %s (start was %s, end was %s). Localized string was %s", 401 Cardinality.class.getSimpleName(), rangeCardinality.name(), startCardinality.name(), endCardinality.name(), localizedString)); 402 403 mutableContext.put(placeholderName, cardinalityTranslation); 404 } else { 405 // Normal "non-range" cardinality 406 if (value == null) 407 value = 0; 408 409 if (!(value instanceof Number)) { 410 logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.", 411 value, languageFormTranslation.getValue())); 412 value = 0; 413 } 414 415 Cardinality cardinality = Cardinality.forNumber((Number) value, locale); 416 String cardinalityTranslation = translationsByCardinality.get(cardinality); 417 418 if (cardinalityTranslation == null) 419 logger.warning(format("Unable to find %s translation for %s. Localized string was %s", 420 Cardinality.class.getSimpleName(), cardinality.name(), localizedString)); 421 422 mutableContext.put(placeholderName, cardinalityTranslation); 423 } 424 } 425 426 // Handle plural ordinalities 427 if (translationsByOrdinality.size() > 0) { 428 if (value == null) 429 value = 0; 430 431 if (!(value instanceof Number)) { 432 logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.", 433 value, languageFormTranslation.getValue())); 434 value = 0; 435 } 436 437 Ordinality ordinality = Ordinality.forNumber((Number) value, locale); 438 String ordinalityTranslation = translationsByOrdinality.get(ordinality); 439 440 if (ordinalityTranslation == null) 441 logger.warning(format("Unable to find %s translation for %s. Localized string was %s", 442 Ordinality.class.getSimpleName(), ordinality.name(), localizedString)); 443 444 mutableContext.put(placeholderName, ordinalityTranslation); 445 } 446 447 // Handle genders 448 if (translationsByGender.size() > 0) { 449 if (value == null) { 450 logger.warning(format("Value '%s' for '%s' is null. No replacement will be performed.", value, 451 languageFormTranslation.getValue())); 452 continue; 453 } 454 455 if (!(value instanceof Gender)) { 456 logger.warning(format("Value '%s' for '%s' is not a %s. No replacement will be performed.", value, 457 languageFormTranslation.getValue(), Gender.class.getSimpleName())); 458 continue; 459 } 460 461 Gender gender = (Gender) value; 462 String genderTranslation = translationsByGender.get(gender); 463 464 if (genderTranslation == null) 465 logger.warning(format("Unable to find %s translation for %s. Localized string was %s", 466 Gender.class.getSimpleName(), gender.name(), localizedString)); 467 468 mutableContext.put(placeholderName, genderTranslation); 469 } 470 } 471 472 translation = getStringInterpolator().interpolate(translation, mutableContext); 473 474 return Optional.of(translation); 475 } 476 477 @Nonnull 478 @Override 479 public Locale bestMatchFor(@Nonnull Locale locale) { 480 requireNonNull(locale); 481 return bestMatchFor(List.of(new LanguageRange(locale.toLanguageTag()))); 482 } 483 484 @Nonnull 485 @Override 486 public Locale bestMatchFor(@Nonnull List<LanguageRange> languageRanges) { 487 requireNonNull(languageRanges); 488 489 if (languageRanges.isEmpty()) 490 return getFallbackLocale(); 491 492 // Walk through each LanguageRange in preference order 493 for (LanguageRange languageRange : languageRanges) { 494 String range = languageRange.getRange(); // e.g. "pt" or "pt-PT" 495 double weight = languageRange.getWeight(); 496 497 if (weight <= 0) 498 continue; 499 500 // Exact tag match? 501 for (Locale locale : getLocalizedStringsByLocale().keySet()) 502 if (locale.toLanguageTag().equalsIgnoreCase(range)) 503 return locale; 504 505 // Primary‐tag match (e.g. range="pt" or "pt-XX") 506 String primary = range.split("-")[0]; // e.g. "pt" 507 List<Locale> candidates = getLocalizedStringsByLocale().keySet().stream() 508 .filter(locale -> locale.getLanguage().equalsIgnoreCase(primary)) 509 .collect(Collectors.toList()); 510 511 if (candidates.isEmpty()) 512 continue; // try the next LanguageRange 513 514 if (candidates.size() == 1) 515 return candidates.get(0); 516 517 // Tie‐breaker list for this primary tag? 518 List<Locale> tiebreakers = getTiebreakerLocalesByLanguageCode().get(primary); 519 520 if (tiebreakers != null) 521 for (Locale tiebreaker : tiebreakers) 522 if (candidates.contains(tiebreaker)) 523 return tiebreaker; 524 } 525 526 // 4) Nothing matched at all 527 return getFallbackLocale(); 528 } 529 530 /** 531 * Gets the set of localized strings for each locale. 532 * 533 * @return the set of localized strings for each locale, not null 534 */ 535 @Nonnull 536 public Map<Locale, Set<LocalizedString>> getLocalizedStringsByLocale() { 537 return localizedStringsByLocale; 538 } 539 540 /** 541 * Gets the locale supplier. 542 * 543 * @return the locale supplier, not null 544 */ 545 @Nonnull 546 public Function<LocaleMatcher, Locale> getLocaleSupplier() { 547 return this.localeSupplier; 548 } 549 550 /** 551 * Gets the mapping of a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales. 552 * 553 * @return the per-language-code "tiebreaker" locales, not null 554 */ 555 @Nonnull 556 public Map<String, List<Locale>> getTiebreakerLocalesByLanguageCode() { 557 return this.tiebreakerLocalesByLanguageCode; 558 } 559 560 /** 561 * Gets the strategy for handling string lookup failures. 562 * 563 * @return the strategy for handling string lookup failures, not null 564 */ 565 @Nonnull 566 public FailureMode getFailureMode() { 567 return failureMode; 568 } 569 570 /** 571 * Gets the fallback locale. 572 * 573 * @return the fallback locale, not null 574 */ 575 @Nonnull 576 public Locale getFallbackLocale() { 577 return fallbackLocale; 578 } 579 580 /** 581 * Gets the string interpolator used to merge placeholders into translations. 582 * 583 * @return the string interpolator, not null 584 */ 585 @Nonnull 586 protected StringInterpolator getStringInterpolator() { 587 return stringInterpolator; 588 } 589 590 /** 591 * Gets the expression evaluator used to determine if alternative expressions match the evaluation context. 592 * 593 * @return the expression evaluator, not null 594 */ 595 @Nonnull 596 protected ExpressionEvaluator getExpressionEvaluator() { 597 return expressionEvaluator; 598 } 599 600 /** 601 * Gets our "master" cache of localized strings by key by locale. 602 * 603 * @return the cache of localized strings by key by locale, not null 604 */ 605 @Nonnull 606 protected Map<Locale, Map<String, LocalizedString>> getLocalizedStringsByKeyByLocale() { 607 return localizedStringsByKeyByLocale; 608 } 609 610 /** 611 * Data structure which holds a locale and the localized strings for it, with the strings mapped by key for fast access. 612 * 613 * @author <a href="https://revetkn.com">Mark Allen</a> 614 */ 615 @Immutable 616 static class LocalizedStringSource { 617 @Nonnull 618 private final Locale locale; 619 @Nonnull 620 private final Map<String, LocalizedString> localizedStringsByKey; 621 622 /** 623 * Constructs a localized string source with the given locale and map of keys to localized strings. 624 * 625 * @param locale the locale for these localized strings, not null 626 * @param localizedStringsByKey localized strings by translation key, not null 627 */ 628 public LocalizedStringSource(@Nonnull Locale locale, @Nonnull Map<String, LocalizedString> localizedStringsByKey) { 629 requireNonNull(locale); 630 requireNonNull(localizedStringsByKey); 631 632 this.locale = locale; 633 this.localizedStringsByKey = localizedStringsByKey; 634 } 635 636 /** 637 * Generates a {@code String} representation of this object. 638 * 639 * @return a string representation of this object, not null 640 */ 641 @Override 642 @Nonnull 643 public String toString() { 644 return format("%s{locale=%s, localizedStringsByKey=%s", getClass().getSimpleName(), getLocale(), getLocalizedStringsByKey()); 645 } 646 647 /** 648 * Checks if this object is equal to another one. 649 * 650 * @param other the object to check, null returns false 651 * @return true if this is equal to the other object, false otherwise 652 */ 653 @Override 654 public boolean equals(@Nullable Object other) { 655 if (this == other) 656 return true; 657 658 if (other == null || !getClass().equals(other.getClass())) 659 return false; 660 661 LocalizedStringSource localizedStringSource = (LocalizedStringSource) other; 662 663 return Objects.equals(getLocale(), localizedStringSource.getLocale()) 664 && Objects.equals(getLocalizedStringsByKey(), localizedStringSource.getLocalizedStringsByKey()); 665 } 666 667 /** 668 * A hash code for this object. 669 * 670 * @return a suitable hash code 671 */ 672 @Override 673 public int hashCode() { 674 return Objects.hash(getLocale(), getLocalizedStringsByKey()); 675 } 676 677 @Nonnull 678 public Locale getLocale() { 679 return locale; 680 } 681 682 @Nonnull 683 public Map<String, LocalizedString> getLocalizedStringsByKey() { 684 return localizedStringsByKey; 685 } 686 } 687 688 /** 689 * Strategies for handling localized string lookup failures. 690 */ 691 public enum FailureMode { 692 /** 693 * The system will attempt a series of fallbacks in order to not throw an exception at runtime. 694 * <p> 695 * This mode is useful for production, where we often want program execution to continue in the face of 696 * localization errors. 697 */ 698 USE_FALLBACK, 699 /** 700 * The system will throw an exception if a localization is missing for the specified locale. 701 * <p> 702 * This mode is useful for testing, since problems are uncovered right away when execution halts. 703 */ 704 FAIL_FAST 705 } 706 707 /** 708 * Builder used to construct instances of {@link DefaultStrings}. 709 * <p> 710 * This class is intended for use by a single thread. 711 * 712 * @author <a href="https://revetkn.com">Mark Allen</a> 713 */ 714 @NotThreadSafe 715 public static class Builder { 716 @Nonnull 717 private final Locale fallbackLocale; 718 @Nullable 719 private Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier; 720 @Nullable 721 private Function<LocaleMatcher, Locale> localeSupplier; 722 @Nullable 723 private Supplier<List<LanguageRange>> languageRangesSupplier; 724 @Nullable 725 private Map<String, List<Locale>> tiebreakerLocalesByLanguageCode; 726 @Nullable 727 private FailureMode failureMode; 728 729 /** 730 * Constructs a strings builder with a default locale. 731 * 732 * @param fallbackLocale fallback locale, not null 733 */ 734 protected Builder(@Nonnull Locale fallbackLocale) { 735 requireNonNull(fallbackLocale); 736 this.fallbackLocale = fallbackLocale; 737 } 738 739 /** 740 * Applies a localized string supplier to this builder. 741 * 742 * @param localizedStringSupplier localized string supplier, may be null 743 * @return this builder instance, useful for chaining. not null 744 */ 745 @Nonnull 746 public Builder localizedStringSupplier(@Nullable Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier) { 747 this.localizedStringSupplier = localizedStringSupplier; 748 return this; 749 } 750 751 /** 752 * Applies a locale supplier to this builder. 753 * 754 * @param localeSupplier locale supplier, may be null 755 * @return this builder instance, useful for chaining. not null 756 */ 757 @Nonnull 758 public Builder localeSupplier(@Nullable Function<LocaleMatcher, Locale> localeSupplier) { 759 this.localeSupplier = localeSupplier; 760 return this; 761 } 762 763 /** 764 * Applies a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales to this builder. 765 * 766 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallback locales, may be null 767 * @return this builder instance, useful for chaining. not null 768 */ 769 @Nonnull 770 public Builder tiebreakerLocalesByLanguageCode(@Nullable Map<String, List<Locale>> tiebreakerLocalesByLanguageCode) { 771 this.tiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode; 772 return this; 773 } 774 775 /** 776 * Applies a failure mode to this builder. 777 * 778 * @param failureMode strategy for dealing with lookup failures, may be null 779 * @return this builder instance, useful for chaining. not null 780 */ 781 @Nonnull 782 public Builder failureMode(@Nullable FailureMode failureMode) { 783 this.failureMode = failureMode; 784 return this; 785 } 786 787 /** 788 * Constructs an instance of {@link DefaultStrings}. 789 * 790 * @return an instance of {@link DefaultStrings}, not null 791 */ 792 @Nonnull 793 public DefaultStrings build() { 794 return new DefaultStrings(fallbackLocale, localizedStringSupplier, localeSupplier, tiebreakerLocalesByLanguageCode, failureMode); 795 } 796 } 797}