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.LinkedHashSet; 028import java.util.List; 029import java.util.Map; 030import java.util.Objects; 031import java.util.Optional; 032import java.util.Set; 033import java.util.stream.Collectors; 034 035import static java.lang.String.format; 036import static java.util.Objects.requireNonNull; 037 038/** 039 * Represents a single localized string - its key, translated value, and any associated translation rules. 040 * <p> 041 * Normally instances are sourced from a file which contains all localized strings for a given locale. 042 * 043 * @author <a href="https://revetkn.com">Mark Allen</a> 044 */ 045@Immutable 046public class LocalizedString { 047 @NonNull 048 private final String key; 049 @Nullable 050 private final String translation; 051 @Nullable 052 private final String commentary; 053 @Nullable 054 private final List<@NonNull Token> expressionTokens; 055 @NonNull 056 private final Map<@NonNull String, @NonNull PlaceholderMetadata> placeholderMetadataByPlaceholder; 057 @NonNull 058 private final Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder; 059 @NonNull 060 private final List<@NonNull LocalizedString> alternatives; 061 062 /** 063 * Constructs a localized string with a key, default translation, and additional translation rules. 064 * 065 * @param key this string's translation key, not null 066 * @param translation this string's default translation, may be null 067 * @param commentary this string's commentary (usage/translation notes), may be null 068 * @param placeholderMetadataByPlaceholder per-placeholder translator metadata, may be null 069 * @param languageFormTranslationsByPlaceholder per-language-form translations that correspond to a placeholder value, may be null 070 * @param alternatives alternative expression-driven translations for this string, may be null 071 */ 072 protected LocalizedString(@NonNull String key, @Nullable String translation, @Nullable String commentary, 073 @Nullable Map<@NonNull String, @NonNull PlaceholderMetadata> placeholderMetadataByPlaceholder, 074 @Nullable Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder, 075 @Nullable List<@NonNull LocalizedString> alternatives, 076 @Nullable List<@NonNull Token> expressionTokens) { 077 requireNonNull(key); 078 079 this.key = key; 080 this.translation = translation; 081 this.commentary = commentary; 082 this.expressionTokens = expressionTokens == null ? null : Collections.unmodifiableList(new ArrayList<>(expressionTokens)); 083 084 if (placeholderMetadataByPlaceholder == null) { 085 this.placeholderMetadataByPlaceholder = Collections.emptyMap(); 086 } else { 087 this.placeholderMetadataByPlaceholder = Collections.unmodifiableMap(new LinkedHashMap<>(placeholderMetadataByPlaceholder)); 088 } 089 090 if (languageFormTranslationsByPlaceholder == null) { 091 this.languageFormTranslationsByPlaceholder = Collections.emptyMap(); 092 } else { 093 // Defensive copy to unmodifiable map 094 this.languageFormTranslationsByPlaceholder = Collections.unmodifiableMap(new LinkedHashMap<>(languageFormTranslationsByPlaceholder)); 095 } 096 097 // Defensive copy to unmodifiable list 098 this.alternatives = alternatives == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList<>(alternatives)); 099 100 if (translation == null && alternatives.size() == 0) 101 throw new IllegalArgumentException(format("You must provide either a translation or at least one alternative expression. " + 102 "Offending key was '%s'", key)); 103 } 104 105 /** 106 * Generates a {@code String} representation of this object. 107 * 108 * @return a string representation of this object, not null 109 */ 110 @Override 111 @NonNull 112 public String toString() { 113 List<@NonNull String> components = new ArrayList<>(6); 114 115 components.add(format("key=%s", getKey())); 116 117 if (getTranslation().isPresent()) 118 components.add(format("translation=%s", getTranslation().get())); 119 120 if (getCommentary().isPresent()) 121 components.add(format("commentary=%s", getCommentary().get())); 122 123 if (getPlaceholderMetadataByPlaceholder().size() > 0) 124 components.add(format("placeholderMetadataByPlaceholder=%s", getPlaceholderMetadataByPlaceholder())); 125 126 if (getLanguageFormTranslationsByPlaceholder().size() > 0) 127 components.add(format("languageFormTranslationsByPlaceholder=%s", getLanguageFormTranslationsByPlaceholder())); 128 129 if (getAlternatives().size() > 0) 130 components.add(format("alternatives=%s", getAlternatives())); 131 132 return format("%s{%s}", getClass().getSimpleName(), components.stream().collect(Collectors.joining(", "))); 133 } 134 135 /** 136 * Checks if this object is equal to another one. 137 * 138 * @param other the object to check, null returns false 139 * @return true if this is equal to the other object, false otherwise 140 */ 141 @Override 142 public boolean equals(@Nullable Object other) { 143 if (this == other) 144 return true; 145 146 if (other == null || !getClass().equals(other.getClass())) 147 return false; 148 149 LocalizedString localizedString = (LocalizedString) other; 150 151 return Objects.equals(getKey(), localizedString.getKey()) 152 && Objects.equals(getTranslation(), localizedString.getTranslation()) 153 && Objects.equals(getCommentary(), localizedString.getCommentary()) 154 && Objects.equals(getPlaceholderMetadataByPlaceholder(), localizedString.getPlaceholderMetadataByPlaceholder()) 155 && Objects.equals(getLanguageFormTranslationsByPlaceholder(), localizedString.getLanguageFormTranslationsByPlaceholder()) 156 && Objects.equals(getAlternatives(), localizedString.getAlternatives()); 157 } 158 159 /** 160 * A hash code for this object. 161 * 162 * @return a suitable hash code 163 */ 164 @Override 165 public int hashCode() { 166 return Objects.hash(getKey(), getTranslation(), getCommentary(), getPlaceholderMetadataByPlaceholder(), 167 getLanguageFormTranslationsByPlaceholder(), getAlternatives()); 168 } 169 170 /** 171 * Gets this string's translation key. 172 * 173 * @return this string's translation key, not null 174 */ 175 @NonNull 176 public String getKey() { 177 return key; 178 } 179 180 /** 181 * Gets this string's default translation, if available. 182 * 183 * @return this string's default translation, not null 184 */ 185 @NonNull 186 public Optional<String> getTranslation() { 187 return Optional.ofNullable(translation); 188 } 189 190 /** 191 * Gets this string's commentary (usage/translation notes). 192 * 193 * @return this string's commentary, not null 194 */ 195 @NonNull 196 public Optional<String> getCommentary() { 197 return Optional.ofNullable(commentary); 198 } 199 200 /** 201 * Gets per-placeholder translator metadata for this string. 202 * <p> 203 * This metadata can be used to document placeholder meaning, type, examples, and allowed values for translators or tooling. 204 * 205 * @return per-placeholder translator metadata for this string, not null 206 */ 207 @NonNull 208 public Map<@NonNull String, @NonNull PlaceholderMetadata> getPlaceholderMetadataByPlaceholder() { 209 return placeholderMetadataByPlaceholder; 210 } 211 212 /** 213 * Gets per-language-form translations that correspond to a placeholder value. 214 * <p> 215 * For example, language form {@code GENDER_MASCULINE} might be translated as {@code He} for placeholder {@code subject}, 216 * or a selector-based placeholder might use both {@code GENDER} and {@code CASE} to choose a single translation rule. 217 * 218 * @return per-language-form translations that correspond to a placeholder value, not null 219 */ 220 @NonNull 221 public Map<@NonNull String, @NonNull LanguageFormTranslation> getLanguageFormTranslationsByPlaceholder() { 222 return languageFormTranslationsByPlaceholder; 223 } 224 225 /** 226 * Gets alternative expression-driven translations for this string. 227 * <p> 228 * In this context, the {@code key} for each alternative is a localization expression, not a translation key. 229 * <p> 230 * 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}. 231 * 232 * @return alternative expression-driven translations for this string, not null 233 */ 234 @NonNull 235 public List<@NonNull LocalizedString> getAlternatives() { 236 return alternatives; 237 } 238 239 @Nullable 240 List<@NonNull Token> getExpressionTokens() { 241 return expressionTokens; 242 } 243 244 245 /** 246 * Builder used to construct instances of {@link LocalizedString}. 247 * <p> 248 * This class is intended for use by a single thread. 249 * 250 * @author <a href="https://revetkn.com">Mark Allen</a> 251 */ 252 @NotThreadSafe 253 public static class Builder { 254 @NonNull 255 private final String key; 256 @Nullable 257 private String translation; 258 @Nullable 259 private String commentary; 260 @Nullable 261 private Map<@NonNull String, @NonNull PlaceholderMetadata> placeholderMetadataByPlaceholder; 262 @Nullable 263 private Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder; 264 @Nullable 265 private List<@NonNull LocalizedString> alternatives; 266 @Nullable 267 private List<@NonNull Token> expressionTokens; 268 269 /** 270 * Constructs a localized string builder with the given key. 271 * 272 * @param key this string's translation key, not null 273 */ 274 public Builder(@NonNull String key) { 275 requireNonNull(key); 276 this.key = key; 277 } 278 279 /** 280 * Applies a default translation to this builder. 281 * 282 * @param translation a default translation, may be null 283 * @return this builder instance, useful for chaining. not null 284 */ 285 @NonNull 286 public Builder translation(@Nullable String translation) { 287 this.translation = translation; 288 return this; 289 } 290 291 /** 292 * Applies commentary (usage/translation notes) to this builder. 293 * 294 * @param commentary commentary (usage/translation notes), may be null 295 * @return this builder instance, useful for chaining. not null 296 */ 297 @NonNull 298 public Builder commentary(@Nullable String commentary) { 299 this.commentary = commentary; 300 return this; 301 } 302 303 /** 304 * Applies per-placeholder translator metadata to this builder. 305 * 306 * @param placeholderMetadataByPlaceholder per-placeholder translator metadata, may be null 307 * @return this builder instance, useful for chaining. not null 308 */ 309 @NonNull 310 public Builder placeholderMetadataByPlaceholder( 311 @Nullable Map<@NonNull String, @NonNull PlaceholderMetadata> placeholderMetadataByPlaceholder) { 312 this.placeholderMetadataByPlaceholder = placeholderMetadataByPlaceholder; 313 return this; 314 } 315 316 /** 317 * Applies per-language-form translations to this builder. 318 * 319 * @param languageFormTranslationsByPlaceholder per-language-form or selector-driven translations, may be null 320 * @return this builder instance, useful for chaining. not null 321 */ 322 @NonNull 323 public Builder languageFormTranslationsByPlaceholder( 324 @Nullable Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder) { 325 this.languageFormTranslationsByPlaceholder = languageFormTranslationsByPlaceholder; 326 return this; 327 } 328 329 /** 330 * Applies alternative expression-driven translations to this builder. 331 * 332 * @param alternatives alternative expression-driven translations, may be null 333 * @return this builder instance, useful for chaining. not null 334 */ 335 @NonNull 336 public Builder alternatives(@Nullable List<@NonNull LocalizedString> alternatives) { 337 this.alternatives = alternatives; 338 return this; 339 } 340 341 @NonNull 342 Builder expressionTokens(@Nullable List<@NonNull Token> expressionTokens) { 343 this.expressionTokens = expressionTokens; 344 return this; 345 } 346 347 /** 348 * Constructs an instance of {@link LocalizedString}. 349 * 350 * @return an instance of {@link LocalizedString}, not null 351 */ 352 @NonNull 353 public LocalizedString build() { 354 return new LocalizedString(key, translation, commentary, placeholderMetadataByPlaceholder, 355 languageFormTranslationsByPlaceholder, alternatives, expressionTokens); 356 } 357 } 358 359 /** 360 * Translator-facing metadata for a single placeholder. 361 * <p> 362 * This metadata is informational and is not used by runtime string evaluation. It is intended to improve translator 363 * context and to support documentation or linting tools. 364 * 365 * @author <a href="https://revetkn.com">Mark Allen</a> 366 */ 367 @Immutable 368 public static class PlaceholderMetadata { 369 @Nullable 370 private final String type; 371 @Nullable 372 private final String commentary; 373 @Nullable 374 private final String example; 375 @NonNull 376 private final Set<@NonNull String> allowedValues; 377 378 /** 379 * Constructs placeholder metadata. 380 * 381 * @param type translator-facing type label, for example {@code STRING}, {@code NUMBER}, or {@code GENDER}, may be null 382 * @param commentary commentary describing how the placeholder is used, may be null 383 * @param example example placeholder value, may be null 384 * @param allowedValues allowed values for the placeholder as an insertion-ordered set, may be null 385 */ 386 public PlaceholderMetadata(@Nullable String type, @Nullable String commentary, @Nullable String example, 387 @Nullable Set<@NonNull String> allowedValues) { 388 if (type == null && commentary == null && example == null && (allowedValues == null || allowedValues.isEmpty())) 389 throw new IllegalArgumentException(format("%s requires at least one metadata field", getClass().getSimpleName())); 390 391 this.type = type; 392 this.commentary = commentary; 393 this.example = example; 394 this.allowedValues = allowedValues == null ? Collections.emptySet() : Collections.unmodifiableSet(new LinkedHashSet<>(allowedValues)); 395 } 396 397 /** 398 * Generates a {@code String} representation of this object. 399 * 400 * @return a string representation of this object, not null 401 */ 402 @Override 403 @NonNull 404 public String toString() { 405 List<@NonNull String> components = new ArrayList<>(4); 406 407 if (getType().isPresent()) 408 components.add(format("type=%s", getType().get())); 409 410 if (getCommentary().isPresent()) 411 components.add(format("commentary=%s", getCommentary().get())); 412 413 if (getExample().isPresent()) 414 components.add(format("example=%s", getExample().get())); 415 416 if (getAllowedValues().size() > 0) 417 components.add(format("allowedValues=%s", getAllowedValues())); 418 419 return format("%s{%s}", getClass().getSimpleName(), components.stream().collect(Collectors.joining(", "))); 420 } 421 422 /** 423 * Checks if this object is equal to another one. 424 * 425 * @param other the object to check, null returns false 426 * @return true if this is equal to the other object, false otherwise 427 */ 428 @Override 429 public boolean equals(@Nullable Object other) { 430 if (this == other) 431 return true; 432 433 if (other == null || !getClass().equals(other.getClass())) 434 return false; 435 436 PlaceholderMetadata placeholderMetadata = (PlaceholderMetadata) other; 437 438 return Objects.equals(getType(), placeholderMetadata.getType()) 439 && Objects.equals(getCommentary(), placeholderMetadata.getCommentary()) 440 && Objects.equals(getExample(), placeholderMetadata.getExample()) 441 && Objects.equals(getAllowedValues(), placeholderMetadata.getAllowedValues()); 442 } 443 444 /** 445 * A hash code for this object. 446 * 447 * @return a suitable hash code 448 */ 449 @Override 450 public int hashCode() { 451 return Objects.hash(getType(), getCommentary(), getExample(), getAllowedValues()); 452 } 453 454 /** 455 * Gets the translator-facing type label for this placeholder. 456 * 457 * @return the translator-facing type label for this placeholder, not null 458 */ 459 @NonNull 460 public Optional<String> getType() { 461 return Optional.ofNullable(type); 462 } 463 464 /** 465 * Gets the commentary for this placeholder. 466 * 467 * @return the commentary for this placeholder, not null 468 */ 469 @NonNull 470 public Optional<String> getCommentary() { 471 return Optional.ofNullable(commentary); 472 } 473 474 /** 475 * Gets an example value for this placeholder. 476 * 477 * @return an example value for this placeholder, not null 478 */ 479 @NonNull 480 public Optional<String> getExample() { 481 return Optional.ofNullable(example); 482 } 483 484 /** 485 * Gets the allowed values for this placeholder. 486 * 487 * @return the allowed values for this placeholder, not null 488 */ 489 @NonNull 490 public Set<@NonNull String> getAllowedValues() { 491 return allowedValues; 492 } 493 } 494 495 /** 496 * Container for per-language-form (gender, case, definiteness, classifier, formality, clusivity, animacy, 497 * cardinal, ordinal, phonetic) translation information. 498 * <p> 499 * Translations can be keyed either on a single value, a range of values (start and end) in the case of cardinality ranges, 500 * or a selector-based rule set for multi-axis agreement. 501 * <p> 502 * Exactly one translation mode is active for each instance. 503 * 504 * @author <a href="https://revetkn.com">Mark Allen</a> 505 */ 506 @Immutable 507 public static class LanguageFormTranslation { 508 @Nullable 509 private final String value; 510 @Nullable 511 private final LanguageFormTranslationRange range; 512 @NonNull 513 private final Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm; 514 @NonNull 515 private final List<@NonNull LanguageFormSelector> selectors; 516 @NonNull 517 private final List<@NonNull LanguageFormTranslationRule> translationRules; 518 519 /** 520 * Constructs a per-language-form translation set with the given placeholder value and mapping of translations by language form. 521 * 522 * @param value the placeholder value to compare against for translation, not null 523 * @param translationsByLanguageForm the possible translations keyed by language form, not null 524 */ 525 public LanguageFormTranslation(@NonNull String value, @NonNull Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm) { 526 requireNonNull(value); 527 requireNonNull(translationsByLanguageForm); 528 529 this.value = value; 530 this.range = null; 531 this.translationsByLanguageForm = Collections.unmodifiableMap(new LinkedHashMap<>(translationsByLanguageForm)); 532 this.selectors = Collections.emptyList(); 533 this.translationRules = Collections.emptyList(); 534 } 535 536 /** 537 * Constructs a per-language-form translation set with the given placeholder range and mapping of translations by language form. 538 * 539 * @param range the placeholder range to compare against for translation, not null 540 * @param translationsByLanguageForm the possible translations keyed by language form, not null 541 */ 542 public LanguageFormTranslation(@NonNull LanguageFormTranslationRange range, @NonNull Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm) { 543 requireNonNull(range); 544 requireNonNull(translationsByLanguageForm); 545 546 this.value = null; 547 this.range = range; 548 this.translationsByLanguageForm = Collections.unmodifiableMap(new LinkedHashMap<>(translationsByLanguageForm)); 549 this.selectors = Collections.emptyList(); 550 this.translationRules = Collections.emptyList(); 551 } 552 553 /** 554 * Constructs a selector-based translation set with multiple agreement dimensions and ordered translation rules. 555 * 556 * @param selectors the selector definitions that determine which language forms to evaluate, not null 557 * @param translationRules the translation rules to evaluate against the selector values, not null 558 */ 559 public LanguageFormTranslation(@NonNull List<@NonNull LanguageFormSelector> selectors, 560 @NonNull List<@NonNull LanguageFormTranslationRule> translationRules) { 561 requireNonNull(selectors); 562 requireNonNull(translationRules); 563 564 if (selectors.isEmpty()) 565 throw new IllegalArgumentException(format("Selector-based %s instances require at least one selector", 566 getClass().getSimpleName())); 567 568 if (translationRules.isEmpty()) 569 throw new IllegalArgumentException(format("Selector-based %s instances require at least one translation rule", 570 getClass().getSimpleName())); 571 572 this.value = null; 573 this.range = null; 574 this.translationsByLanguageForm = Collections.emptyMap(); 575 this.selectors = Collections.unmodifiableList(new ArrayList<>(selectors)); 576 this.translationRules = Collections.unmodifiableList(new ArrayList<>(translationRules)); 577 } 578 579 /** 580 * Generates a {@code String} representation of this object. 581 * 582 * @return a string representation of this object, not null 583 */ 584 @Override 585 @NonNull 586 public String toString() { 587 if (isSelectorDriven()) 588 return format("%s{selectors=%s, translationRules=%s}", getClass().getSimpleName(), getSelectors(), getTranslationRules()); 589 590 if (getRange().isPresent()) 591 return format("%s{range=%s, translationsByLanguageForm=%s}", getClass().getSimpleName(), getRange().get(), getTranslationsByLanguageForm()); 592 593 return format("%s{value=%s, translationsByLanguageForm=%s}", getClass().getSimpleName(), getValue().get(), getTranslationsByLanguageForm()); 594 } 595 596 /** 597 * Checks if this object is equal to another one. 598 * 599 * @param other the object to check, null returns false 600 * @return true if this is equal to the other object, false otherwise 601 */ 602 @Override 603 public boolean equals(@Nullable Object other) { 604 if (this == other) 605 return true; 606 607 if (other == null || !getClass().equals(other.getClass())) 608 return false; 609 610 LanguageFormTranslation languageFormTranslation = (LanguageFormTranslation) other; 611 612 return Objects.equals(getValue(), languageFormTranslation.getValue()) 613 && Objects.equals(getRange(), languageFormTranslation.getRange()) 614 && Objects.equals(getTranslationsByLanguageForm(), languageFormTranslation.getTranslationsByLanguageForm()) 615 && Objects.equals(getSelectors(), languageFormTranslation.getSelectors()) 616 && Objects.equals(getTranslationRules(), languageFormTranslation.getTranslationRules()); 617 } 618 619 /** 620 * A hash code for this object. 621 * 622 * @return a suitable hash code 623 */ 624 @Override 625 public int hashCode() { 626 return Objects.hash(getValue(), getRange(), getTranslationsByLanguageForm(), getSelectors(), getTranslationRules()); 627 } 628 629 /** 630 * Gets the value for this per-language-form translation set. 631 * 632 * @return the value for this per-language-form translation set, not null 633 */ 634 @NonNull 635 public Optional<String> getValue() { 636 return Optional.ofNullable(value); 637 } 638 639 /** 640 * Gets the range for this per-language-form translation set. 641 * 642 * @return the range for this per-language-form translation set, not null 643 */ 644 @NonNull 645 public Optional<LanguageFormTranslationRange> getRange() { 646 return Optional.ofNullable(range); 647 } 648 649 /** 650 * Indicates whether this translation uses selector-based multi-axis agreement. 651 * 652 * @return true if this translation is selector-driven, false otherwise 653 */ 654 public boolean isSelectorDriven() { 655 return getSelectors().size() > 0; 656 } 657 658 /** 659 * Gets the translations by language form for this per-language-form translation set. 660 * 661 * @return the translations by language form for this per-language-form translation set, not null 662 */ 663 @NonNull 664 public Map<@NonNull LanguageForm, @NonNull String> getTranslationsByLanguageForm() { 665 return translationsByLanguageForm; 666 } 667 668 /** 669 * Gets the selector definitions for this translation set. 670 * 671 * @return the selector definitions for this translation set, not null 672 */ 673 @NonNull 674 public List<@NonNull LanguageFormSelector> getSelectors() { 675 return selectors; 676 } 677 678 /** 679 * Gets the ordered translation rules for this translation set. 680 * 681 * @return the ordered translation rules for this translation set, not null 682 */ 683 @NonNull 684 public List<@NonNull LanguageFormTranslationRule> getTranslationRules() { 685 return translationRules; 686 } 687 } 688 689 /** 690 * Defines a selector used by a multi-axis placeholder translation. 691 * <p> 692 * Each selector identifies an application-supplied placeholder value and the language-form family to derive from it. 693 * 694 * @author <a href="https://revetkn.com">Mark Allen</a> 695 */ 696 @Immutable 697 public static class LanguageFormSelector { 698 @NonNull 699 private final String value; 700 @NonNull 701 private final LanguageFormType form; 702 703 /** 704 * Constructs a selector with the given placeholder value name and language-form type. 705 * 706 * @param value the placeholder value to inspect, not null 707 * @param form the language-form family to derive from the value, not null 708 */ 709 public LanguageFormSelector(@NonNull String value, @NonNull LanguageFormType form) { 710 requireNonNull(value); 711 requireNonNull(form); 712 713 this.value = value; 714 this.form = form; 715 } 716 717 /** 718 * Generates a {@code String} representation of this object. 719 * 720 * @return a string representation of this object, not null 721 */ 722 @Override 723 @NonNull 724 public String toString() { 725 return format("%s{value=%s, form=%s}", getClass().getSimpleName(), getValue(), getForm()); 726 } 727 728 /** 729 * Checks if this object is equal to another one. 730 * 731 * @param other the object to check, null returns false 732 * @return true if this is equal to the other object, false otherwise 733 */ 734 @Override 735 public boolean equals(@Nullable Object other) { 736 if (this == other) 737 return true; 738 739 if (other == null || !getClass().equals(other.getClass())) 740 return false; 741 742 LanguageFormSelector languageFormSelector = (LanguageFormSelector) other; 743 744 return Objects.equals(getValue(), languageFormSelector.getValue()) 745 && Objects.equals(getForm(), languageFormSelector.getForm()); 746 } 747 748 /** 749 * A hash code for this object. 750 * 751 * @return a suitable hash code 752 */ 753 @Override 754 public int hashCode() { 755 return Objects.hash(getValue(), getForm()); 756 } 757 758 /** 759 * Gets the placeholder value name for this selector. 760 * 761 * @return the placeholder value name for this selector, not null 762 */ 763 @NonNull 764 public String getValue() { 765 return value; 766 } 767 768 /** 769 * Gets the language-form family for this selector. 770 * 771 * @return the language-form family for this selector, not null 772 */ 773 @NonNull 774 public LanguageFormType getForm() { 775 return form; 776 } 777 } 778 779 /** 780 * Defines a selector-based translation rule for a multi-axis placeholder translation. 781 * <p> 782 * Rules may optionally provide a {@code when} map. An empty map represents the default rule. 783 * 784 * @author <a href="https://revetkn.com">Mark Allen</a> 785 */ 786 @Immutable 787 public static class LanguageFormTranslationRule { 788 @NonNull 789 private final Map<@NonNull LanguageFormType, @NonNull LanguageForm> whenByLanguageFormType; 790 @NonNull 791 private final String value; 792 793 /** 794 * Constructs a default translation rule with no conditions. 795 * 796 * @param value the value to use when this rule matches, not null 797 */ 798 public LanguageFormTranslationRule(@NonNull String value) { 799 this(Collections.emptyMap(), value); 800 } 801 802 /** 803 * Constructs a translation rule with the given selector conditions and value. 804 * 805 * @param whenByLanguageFormType selector conditions that must be satisfied for this rule to match, not null 806 * @param value the value to use when this rule matches, not null 807 */ 808 public LanguageFormTranslationRule(@NonNull Map<@NonNull LanguageFormType, @NonNull LanguageForm> whenByLanguageFormType, 809 @NonNull String value) { 810 requireNonNull(whenByLanguageFormType); 811 requireNonNull(value); 812 813 this.whenByLanguageFormType = Collections.unmodifiableMap(new LinkedHashMap<>(whenByLanguageFormType)); 814 this.value = value; 815 } 816 817 /** 818 * Generates a {@code String} representation of this object. 819 * 820 * @return a string representation of this object, not null 821 */ 822 @Override 823 @NonNull 824 public String toString() { 825 return format("%s{whenByLanguageFormType=%s, value=%s}", getClass().getSimpleName(), getWhenByLanguageFormType(), getValue()); 826 } 827 828 /** 829 * Checks if this object is equal to another one. 830 * 831 * @param other the object to check, null returns false 832 * @return true if this is equal to the other object, false otherwise 833 */ 834 @Override 835 public boolean equals(@Nullable Object other) { 836 if (this == other) 837 return true; 838 839 if (other == null || !getClass().equals(other.getClass())) 840 return false; 841 842 LanguageFormTranslationRule languageFormTranslationRule = (LanguageFormTranslationRule) other; 843 844 return Objects.equals(getWhenByLanguageFormType(), languageFormTranslationRule.getWhenByLanguageFormType()) 845 && Objects.equals(getValue(), languageFormTranslationRule.getValue()); 846 } 847 848 /** 849 * A hash code for this object. 850 * 851 * @return a suitable hash code 852 */ 853 @Override 854 public int hashCode() { 855 return Objects.hash(getWhenByLanguageFormType(), getValue()); 856 } 857 858 /** 859 * Gets the selector conditions for this rule. 860 * 861 * @return the selector conditions for this rule, not null 862 */ 863 @NonNull 864 public Map<@NonNull LanguageFormType, @NonNull LanguageForm> getWhenByLanguageFormType() { 865 return whenByLanguageFormType; 866 } 867 868 /** 869 * Gets the value for this rule. 870 * 871 * @return the value for this rule, not null 872 */ 873 @NonNull 874 public String getValue() { 875 return value; 876 } 877 } 878 879 /** 880 * Container for per-language-form cardinality translation information over a range (start, end) of values. 881 * 882 * @author <a href="https://revetkn.com">Mark Allen</a> 883 */ 884 @Immutable 885 public static class LanguageFormTranslationRange { 886 @NonNull 887 private final String start; 888 @NonNull 889 private final String end; 890 891 /** 892 * Constructs a translation range with the given start and end values. 893 * 894 * @param start the start value of the range, not null 895 * @param end the end value of the range, not null 896 */ 897 public LanguageFormTranslationRange(@NonNull String start, @NonNull String end) { 898 requireNonNull(start); 899 requireNonNull(end); 900 901 this.start = start; 902 this.end = end; 903 } 904 905 /** 906 * Generates a {@code String} representation of this object. 907 * 908 * @return a string representation of this object, not null 909 */ 910 @Override 911 @NonNull 912 public String toString() { 913 return format("%s{start=%s, end=%s}", getClass().getSimpleName(), getStart(), getEnd()); 914 } 915 916 /** 917 * Checks if this object is equal to another one. 918 * 919 * @param other the object to check, null returns false 920 * @return true if this is equal to the other object, false otherwise 921 */ 922 @Override 923 public boolean equals(@Nullable Object other) { 924 if (this == other) 925 return true; 926 927 if (other == null || !getClass().equals(other.getClass())) 928 return false; 929 930 LanguageFormTranslationRange languageFormTranslationRange = (LanguageFormTranslationRange) other; 931 932 return Objects.equals(getStart(), languageFormTranslationRange.getStart()) 933 && Objects.equals(getEnd(), languageFormTranslationRange.getEnd()); 934 } 935 936 /** 937 * A hash code for this object. 938 * 939 * @return a suitable hash code 940 */ 941 @Override 942 public int hashCode() { 943 return Objects.hash(getStart(), getEnd()); 944 } 945 946 /** 947 * The start value for this range. 948 * 949 * @return the start value for this range, not null 950 */ 951 @NonNull 952 public String getStart() { 953 return start; 954 } 955 956 /** 957 * The end value for this range. 958 * 959 * @return the end value for this range, not null 960 */ 961 @NonNull 962 public String getEnd() { 963 return end; 964 } 965 } 966}