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}