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}