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