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 org.jspecify.annotations.NonNull; 022import org.jspecify.annotations.Nullable; 023 024import javax.annotation.concurrent.NotThreadSafe; 025import javax.annotation.concurrent.ThreadSafe; 026import java.util.ArrayList; 027import java.util.Collections; 028import java.util.Comparator; 029import java.util.HashMap; 030import java.util.HashSet; 031import java.util.LinkedHashMap; 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.Optional; 039import java.util.Set; 040import java.util.function.Function; 041import java.util.function.Supplier; 042import java.util.logging.Logger; 043import java.util.stream.Collectors; 044 045import static java.lang.String.format; 046import static java.util.Objects.requireNonNull; 047 048/** 049 * Default implementation of a localized string provider. 050 * <p> 051 * It is recommended to use a single instance of this class across your entire application. 052 * <p> 053 * In multi-tenant systems like a web application where each user might have a different locale, 054 * your {@code localeSupplier} might return the locale specified by the current request, e.g. 055 * from a set of {@link LanguageRange} parsed from the {@code Accept-Language} header. 056 * 057 * @author <a href="https://revetkn.com">Mark Allen</a> 058 */ 059@ThreadSafe 060public class DefaultStrings implements Strings { 061 @NonNull 062 private static final PhoneticResolver DEFAULT_PHONETIC_RESOLVER; 063 064 static { 065 DEFAULT_PHONETIC_RESOLVER = (term, locale) -> { 066 throw new IllegalStateException(format("No %s was configured. Provide one via %s.Builder#phoneticResolver(...)", 067 PhoneticResolver.class.getSimpleName(), Strings.class.getSimpleName())); 068 }; 069 } 070 071 @NonNull 072 private final Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale; 073 @NonNull 074 private final Function<LocaleMatcher, Locale> localeSupplier; 075 @NonNull 076 private final Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode; 077 @NonNull 078 private final FailureMode failureMode; 079 @NonNull 080 private final Locale fallbackLocale; 081 @NonNull 082 private final StringInterpolator stringInterpolator; 083 @NonNull 084 private final ExpressionEvaluator expressionEvaluator; 085 @NonNull 086 private final PhoneticResolver phoneticResolver; 087 @NonNull 088 private final Logger logger; 089 090 /** 091 * Cache of localized strings by key by locale. 092 * <p> 093 * This is our "master" reference localized string storage that other data structures will point to. 094 */ 095 @NonNull 096 private final Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> localizedStringsByKeyByLocale; 097 098 /** 099 * Vends a builder suitable for constructing {@link DefaultStrings) instances. 100 * <p> 101 * This method is package-private and designed to be invoked via {@link Strings#withFallbackLocale(Locale)}. 102 * 103 * @param fallbackLocale the fallback locale used if no others match, not null 104 * @return the builder, not null 105 */ 106 @NonNull 107 static Builder withFallbackLocale(@NonNull Locale fallbackLocale) { 108 requireNonNull(fallbackLocale); 109 return new Builder(fallbackLocale); 110 } 111 112 /** 113 * Constructs a localized string provider with builder-supplied data. 114 * 115 * @param fallbackLocale fallback locale, not null 116 * @param localizedStringSupplier supplier of localized strings, not null 117 * @param localeSupplier locale supplier, may not be null 118 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallbacks, may be null 119 * @param failureMode strategy for dealing with lookup failures, may be null 120 */ 121 protected DefaultStrings(@NonNull Locale fallbackLocale, 122 @NonNull Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier, 123 @NonNull Function<LocaleMatcher, Locale> localeSupplier, 124 @Nullable Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode, 125 @Nullable FailureMode failureMode) { 126 this(fallbackLocale, localizedStringSupplier, localeSupplier, tiebreakerLocalesByLanguageCode, failureMode, null); 127 } 128 129 /** 130 * Constructs a localized string provider with builder-supplied data. 131 * 132 * @param fallbackLocale fallback locale, not null 133 * @param localizedStringSupplier supplier of localized strings, not null 134 * @param localeSupplier locale supplier, may not be null 135 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallbacks, may be null 136 * @param failureMode strategy for dealing with lookup failures, may be null 137 * @param phoneticResolver resolver for phonetic categories, may be null (defaults to fail-fast resolver) 138 */ 139 protected DefaultStrings(@NonNull Locale fallbackLocale, 140 @NonNull Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier, 141 @NonNull Function<LocaleMatcher, Locale> localeSupplier, 142 @Nullable Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode, 143 @Nullable FailureMode failureMode, 144 @Nullable PhoneticResolver phoneticResolver) { 145 requireNonNull(fallbackLocale); 146 requireNonNull(localizedStringSupplier, format("You must specify a 'localizedStringSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName())); 147 requireNonNull(localeSupplier, format("You must specify a 'localeSupplier' when creating a %s instance", DefaultStrings.class.getSimpleName())); 148 149 this.logger = Logger.getLogger(LoggerType.STRINGS.getLoggerName()); 150 151 Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>> suppliedLocalizedStringsByLocale = localizedStringSupplier.get(); 152 153 if (suppliedLocalizedStringsByLocale == null) 154 suppliedLocalizedStringsByLocale = Collections.emptyMap(); 155 156 // Defensive copy of iterator to unmodifiable set 157 Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = suppliedLocalizedStringsByLocale.entrySet().stream() 158 .collect(Collectors.toMap( 159 entry -> entry.getKey(), 160 entry -> { 161 Set<@NonNull LocalizedString> localizedStrings = new LinkedHashSet<>(); 162 entry.getValue().forEach(localizedStrings::add); 163 return Collections.unmodifiableSet(localizedStrings); 164 } 165 )); 166 167 this.fallbackLocale = fallbackLocale; 168 this.localizedStringsByLocale = Collections.unmodifiableMap(localizedStringsByLocale); 169 170 // Make our own mapping of tiebreakers based on the provided mapping. 171 // First, defensive copy, then add to the map as needed below. 172 Map<@NonNull String, @Nullable List<@NonNull Locale>> internalTiebreakerLocalesByLanguageCode = new HashMap<>(); 173 174 if (tiebreakerLocalesByLanguageCode != null) { 175 for (Entry<@NonNull String, @Nullable List<@NonNull Locale>> entry : tiebreakerLocalesByLanguageCode.entrySet()) { 176 @Nullable List<@NonNull Locale> locales = entry.getValue(); 177 internalTiebreakerLocalesByLanguageCode.put(entry.getKey(), 178 locales == null ? null : new ArrayList<>(locales)); 179 } 180 } 181 182 // Verify tiebreakers are provided to support locale resolution when ambiguity exists. 183 // For each language code, if there is more than 1 localized strings file that matches it, tiebreakers must be provided. 184 Map<@NonNull String, @NonNull Set<@NonNull Locale>> supportedLocalesByLanguageCode = new HashMap<>(localizedStringsByLocale.size()); 185 186 for (Locale supportedLocale : localizedStringsByLocale.keySet()) { 187 String languageCode = supportedLocale.getLanguage(); 188 Set<@NonNull Locale> locales = supportedLocalesByLanguageCode.get(languageCode); 189 190 if (locales == null) { 191 locales = new HashSet<>(); 192 supportedLocalesByLanguageCode.put(languageCode, locales); 193 } 194 195 locales.add(supportedLocale); 196 } 197 198 for (Entry<@NonNull String, @NonNull Set<@NonNull Locale>> entry : supportedLocalesByLanguageCode.entrySet()) { 199 String languageCode = entry.getKey(); 200 List<@NonNull Locale> locales = entry.getValue().stream() 201 .sorted(Comparator.comparing(Locale::toLanguageTag)) 202 .collect(Collectors.toList()); 203 204 if (locales.size() == 1) { 205 // If there is exactly 1 locale for the language code, it's its own "identity" tiebreaker. 206 internalTiebreakerLocalesByLanguageCode.put(languageCode, new ArrayList<>(locales)); 207 } else if (locales.size() > 1) { 208 // We need to provide tiebreakers if a locale has more than 1 strings file. 209 @Nullable List<@NonNull Locale> providedTiebreakerLocales = internalTiebreakerLocalesByLanguageCode.get(languageCode); 210 211 if (providedTiebreakerLocales == null || providedTiebreakerLocales.size() == 0) { 212 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", 213 languageCode, locales.stream().map(locale -> locale.toLanguageTag()).collect(Collectors.toList()))); 214 } else { 215 // First, verify that all tiebreakers actually exist 216 Set<@NonNull Locale> supportedLocales = localizedStringsByLocale.keySet(); 217 218 for (Locale providedTiebreakerLocale : providedTiebreakerLocales) 219 if (!supportedLocales.contains(providedTiebreakerLocale)) 220 throw new IllegalArgumentException(format("Tiebreaker locale '%s' specified in 'tiebreakerLocalesByLanguageCode' does not have a localized strings file. Supported locales are: %s", 221 providedTiebreakerLocale.toLanguageTag(), supportedLocales.stream().map(supportedLocale -> supportedLocale.toLanguageTag()).sorted().collect(Collectors.toList()))); 222 223 // Next, verify that tiebreakers are exhaustively specified 224 List<@NonNull Locale> missingLocales = new ArrayList<>(locales.size()); 225 226 for (Locale locale : locales) 227 if (!providedTiebreakerLocales.contains(locale)) 228 missingLocales.add(locale); 229 230 if (missingLocales.size() > 0) 231 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", 232 providedTiebreakerLocales.stream().map(providedTiebreakerLocale -> providedTiebreakerLocale.toLanguageTag()).sorted().collect(Collectors.toList()), 233 languageCode, 234 missingLocales.stream().map(missingLocale -> missingLocale.toLanguageTag()).sorted().collect(Collectors.toList()))); 235 } 236 237 internalTiebreakerLocalesByLanguageCode.put(languageCode, new ArrayList<>(providedTiebreakerLocales)); 238 } else { 239 // Should never occur 240 throw new IllegalStateException("No locales match language code"); 241 } 242 } 243 244 Map<@NonNull String, @Nullable List<@NonNull Locale>> finalizedTiebreakerLocalesByLanguageCode = new HashMap<>(internalTiebreakerLocalesByLanguageCode.size()); 245 246 for (Entry<@NonNull String, @Nullable List<@NonNull Locale>> entry : internalTiebreakerLocalesByLanguageCode.entrySet()) { 247 @Nullable List<@NonNull Locale> locales = entry.getValue(); 248 finalizedTiebreakerLocalesByLanguageCode.put(entry.getKey(), 249 locales == null ? null : Collections.unmodifiableList(new ArrayList<>(locales))); 250 } 251 252 this.tiebreakerLocalesByLanguageCode = Collections.unmodifiableMap(finalizedTiebreakerLocalesByLanguageCode); 253 254 this.failureMode = failureMode == null ? FailureMode.USE_FALLBACK : failureMode; 255 this.stringInterpolator = new StringInterpolator(); 256 this.phoneticResolver = phoneticResolver == null ? DEFAULT_PHONETIC_RESOLVER : phoneticResolver; 257 this.expressionEvaluator = new ExpressionEvaluator(null, this.phoneticResolver); 258 259 Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> localizedStringsByKeyByLocale = 260 new HashMap<>(localizedStringsByLocale.size()); 261 262 for (Entry<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> entry : localizedStringsByLocale.entrySet()) { 263 Locale locale = entry.getKey(); 264 Map<@NonNull String, @NonNull LocalizedString> localizedStringsByKey = new LinkedHashMap<>(); 265 266 for (LocalizedString localizedString : entry.getValue()) { 267 if (localizedString == null) 268 throw new IllegalArgumentException(format("Null localized string encountered for locale '%s'", locale.toLanguageTag())); 269 270 String key = localizedString.getKey(); 271 LocalizedString existing = localizedStringsByKey.putIfAbsent(key, localizedString); 272 273 if (existing != null) 274 throw new IllegalArgumentException(format("Duplicate localized string key '%s' encountered for locale '%s'", key, locale.toLanguageTag())); 275 } 276 277 localizedStringsByKeyByLocale.put(locale, Collections.unmodifiableMap(localizedStringsByKey)); 278 } 279 280 this.localizedStringsByKeyByLocale = Collections.unmodifiableMap(localizedStringsByKeyByLocale); 281 282 if (!localizedStringsByLocale.containsKey(getFallbackLocale())) 283 throw new IllegalArgumentException(format("Specified fallback locale is '%s' but no matching " + 284 "localized strings locale was found. Known locales: [%s]", fallbackLocale.toLanguageTag(), 285 localizedStringsByLocale.keySet().stream() 286 .map(locale -> locale.toLanguageTag()) 287 .sorted() 288 .collect(Collectors.joining(", ")))); 289 290 this.localeSupplier = localeSupplier; 291 } 292 293 @NonNull 294 @Override 295 public String get(@NonNull String key) { 296 requireNonNull(key); 297 return get(key, null, getLocaleSupplier().apply(this)); 298 } 299 300 @NonNull 301 @Override 302 public String get(@NonNull String key, 303 @Nullable Map<@NonNull String, @Nullable Object> placeholders) { 304 requireNonNull(key); 305 return get(key, placeholders, getLocaleSupplier().apply(this)); 306 } 307 308 @NonNull 309 protected String get(@NonNull String key, 310 @Nullable Map<@NonNull String, @Nullable Object> placeholders, 311 @NonNull Locale locale) { 312 requireNonNull(key); 313 requireNonNull(locale); 314 315 if (placeholders == null) 316 placeholders = Collections.emptyMap(); 317 318 Locale finalLocale = locale; 319 Map<@NonNull String, @Nullable Object> mutableContext = new HashMap<>(placeholders); 320 Map<@NonNull String, @Nullable Object> immutableContext = Collections.unmodifiableMap(new HashMap<>(placeholders)); 321 322 @Nullable Map<@NonNull String, @NonNull LocalizedString> localizedStrings = getLocalizedStringsByKeyByLocale().get(locale); 323 324 if (localizedStrings == null) { 325 finalLocale = getFallbackLocale(); 326 localizedStrings = getLocalizedStringsByKeyByLocale().get(getFallbackLocale()); 327 } 328 329 // Should never occur 330 if (localizedStrings == null) 331 throw new IllegalStateException(format("Unable to find strings file for both '%s' and fallback locale '%s'", 332 locale.toLanguageTag(), getFallbackLocale().toLanguageTag())); 333 334 LocalizedString localizedString = localizedStrings.get(key); 335 String translation = null; 336 337 if (localizedString != null) 338 translation = getInternal(key, localizedString, mutableContext, immutableContext, finalLocale).orElse(null); 339 340 if (translation == null) { 341 String message = format("No match for '%s' was found for locale '%s'.", key, locale.toLanguageTag()); 342 logger.finer(message); 343 344 if (getFailureMode() == FailureMode.FAIL_FAST) 345 throw new MissingTranslationException(message, key, placeholders, locale); 346 347 // Not fail-fast? Merge against the key itself in hopes that the key is a meaningful natural-language value 348 translation = getStringInterpolator().interpolate(key, mutableContext); 349 } 350 351 return translation; 352 } 353 354 /** 355 * Recursive method which attempts to translate a localized string. 356 * 357 * @param key the toplevel translation key (always the same regardless of recursion depth), not null 358 * @param localizedString the localized string on which to operate, not null 359 * @param mutableContext the mutable context for the translation, not null 360 * @param immutableContext the original user-supplied translation context, not null 361 * @param locale the locale to use for evaluation, not null 362 * @return the translation, if possible (may not be possible if no translation value specified and no alternative expressions match), not null 363 */ 364 @NonNull 365 protected Optional<String> getInternal(@NonNull String key, @NonNull LocalizedString localizedString, 366 @NonNull Map<@NonNull String, @Nullable Object> mutableContext, 367 @NonNull Map<@NonNull String, @Nullable Object> immutableContext, 368 @NonNull Locale locale) { 369 requireNonNull(key); 370 requireNonNull(localizedString); 371 requireNonNull(mutableContext); 372 requireNonNull(immutableContext); 373 requireNonNull(locale); 374 375 // First, see if any alternatives match by evaluating them 376 for (LocalizedString alternative : localizedString.getAlternatives()) { 377 if (alternativeMatches(alternative, mutableContext, locale)) { 378 logger.finer(format("An alternative match for '%s' was found for key '%s' and context %s", alternative.getKey(), key, mutableContext)); 379 380 // If we have a matching alternative, recurse into it 381 return getInternal(key, alternative, mutableContext, immutableContext, locale); 382 } 383 } 384 385 if (!localizedString.getTranslation().isPresent()) 386 return Optional.empty(); 387 388 String translation = localizedString.getTranslation().get(); 389 390 for (Entry<@NonNull String, @NonNull LanguageFormTranslation> entry : localizedString.getLanguageFormTranslationsByPlaceholder().entrySet()) { 391 String placeholderName = entry.getKey(); 392 LanguageFormTranslation languageFormTranslation = entry.getValue(); 393 Object value = null; 394 Object rangeStart = null; 395 Object rangeEnd = null; 396 Map<@NonNull Cardinality, @NonNull String> translationsByCardinality = new HashMap<>(); 397 Map<@NonNull Ordinality, @NonNull String> translationsByOrdinality = new HashMap<>(); 398 Map<@NonNull Gender, @NonNull String> translationsByGender = new HashMap<>(); 399 Map<@NonNull Formality, @NonNull String> translationsByFormality = new HashMap<>(); 400 Map<@NonNull Clusivity, @NonNull String> translationsByClusivity = new HashMap<>(); 401 Map<@NonNull Animacy, @NonNull String> translationsByAnimacy = new HashMap<>(); 402 Map<@NonNull Phonetic, @NonNull String> translationsByPhonetic = new HashMap<>(); 403 404 if (languageFormTranslation.getRange().isPresent()) { 405 LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get(); 406 rangeStart = unwrapOptional(immutableContext.get(languageFormTranslationRange.getStart())); 407 rangeEnd = unwrapOptional(immutableContext.get(languageFormTranslationRange.getEnd())); 408 } else { 409 value = unwrapOptional(immutableContext.get(languageFormTranslation.getValue().get())); 410 } 411 412 for (Entry<@NonNull LanguageForm, @NonNull String> translationEntry : languageFormTranslation.getTranslationsByLanguageForm().entrySet()) { 413 LanguageForm languageForm = translationEntry.getKey(); 414 String translatedLanguageForm = translationEntry.getValue(); 415 416 if (languageForm instanceof Cardinality) 417 translationsByCardinality.put((Cardinality) languageForm, translatedLanguageForm); 418 else if (languageForm instanceof Ordinality) 419 translationsByOrdinality.put((Ordinality) languageForm, translatedLanguageForm); 420 else if (languageForm instanceof Gender) 421 translationsByGender.put((Gender) languageForm, translatedLanguageForm); 422 else if (languageForm instanceof Formality) 423 translationsByFormality.put((Formality) languageForm, translatedLanguageForm); 424 else if (languageForm instanceof Clusivity) 425 translationsByClusivity.put((Clusivity) languageForm, translatedLanguageForm); 426 else if (languageForm instanceof Animacy) 427 translationsByAnimacy.put((Animacy) languageForm, translatedLanguageForm); 428 else if (languageForm instanceof Phonetic) 429 translationsByPhonetic.put((Phonetic) languageForm, translatedLanguageForm); 430 else 431 throw new IllegalArgumentException(format("Encountered unrecognized language form %s", languageForm)); 432 } 433 434 int distinctLanguageForms = (translationsByCardinality.size() > 0 ? 1 : 0) + 435 (translationsByOrdinality.size() > 0 ? 1 : 0) + 436 (translationsByGender.size() > 0 ? 1 : 0) + 437 (translationsByFormality.size() > 0 ? 1 : 0) + 438 (translationsByClusivity.size() > 0 ? 1 : 0) + 439 (translationsByAnimacy.size() > 0 ? 1 : 0) + 440 (translationsByPhonetic.size() > 0 ? 1 : 0); 441 442 if (distinctLanguageForms > 1) 443 throw new IllegalArgumentException(format("You cannot mix-and-match language forms. Offending localized string was %s", localizedString)); 444 445 if (distinctLanguageForms == 0) 446 continue; 447 448 if (languageFormTranslation.getRange().isPresent() && translationsByCardinality.isEmpty()) 449 throw new IllegalArgumentException(format("Range-based translations are only supported for %s. Offending localized string was %s", 450 Cardinality.class.getSimpleName(), localizedString)); 451 452 // Handle plural cardinalities 453 if (translationsByCardinality.size() > 0) { 454 // Special case: calculate range from min and max if this is a range-driven cardinality 455 if (languageFormTranslation.getRange().isPresent()) { 456 LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get(); 457 458 if (rangeStart == null) 459 throw new IllegalArgumentException(format("Missing range start placeholder '%s' for key '%s'", 460 languageFormTranslationRange.getStart(), key)); 461 462 if (rangeEnd == null) 463 throw new IllegalArgumentException(format("Missing range end placeholder '%s' for key '%s'", 464 languageFormTranslationRange.getEnd(), key)); 465 466 if (!(rangeStart instanceof Number)) { 467 throw new IllegalArgumentException(format("Range start placeholder '%s' for key '%s' must be a %s but was %s", 468 languageFormTranslationRange.getStart(), key, Number.class.getSimpleName(), rangeStart.getClass().getSimpleName())); 469 } 470 471 if (!(rangeEnd instanceof Number)) { 472 throw new IllegalArgumentException(format("Range end placeholder '%s' for key '%s' must be a %s but was %s", 473 languageFormTranslationRange.getEnd(), key, Number.class.getSimpleName(), rangeEnd.getClass().getSimpleName())); 474 } 475 476 Cardinality startCardinality = Cardinality.forNumber((Number) rangeStart, locale); 477 Cardinality endCardinality = Cardinality.forNumber((Number) rangeEnd, locale); 478 Cardinality rangeCardinality = Cardinality.forRange(startCardinality, endCardinality, locale); 479 480 String cardinalityTranslation = translationsByCardinality.get(rangeCardinality); 481 482 if (cardinalityTranslation == null) 483 throw new IllegalStateException(format("Missing %s translation for range cardinality %s (start was %s, end was %s). Localized string was %s", 484 Cardinality.class.getSimpleName(), rangeCardinality.name(), startCardinality.name(), endCardinality.name(), localizedString)); 485 486 mutableContext.put(placeholderName, cardinalityTranslation); 487 } else { 488 // Normal "non-range" cardinality 489 if (value == null) 490 throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'", 491 languageFormTranslation.getValue().get(), key)); 492 493 if (!(value instanceof Number)) { 494 throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s", 495 languageFormTranslation.getValue().get(), key, Number.class.getSimpleName(), value.getClass().getSimpleName())); 496 } 497 498 Cardinality cardinality = Cardinality.forNumber((Number) value, locale); 499 String cardinalityTranslation = translationsByCardinality.get(cardinality); 500 501 if (cardinalityTranslation == null) 502 throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s", 503 Cardinality.class.getSimpleName(), cardinality.name(), localizedString)); 504 505 mutableContext.put(placeholderName, cardinalityTranslation); 506 } 507 } 508 509 // Handle plural ordinalities 510 if (translationsByOrdinality.size() > 0) { 511 if (value == null) 512 throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'", 513 languageFormTranslation.getValue().get(), key)); 514 515 if (!(value instanceof Number)) { 516 throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s", 517 languageFormTranslation.getValue().get(), key, Number.class.getSimpleName(), value.getClass().getSimpleName())); 518 } 519 520 Ordinality ordinality = Ordinality.forNumber((Number) value, locale); 521 String ordinalityTranslation = translationsByOrdinality.get(ordinality); 522 523 if (ordinalityTranslation == null) 524 throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s", 525 Ordinality.class.getSimpleName(), ordinality.name(), localizedString)); 526 527 mutableContext.put(placeholderName, ordinalityTranslation); 528 } 529 530 // Handle genders 531 if (translationsByGender.size() > 0) { 532 if (value == null) 533 throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'", 534 languageFormTranslation.getValue().get(), key)); 535 536 if (!(value instanceof Gender)) 537 throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s", 538 languageFormTranslation.getValue().get(), key, Gender.class.getSimpleName(), value.getClass().getSimpleName())); 539 540 Gender gender = (Gender) value; 541 String genderTranslation = translationsByGender.get(gender); 542 543 if (genderTranslation == null) 544 throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s", 545 Gender.class.getSimpleName(), gender.name(), localizedString)); 546 547 mutableContext.put(placeholderName, genderTranslation); 548 } 549 550 // Handle formality 551 if (translationsByFormality.size() > 0) { 552 if (value == null) 553 throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'", 554 languageFormTranslation.getValue().get(), key)); 555 556 if (!(value instanceof Formality)) 557 throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s", 558 languageFormTranslation.getValue().get(), key, Formality.class.getSimpleName(), value.getClass().getSimpleName())); 559 560 Formality formality = (Formality) value; 561 String formalityTranslation = translationsByFormality.get(formality); 562 563 if (formalityTranslation == null) 564 throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s", 565 Formality.class.getSimpleName(), formality.name(), localizedString)); 566 567 mutableContext.put(placeholderName, formalityTranslation); 568 } 569 570 // Handle clusivity 571 if (translationsByClusivity.size() > 0) { 572 if (value == null) 573 throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'", 574 languageFormTranslation.getValue().get(), key)); 575 576 if (!(value instanceof Clusivity)) 577 throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s", 578 languageFormTranslation.getValue().get(), key, Clusivity.class.getSimpleName(), value.getClass().getSimpleName())); 579 580 Clusivity clusivity = (Clusivity) value; 581 String clusivityTranslation = translationsByClusivity.get(clusivity); 582 583 if (clusivityTranslation == null) 584 throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s", 585 Clusivity.class.getSimpleName(), clusivity.name(), localizedString)); 586 587 mutableContext.put(placeholderName, clusivityTranslation); 588 } 589 590 // Handle animacy 591 if (translationsByAnimacy.size() > 0) { 592 if (value == null) 593 throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'", 594 languageFormTranslation.getValue().get(), key)); 595 596 if (!(value instanceof Animacy)) 597 throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s but was %s", 598 languageFormTranslation.getValue().get(), key, Animacy.class.getSimpleName(), value.getClass().getSimpleName())); 599 600 Animacy animacy = (Animacy) value; 601 String animacyTranslation = translationsByAnimacy.get(animacy); 602 603 if (animacyTranslation == null) 604 throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s", 605 Animacy.class.getSimpleName(), animacy.name(), localizedString)); 606 607 mutableContext.put(placeholderName, animacyTranslation); 608 } 609 610 // Handle phonetics 611 if (translationsByPhonetic.size() > 0) { 612 if (languageFormTranslation.getRange().isPresent()) 613 throw new IllegalArgumentException(format("Phonetic translations cannot use ranges. Offending localized string was %s", localizedString)); 614 615 if (value == null) 616 throw new IllegalArgumentException(format("Missing value for placeholder '%s' in key '%s'", 617 languageFormTranslation.getValue().get(), key)); 618 619 Phonetic phonetic; 620 621 if (value instanceof Phonetic) { 622 phonetic = (Phonetic) value; 623 } else if (value instanceof CharSequence) { 624 PhoneticResolver resolver = getPhoneticResolver(); 625 phonetic = resolver.resolve(value.toString(), locale); 626 627 if (phonetic == null) 628 throw new IllegalArgumentException(format("%s returned null for placeholder '%s' in key '%s'", 629 PhoneticResolver.class.getSimpleName(), languageFormTranslation.getValue().get(), key)); 630 } else { 631 throw new IllegalArgumentException(format("Placeholder '%s' in key '%s' must be a %s or %s but was %s", 632 languageFormTranslation.getValue().get(), key, Phonetic.class.getSimpleName(), 633 CharSequence.class.getSimpleName(), value.getClass().getSimpleName())); 634 } 635 636 String phoneticTranslation = translationsByPhonetic.get(phonetic); 637 638 if (phoneticTranslation == null) 639 throw new IllegalStateException(format("Missing %s translation for %s. Localized string was %s", 640 Phonetic.class.getSimpleName(), phonetic.name(), localizedString)); 641 642 mutableContext.put(placeholderName, phoneticTranslation); 643 } 644 } 645 646 translation = getStringInterpolator().interpolate(translation, mutableContext); 647 648 return Optional.of(translation); 649 } 650 651 private boolean alternativeMatches(@NonNull LocalizedString alternative, 652 @NonNull Map<@NonNull String, @Nullable Object> context, 653 @NonNull Locale locale) { 654 requireNonNull(alternative); 655 requireNonNull(context); 656 requireNonNull(locale); 657 658 List<@NonNull Token> expressionTokens = alternative.getExpressionTokens(); 659 660 if (expressionTokens != null) 661 return getExpressionEvaluator().evaluateReversePolishNotationTokens(expressionTokens, context, locale); 662 663 return getExpressionEvaluator().evaluate(alternative.getKey(), context, locale); 664 } 665 666 @NonNull 667 @Override 668 public Locale bestMatchFor(@NonNull Locale locale) { 669 requireNonNull(locale); 670 return bestMatchFor(List.of(new LanguageRange(locale.toLanguageTag()))); 671 } 672 673 @NonNull 674 @Override 675 public Locale bestMatchFor(@NonNull List<@NonNull LanguageRange> languageRanges) { 676 requireNonNull(languageRanges); 677 678 if (languageRanges.isEmpty()) 679 return getFallbackLocale(); 680 681 List<@NonNull LanguageRange> sortedLanguageRanges = new ArrayList<>(languageRanges); 682 sortedLanguageRanges.sort(Comparator.comparingDouble(LanguageRange::getWeight).reversed()); 683 List<@NonNull Locale> availableLocales = new ArrayList<>(getLocalizedStringsByLocale().keySet()); 684 availableLocales.sort(Comparator.comparing(Locale::toLanguageTag)); 685 686 // Walk through each LanguageRange in preference order 687 for (LanguageRange languageRange : sortedLanguageRanges) { 688 String range = languageRange.getRange(); // e.g. "pt" or "pt-PT" 689 double weight = languageRange.getWeight(); 690 691 if (weight <= 0) 692 continue; 693 694 if ("*".equals(range)) 695 return getFallbackLocale(); 696 697 // Exact tag match? 698 for (Locale locale : availableLocales) 699 if (locale.toLanguageTag().equalsIgnoreCase(range)) 700 return locale; 701 702 // Primary-tag candidates (e.g. "pt" or "pt-XX") 703 String primary = range.split("-")[0]; // e.g. "pt" 704 705 if ("*".equals(primary)) { 706 List<Locale> filteredCandidates = Locale.filter(Collections.singletonList(languageRange), availableLocales, 707 Locale.FilteringMode.EXTENDED_FILTERING); 708 709 if (!filteredCandidates.isEmpty()) 710 return filteredCandidates.get(0); 711 712 continue; 713 } 714 715 List<@NonNull Locale> candidates = availableLocales.stream() 716 .filter(locale -> locale.getLanguage().equalsIgnoreCase(primary)) 717 .collect(Collectors.toList()); 718 719 if (candidates.isEmpty()) 720 continue; // try the next LanguageRange 721 722 List<Locale> filteredCandidates = Locale.filter(Collections.singletonList(languageRange), candidates, 723 Locale.FilteringMode.EXTENDED_FILTERING); 724 725 if (!filteredCandidates.isEmpty()) { 726 boolean hasSpecificMatch = filteredCandidates.stream() 727 .anyMatch(locale -> !locale.toLanguageTag().equalsIgnoreCase(locale.getLanguage())); 728 729 if (hasSpecificMatch) 730 candidates = filteredCandidates; 731 } 732 733 if (candidates.size() == 1) 734 return candidates.get(0); 735 736 // Tieābreaker list for this primary tag? 737 @Nullable List<@NonNull Locale> tiebreakers = getTiebreakerLocalesByLanguageCode().get(primary); 738 739 if (tiebreakers != null) 740 for (Locale tiebreaker : tiebreakers) 741 if (candidates.contains(tiebreaker)) 742 return tiebreaker; 743 744 return candidates.get(0); 745 } 746 747 // 4) Nothing matched at all 748 return getFallbackLocale(); 749 } 750 751 @Nullable 752 private static Object unwrapOptional(@Nullable Object value) { 753 if (value instanceof Optional) 754 return ((Optional<?>) value).orElse(null); 755 756 return value; 757 } 758 759 /** 760 * Gets the set of localized strings for each locale. 761 * 762 * @return the set of localized strings for each locale, not null 763 */ 764 @NonNull 765 public Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> getLocalizedStringsByLocale() { 766 return localizedStringsByLocale; 767 } 768 769 /** 770 * Gets the locale supplier. 771 * 772 * @return the locale supplier, not null 773 */ 774 @NonNull 775 public Function<LocaleMatcher, Locale> getLocaleSupplier() { 776 return this.localeSupplier; 777 } 778 779 /** 780 * Gets the mapping of a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales. 781 * 782 * @return the per-language-code "tiebreaker" locales, not null 783 */ 784 @NonNull 785 public Map<@NonNull String, @Nullable List<@NonNull Locale>> getTiebreakerLocalesByLanguageCode() { 786 return this.tiebreakerLocalesByLanguageCode; 787 } 788 789 /** 790 * Gets the strategy for handling string lookup failures. 791 * 792 * @return the strategy for handling string lookup failures, not null 793 */ 794 @NonNull 795 public FailureMode getFailureMode() { 796 return failureMode; 797 } 798 799 /** 800 * Gets the fallback locale. 801 * 802 * @return the fallback locale, not null 803 */ 804 @NonNull 805 public Locale getFallbackLocale() { 806 return fallbackLocale; 807 } 808 809 /** 810 * Gets the string interpolator used to merge placeholders into translations. 811 * 812 * @return the string interpolator, not null 813 */ 814 @NonNull 815 protected StringInterpolator getStringInterpolator() { 816 return stringInterpolator; 817 } 818 819 /** 820 * Gets the expression evaluator used to determine if alternative expressions match the evaluation context. 821 * 822 * @return the expression evaluator, not null 823 */ 824 @NonNull 825 protected ExpressionEvaluator getExpressionEvaluator() { 826 return expressionEvaluator; 827 } 828 829 /** 830 * Gets the phonetic resolver used to determine phonetic categories. 831 * 832 * @return the phonetic resolver, not null 833 */ 834 @NonNull 835 protected PhoneticResolver getPhoneticResolver() { 836 return phoneticResolver; 837 } 838 839 /** 840 * Gets our "master" cache of localized strings by key by locale. 841 * 842 * @return the cache of localized strings by key by locale, not null 843 */ 844 @NonNull 845 protected Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull LocalizedString>> getLocalizedStringsByKeyByLocale() { 846 return localizedStringsByKeyByLocale; 847 } 848 849 /** 850 * Strategies for handling localized string lookup failures. 851 */ 852 public enum FailureMode { 853 /** 854 * The system will attempt a series of fallbacks in order to not throw an exception at runtime. 855 * <p> 856 * This mode is useful for production, where we often want program execution to continue in the face of 857 * localization errors. 858 */ 859 USE_FALLBACK, 860 /** 861 * The system will throw an exception if a localization is missing for the specified locale. 862 * <p> 863 * This mode is useful for testing, since problems are uncovered right away when execution halts. 864 */ 865 FAIL_FAST 866 } 867 868 /** 869 * Builder used to construct instances of {@link DefaultStrings}. 870 * <p> 871 * This class is intended for use by a single thread. 872 * 873 * @author <a href="https://revetkn.com">Mark Allen</a> 874 */ 875 @NotThreadSafe 876 public static class Builder { 877 @NonNull 878 private final Locale fallbackLocale; 879 @Nullable 880 private Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier; 881 @Nullable 882 private Function<LocaleMatcher, Locale> localeSupplier; 883 @Nullable 884 private Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode; 885 @Nullable 886 private FailureMode failureMode; 887 @Nullable 888 private PhoneticResolver phoneticResolver; 889 890 /** 891 * Constructs a strings builder with a default locale. 892 * 893 * @param fallbackLocale fallback locale, not null 894 */ 895 protected Builder(@NonNull Locale fallbackLocale) { 896 requireNonNull(fallbackLocale); 897 this.fallbackLocale = fallbackLocale; 898 } 899 900 /** 901 * Applies a localized string supplier to this builder. 902 * 903 * @param localizedStringSupplier localized string supplier, may be null 904 * @return this builder instance, useful for chaining. not null 905 */ 906 @NonNull 907 public Builder localizedStringSupplier(@Nullable Supplier<Map<@NonNull Locale, ? extends Iterable<@NonNull LocalizedString>>> localizedStringSupplier) { 908 this.localizedStringSupplier = localizedStringSupplier; 909 return this; 910 } 911 912 /** 913 * Applies a locale supplier to this builder. 914 * 915 * @param localeSupplier locale supplier, may be null 916 * @return this builder instance, useful for chaining. not null 917 */ 918 @NonNull 919 public Builder localeSupplier(@Nullable Function<LocaleMatcher, Locale> localeSupplier) { 920 this.localeSupplier = localeSupplier; 921 return this; 922 } 923 924 /** 925 * Applies a mapping of an ISO 639 language code to its ordered "tiebreaker" fallback locales to this builder. 926 * 927 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallback locales, may be null 928 * @return this builder instance, useful for chaining. not null 929 */ 930 @NonNull 931 public Builder tiebreakerLocalesByLanguageCode(@Nullable Map<@NonNull String, @Nullable List<@NonNull Locale>> tiebreakerLocalesByLanguageCode) { 932 this.tiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode; 933 return this; 934 } 935 936 /** 937 * Applies a failure mode to this builder. 938 * 939 * @param failureMode strategy for dealing with lookup failures, may be null 940 * @return this builder instance, useful for chaining. not null 941 */ 942 @NonNull 943 public Builder failureMode(@Nullable FailureMode failureMode) { 944 this.failureMode = failureMode; 945 return this; 946 } 947 948 /** 949 * Applies a phonetic resolver to this builder. 950 * 951 * @param phoneticResolver phonetic resolver, may be null (defaults to fail-fast resolver) 952 * @return this builder instance, useful for chaining. not null 953 */ 954 @NonNull 955 public Builder phoneticResolver(@Nullable PhoneticResolver phoneticResolver) { 956 this.phoneticResolver = phoneticResolver; 957 return this; 958 } 959 960 /** 961 * Constructs an instance of {@link DefaultStrings}. 962 * 963 * @return an instance of {@link DefaultStrings}, not null 964 */ 965 @NonNull 966 public DefaultStrings build() { 967 return new DefaultStrings(fallbackLocale, localizedStringSupplier, localeSupplier, tiebreakerLocalesByLanguageCode, 968 failureMode, phoneticResolver); 969 } 970 } 971}