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 javax.annotation.Nonnull; 020import javax.annotation.Nullable; 021import javax.annotation.concurrent.Immutable; 022import javax.annotation.concurrent.NotThreadSafe; 023import java.util.ArrayList; 024import java.util.Collections; 025import java.util.LinkedHashMap; 026import java.util.List; 027import java.util.Map; 028import java.util.Objects; 029import java.util.Optional; 030import java.util.stream.Collectors; 031 032import static java.lang.String.format; 033import static java.util.Objects.requireNonNull; 034 035/** 036 * Represents a single localized string - its key, translated value, and any associated translation rules. 037 * <p> 038 * Normally instances are sourced from a file which contains all localized strings for a given locale. 039 * 040 * @author <a href="https://revetkn.com">Mark Allen</a> 041 */ 042@Immutable 043public class LocalizedString { 044 @Nonnull 045 private final String key; 046 @Nullable 047 private final String translation; 048 @Nullable 049 private final String commentary; 050 @Nonnull 051 private final Map<String, LanguageFormTranslation> languageFormTranslationsByPlaceholder; 052 @Nonnull 053 private final List<LocalizedString> alternatives; 054 055 /** 056 * Constructs a localized string with a key, default translation, and additional translation rules. 057 * 058 * @param key this string's translation key, not null 059 * @param translation this string's default translation, may be null 060 * @param commentary this string's commentary (usage/translation notes), may be null 061 * @param languageFormTranslationsByPlaceholder per-language-form translations that correspond to a placeholder value, may be null 062 * @param alternatives alternative expression-driven translations for this string, may be null 063 */ 064 protected LocalizedString(@Nonnull String key, @Nullable String translation, @Nullable String commentary, 065 @Nullable Map<String, LanguageFormTranslation> languageFormTranslationsByPlaceholder, 066 @Nullable List<LocalizedString> alternatives) { 067 requireNonNull(key); 068 069 this.key = key; 070 this.translation = translation; 071 this.commentary = commentary; 072 073 if (languageFormTranslationsByPlaceholder == null) { 074 this.languageFormTranslationsByPlaceholder = Collections.emptyMap(); 075 } else { 076 // Defensive copy to unmodifiable map 077 this.languageFormTranslationsByPlaceholder = Collections.unmodifiableMap(languageFormTranslationsByPlaceholder); 078 } 079 080 // Defensive copy to unmodifiable list 081 this.alternatives = alternatives == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList<>(alternatives)); 082 083 if (translation == null && alternatives.size() == 0) 084 throw new IllegalArgumentException(format("You must provide either a translation or at least one alternative expression. " + 085 "Offending key was '%s'", key)); 086 } 087 088 /** 089 * Generates a {@code String} representation of this object. 090 * 091 * @return a string representation of this object, not null 092 */ 093 @Override 094 @Nonnull 095 public String toString() { 096 List<String> components = new ArrayList<>(5); 097 098 components.add(format("key=%s", getKey())); 099 100 if (getTranslation().isPresent()) 101 components.add(format("translation=%s", getTranslation().get())); 102 103 if (getCommentary().isPresent()) 104 components.add(format("commentary=%s", getCommentary().get())); 105 106 if (getLanguageFormTranslationsByPlaceholder().size() > 0) 107 components.add(format("languageFormTranslationsByPlaceholder=%s", getLanguageFormTranslationsByPlaceholder())); 108 109 if (getAlternatives().size() > 0) 110 components.add(format("alternatives=%s", getAlternatives())); 111 112 return format("%s{%s}", getClass().getSimpleName(), components.stream().collect(Collectors.joining(", "))); 113 } 114 115 /** 116 * Checks if this object is equal to another one. 117 * 118 * @param other the object to check, null returns false 119 * @return true if this is equal to the other object, false otherwise 120 */ 121 @Override 122 public boolean equals(@Nullable Object other) { 123 if (this == other) 124 return true; 125 126 if (other == null || !getClass().equals(other.getClass())) 127 return false; 128 129 LocalizedString localizedString = (LocalizedString) other; 130 131 return Objects.equals(getKey(), localizedString.getKey()) 132 && Objects.equals(getTranslation(), localizedString.getTranslation()) 133 && Objects.equals(getCommentary(), localizedString.getCommentary()) 134 && Objects.equals(getLanguageFormTranslationsByPlaceholder(), localizedString.getLanguageFormTranslationsByPlaceholder()) 135 && Objects.equals(getAlternatives(), localizedString.getAlternatives()); 136 } 137 138 /** 139 * A hash code for this object. 140 * 141 * @return a suitable hash code 142 */ 143 @Override 144 public int hashCode() { 145 return Objects.hash(getKey(), getTranslation(), getCommentary(), getLanguageFormTranslationsByPlaceholder(), getAlternatives()); 146 } 147 148 /** 149 * Gets this string's translation key. 150 * 151 * @return this string's translation key, not null 152 */ 153 @Nonnull 154 public String getKey() { 155 return key; 156 } 157 158 /** 159 * Gets this string's default translation, if available. 160 * 161 * @return this string's default translation, not null 162 */ 163 @Nonnull 164 public Optional<String> getTranslation() { 165 return Optional.ofNullable(translation); 166 } 167 168 /** 169 * Gets this string's commentary (usage/translation notes). 170 * 171 * @return this string's commentary, not null 172 */ 173 @Nonnull 174 public Optional<String> getCommentary() { 175 return Optional.ofNullable(commentary); 176 } 177 178 /** 179 * Gets per-language-form translations that correspond to a placeholder value. 180 * <p> 181 * For example, language form {@code MASCULINE} might be translated as {@code He} for placeholder {@code subject}. 182 * 183 * @return per-language-form translations that correspond to a placeholder value, not null 184 */ 185 @Nonnull 186 public Map<String, LanguageFormTranslation> getLanguageFormTranslationsByPlaceholder() { 187 return languageFormTranslationsByPlaceholder; 188 } 189 190 /** 191 * Gets alternative expression-driven translations for this string. 192 * <p> 193 * In this context, the {@code key} for each alternative is a localization expression, not a translation key. 194 * <p> 195 * For example, if {@code bookCount == 0} you might want to say {@code I haven't read any books} instead of {@code I read 0 books}. 196 * 197 * @return alternative expression-driven translations for this string, not null 198 */ 199 @Nonnull 200 public List<LocalizedString> getAlternatives() { 201 return alternatives; 202 } 203 204 205 /** 206 * Builder used to construct instances of {@link LocalizedString}. 207 * <p> 208 * This class is intended for use by a single thread. 209 * 210 * @author <a href="https://revetkn.com">Mark Allen</a> 211 */ 212 @NotThreadSafe 213 public static class Builder { 214 @Nonnull 215 private final String key; 216 @Nullable 217 private String translation; 218 @Nullable 219 private String commentary; 220 @Nullable 221 private Map<String, LanguageFormTranslation> languageFormTranslationsByPlaceholder; 222 @Nullable 223 private List<LocalizedString> alternatives; 224 225 /** 226 * Constructs a localized string builder with the given key. 227 * 228 * @param key this string's translation key, not null 229 */ 230 public Builder(@Nonnull String key) { 231 requireNonNull(key); 232 this.key = key; 233 } 234 235 /** 236 * Applies a default translation to this builder. 237 * 238 * @param translation a default translation, may be null 239 * @return this builder instance, useful for chaining. not null 240 */ 241 @Nonnull 242 public Builder translation(@Nullable String translation) { 243 this.translation = translation; 244 return this; 245 } 246 247 /** 248 * Applies commentary (usage/translation notes) to this builder. 249 * 250 * @param commentary commentary (usage/translation notes), may be null 251 * @return this builder instance, useful for chaining. not null 252 */ 253 @Nonnull 254 public Builder commentary(@Nullable String commentary) { 255 this.commentary = commentary; 256 return this; 257 } 258 259 /** 260 * Applies per-language-form translations to this builder. 261 * 262 * @param languageFormTranslationsByPlaceholder per-language-form translations, may be null 263 * @return this builder instance, useful for chaining. not null 264 */ 265 @Nonnull 266 public Builder languageFormTranslationsByPlaceholder( 267 @Nullable Map<String, LanguageFormTranslation> languageFormTranslationsByPlaceholder) { 268 this.languageFormTranslationsByPlaceholder = languageFormTranslationsByPlaceholder; 269 return this; 270 } 271 272 /** 273 * Applies alternative expression-driven translations to this builder. 274 * 275 * @param alternatives alternative expression-driven translations, may be null 276 * @return this builder instance, useful for chaining. not null 277 */ 278 @Nonnull 279 public Builder alternatives(@Nullable List<LocalizedString> alternatives) { 280 this.alternatives = alternatives; 281 return this; 282 } 283 284 /** 285 * Constructs an instance of {@link LocalizedString}. 286 * 287 * @return an instance of {@link LocalizedString}, not null 288 */ 289 @Nonnull 290 public LocalizedString build() { 291 return new LocalizedString(key, translation, commentary, languageFormTranslationsByPlaceholder, alternatives); 292 } 293 } 294 295 /** 296 * Container for per-language-form (gender, cardinal, ordinal) translation information. 297 * <p> 298 * Translations can be keyed either on a single value or a range of values (start and end) in the case of cardinality ranges. 299 * <p> 300 * It is required to have either a {@code value} or {@code range}, but not both. 301 * 302 * @author <a href="https://revetkn.com">Mark Allen</a> 303 */ 304 @Immutable 305 public static class LanguageFormTranslation { 306 @Nullable 307 private final String value; 308 @Nullable 309 private final LanguageFormTranslationRange range; 310 @Nonnull 311 private final Map<LanguageForm, String> translationsByLanguageForm; 312 313 /** 314 * Constructs a per-language-form translation set with the given placeholder value and mapping of translations by language form. 315 * 316 * @param value the placeholder value to compare against for translation, not null 317 * @param translationsByLanguageForm the possible translations keyed by language form, not null 318 */ 319 public LanguageFormTranslation(@Nonnull String value, @Nonnull Map<LanguageForm, String> translationsByLanguageForm) { 320 requireNonNull(value); 321 requireNonNull(translationsByLanguageForm); 322 323 this.value = value; 324 this.range = null; 325 this.translationsByLanguageForm = Collections.unmodifiableMap(new LinkedHashMap<>(translationsByLanguageForm)); 326 } 327 328 /** 329 * Constructs a per-language-form translation set with the given placeholder range and mapping of translations by language form. 330 * 331 * @param range the placeholder range to compare against for translation, not null 332 * @param translationsByLanguageForm the possible translations keyed by language form, not null 333 */ 334 public LanguageFormTranslation(@Nonnull LanguageFormTranslationRange range, @Nonnull Map<LanguageForm, String> translationsByLanguageForm) { 335 requireNonNull(range); 336 requireNonNull(translationsByLanguageForm); 337 338 this.value = null; 339 this.range = range; 340 this.translationsByLanguageForm = Collections.unmodifiableMap(new LinkedHashMap<>(translationsByLanguageForm)); 341 } 342 343 /** 344 * Generates a {@code String} representation of this object. 345 * 346 * @return a string representation of this object, not null 347 */ 348 @Override 349 @Nonnull 350 public String toString() { 351 if (getRange().isPresent()) 352 return format("%s{range=%s, translationsByLanguageForm=%s", getClass().getSimpleName(), getRange().get(), getTranslationsByLanguageForm()); 353 354 return format("%s{value=%s, translationsByLanguageForm=%s", getClass().getSimpleName(), getValue().get(), getTranslationsByLanguageForm()); 355 } 356 357 /** 358 * Checks if this object is equal to another one. 359 * 360 * @param other the object to check, null returns false 361 * @return true if this is equal to the other object, false otherwise 362 */ 363 @Override 364 public boolean equals(@Nullable Object other) { 365 if (this == other) 366 return true; 367 368 if (other == null || !getClass().equals(other.getClass())) 369 return false; 370 371 LanguageFormTranslation languageFormTranslation = (LanguageFormTranslation) other; 372 373 return Objects.equals(getValue(), languageFormTranslation.getValue()) 374 && Objects.equals(getRange(), languageFormTranslation.getRange()) 375 && Objects.equals(getTranslationsByLanguageForm(), languageFormTranslation.getTranslationsByLanguageForm()); 376 } 377 378 /** 379 * A hash code for this object. 380 * 381 * @return a suitable hash code 382 */ 383 @Override 384 public int hashCode() { 385 return Objects.hash(getValue(), getRange(), getTranslationsByLanguageForm()); 386 } 387 388 /** 389 * Gets the value for this per-language-form translation set. 390 * 391 * @return the value for this per-language-form translation set, not null 392 */ 393 @Nonnull 394 public Optional<String> getValue() { 395 return Optional.ofNullable(value); 396 } 397 398 /** 399 * Gets the range for this per-language-form translation set. 400 * 401 * @return the range for this per-language-form translation set, not null 402 */ 403 @Nonnull 404 public Optional<LanguageFormTranslationRange> getRange() { 405 return Optional.ofNullable(range); 406 } 407 408 /** 409 * Gets the translations by language form for this per-language-form translation set. 410 * 411 * @return the translations by language form for this per-language-form translation set, not null 412 */ 413 @Nonnull 414 public Map<LanguageForm, String> getTranslationsByLanguageForm() { 415 return translationsByLanguageForm; 416 } 417 } 418 419 /** 420 * Container for per-language-form cardinality translation information over a range (start, end) of values. 421 * 422 * @author <a href="https://revetkn.com">Mark Allen</a> 423 */ 424 @Immutable 425 public static class LanguageFormTranslationRange { 426 @Nonnull 427 private String start; 428 @Nonnull 429 private String end; 430 431 /** 432 * Constructs a translation range with the given start and end values. 433 * 434 * @param start the start value of the range, not null 435 * @param end the end value of the range, not null 436 */ 437 public LanguageFormTranslationRange(@Nonnull String start, @Nonnull String end) { 438 requireNonNull(start); 439 requireNonNull(end); 440 441 this.start = start; 442 this.end = end; 443 } 444 445 /** 446 * Generates a {@code String} representation of this object. 447 * 448 * @return a string representation of this object, not null 449 */ 450 @Override 451 @Nonnull 452 public String toString() { 453 return format("%s{start=%s, end=%s", getClass().getSimpleName(), getStart(), getEnd()); 454 } 455 456 /** 457 * Checks if this object is equal to another one. 458 * 459 * @param other the object to check, null returns false 460 * @return true if this is equal to the other object, false otherwise 461 */ 462 @Override 463 public boolean equals(@Nullable Object other) { 464 if (this == other) 465 return true; 466 467 if (other == null || !getClass().equals(other.getClass())) 468 return false; 469 470 LanguageFormTranslationRange languageFormTranslationRange = (LanguageFormTranslationRange) other; 471 472 return Objects.equals(getStart(), languageFormTranslationRange.getStart()) 473 && Objects.equals(getEnd(), languageFormTranslationRange.getEnd()); 474 } 475 476 /** 477 * A hash code for this object. 478 * 479 * @return a suitable hash code 480 */ 481 @Override 482 public int hashCode() { 483 return Objects.hash(getStart(), getEnd()); 484 } 485 486 /** 487 * The start value for this range. 488 * 489 * @return the start value for this range, not null 490 */ 491 @Nonnull 492 public String getStart() { 493 return start; 494 } 495 496 /** 497 * The end value for this range. 498 * 499 * @return the end value for this range, not null 500 */ 501 @Nonnull 502 public String getEnd() { 503 return end; 504 } 505 } 506}