001/*
002 * PlotSquared, a land and world management plugin for Minecraft.
003 * Copyright (C) IntellectualSites <https://intellectualsites.com>
004 * Copyright (C) IntellectualSites team and contributors
005 *
006 * This program is free software: you can redistribute it and/or modify
007 * it under the terms of the GNU General Public License as published by
008 * the Free Software Foundation, either version 3 of the License, or
009 * (at your option) any later version.
010 *
011 * This program is distributed in the hope that it will be useful,
012 * but WITHOUT ANY WARRANTY; without even the implied warranty of
013 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
014 * GNU General Public License for more details.
015 *
016 * You should have received a copy of the GNU General Public License
017 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
018 */
019package com.plotsquared.core.configuration.caption.load;
020
021import com.google.common.reflect.TypeToken;
022import com.google.gson.Gson;
023import com.google.gson.GsonBuilder;
024import com.plotsquared.core.configuration.caption.CaptionMap;
025import com.plotsquared.core.configuration.caption.LocalizedCaptionMap;
026import com.plotsquared.core.configuration.caption.PerUserLocaleCaptionMap;
027import com.plotsquared.core.configuration.caption.TranslatableCaption;
028import org.apache.logging.log4j.LogManager;
029import org.apache.logging.log4j.Logger;
030import org.checkerframework.checker.nullness.qual.NonNull;
031
032import java.io.BufferedReader;
033import java.io.BufferedWriter;
034import java.io.IOException;
035import java.io.Reader;
036import java.lang.reflect.Type;
037import java.nio.charset.StandardCharsets;
038import java.nio.file.Files;
039import java.nio.file.Path;
040import java.util.Collections;
041import java.util.HashMap;
042import java.util.LinkedHashMap;
043import java.util.List;
044import java.util.Locale;
045import java.util.Map;
046import java.util.function.Function;
047import java.util.regex.Matcher;
048import java.util.regex.Pattern;
049import java.util.stream.Collectors;
050import java.util.stream.Stream;
051
052/**
053 * This class handles loading and updating of message files.
054 */
055public final class CaptionLoader {
056
057    private static final Logger LOGGER = LogManager.getLogger("PlotSquared/" + CaptionLoader.class.getSimpleName());
058
059    private static final Gson GSON;
060
061    static {
062        GSON = new GsonBuilder()
063                .setPrettyPrinting()
064                .disableHtmlEscaping()
065                .create();
066    }
067
068    private final Map<String, String> defaultMessages;
069    private final Locale defaultLocale;
070    private final Function<Path, Locale> localeExtractor;
071    private final DefaultCaptionProvider captionProvider;
072    private final String namespace;
073
074    private CaptionLoader(
075            final @NonNull Locale internalLocale,
076            final @NonNull Function<@NonNull Path, @NonNull Locale> localeExtractor,
077            final @NonNull DefaultCaptionProvider captionProvider,
078            final @NonNull String namespace
079    ) {
080        this.defaultLocale = internalLocale;
081        this.localeExtractor = localeExtractor;
082        this.captionProvider = captionProvider;
083        this.namespace = namespace;
084        Map<String, String> temp;
085        try {
086            temp = this.captionProvider.loadDefaults(internalLocale);
087        } catch (Exception e) {
088            LOGGER.error("Failed to load default messages", e);
089            temp = Collections.emptyMap();
090        }
091        this.defaultMessages = temp;
092    }
093
094    /**
095     * Returns a new CaptionLoader instance. That instance will use the internalLocale to extract default values
096     * from the captionProvider
097     *
098     * @param internalLocale  the locale used internally to resolve default messages from the caption provider.
099     * @param localeExtractor a function to extract a locale from a path, e.g. by its name.
100     * @param captionProvider the provider for default captions.
101     * @return a CaptionLoader instance that can load and patch message files.
102     */
103    public static @NonNull CaptionLoader of(
104            final @NonNull Locale internalLocale,
105            final @NonNull Function<@NonNull Path, @NonNull Locale> localeExtractor,
106            final @NonNull DefaultCaptionProvider captionProvider,
107            final @NonNull String namespace
108    ) {
109        return new CaptionLoader(internalLocale, localeExtractor, captionProvider, namespace);
110    }
111
112    /**
113     * Returns a function that extracts a locale from a path using the given pattern.
114     * The pattern is required to have (at least) one capturing group, as this is used to access the locale
115     * tag.The function will throw an {@link IllegalArgumentException} if the matcher doesn't match the file name
116     * of the input path. The language tag is loaded using {@link Locale#forLanguageTag(String)}.
117     *
118     * @param pattern the pattern to match and extract the language tag with.
119     * @return a function to extract a locale from a path using a pattern.
120     * @see Matcher#group(int)
121     * @see Path#getFileName()
122     */
123    public static @NonNull Function<@NonNull Path, @NonNull Locale> patternExtractor(final @NonNull Pattern pattern) {
124        return path -> {
125            final String fileName = path.getFileName().toString();
126            final Matcher matcher = pattern.matcher(fileName);
127            if (matcher.matches()) {
128                return Locale.forLanguageTag(matcher.group(1));
129            } else {
130                throw new IllegalArgumentException(fileName + " is an invalid message file (cannot extract locale)");
131            }
132        };
133    }
134
135    /**
136     * Loads a map of translation keys mapping to their translations from a reader.
137     * The format is expected to be a json object:
138     * <pre>{@code
139     * {
140     *     "key1": "value a",
141     *     "key2": "value b",
142     *     ...
143     * }
144     * }</pre>
145     *
146     * @param reader the reader to read the map from.
147     * @return the translation map.
148     */
149    @SuppressWarnings("UnstableApiUsage")
150    static @NonNull Map<@NonNull String, @NonNull String> loadFromReader(final @NonNull Reader reader) {
151        final Type type = new TypeToken<Map<String, String>>() {
152        }.getType();
153        return new LinkedHashMap<>(GSON.fromJson(reader, type));
154    }
155
156    private static void save(final Path file, final Map<String, String> content) {
157        try (final BufferedWriter writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
158            GSON.toJson(content, writer);
159            LOGGER.info("Saved {} with new content", file.getFileName());
160        } catch (final IOException e) {
161            LOGGER.error("Failed to save caption file '{}'", file.getFileName().toString(), e);
162        }
163    }
164
165    /**
166     * Load all message files in the given directory into a new CaptionMap.
167     *
168     * @param directory The directory to load files from
169     * @return A new CaptionMap containing the loaded messages
170     * @throws IOException if the files in the given path can't be listed
171     * @see Files#list(Path)
172     * @see #loadSingle(Path)
173     */
174    public @NonNull CaptionMap loadAll(final @NonNull Path directory) throws IOException {
175        final Map<Locale, CaptionMap> localeMaps = new HashMap<>();
176        try (final Stream<Path> files = Files.list(directory)) {
177            final List<Path> captionFiles = files.filter(Files::isRegularFile).toList();
178            for (Path file : captionFiles) {
179                try {
180                    final CaptionMap localeMap = loadSingle(file);
181                    localeMaps.put(localeMap.getLocale(), localeMap);
182                } catch (Exception e) {
183                    LOGGER.error("Failed to load language file '{}'", file.getFileName().toString(), e);
184                }
185            }
186            LOGGER.info("Loaded {} message files. Loaded Languages: {}", localeMaps.size(), localeMaps.keySet());
187            return new PerUserLocaleCaptionMap(localeMaps);
188        }
189    }
190
191    /**
192     * Load a message file into a new CaptionMap. The file name must match
193     * the pattern expected by the {@link #localeExtractor}.
194     * Note that this method does not attempt to create a new file.
195     *
196     * @param file The file to load
197     * @return A new CaptionMap containing the loaded messages
198     * @throws IOException              if the file couldn't be accessed or read successfully.
199     * @throws IllegalArgumentException if the file name doesn't match the specified format.
200     * @see #loadOrCreateSingle(Path)
201     */
202    public @NonNull CaptionMap loadSingle(final @NonNull Path file) throws IOException {
203        final Locale locale = this.localeExtractor.apply(file);
204        try (final BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
205            Map<String, String> map = loadFromReader(reader);
206            if (patch(map, locale)) {
207                save(file, map); // update the file using the modified map
208            }
209            return new LocalizedCaptionMap(locale, mapToCaptions(map));
210        }
211    }
212
213    /**
214     * Load a message file into a new CaptionMap. The file name must match
215     * the pattern expected by the {@link #localeExtractor}.
216     * If no file exists at the given path, this method will
217     * attempt to create one and fill it with default values.
218     *
219     * @param file The file to load
220     * @return A new CaptionMap containing the loaded messages
221     * @throws IOException              if the file couldn't be accessed or read successfully.
222     * @throws IllegalArgumentException if the file name doesn't match the specified format.
223     * @see #loadSingle(Path)
224     * @since 6.9.3
225     */
226    public @NonNull CaptionMap loadOrCreateSingle(final @NonNull Path file) throws IOException {
227        final Locale locale = this.localeExtractor.apply(file);
228        if (!Files.exists(file)) {
229            Map<String, String> map = new LinkedHashMap<>();
230            patch(map, locale);
231            save(file, map);
232            return new LocalizedCaptionMap(locale, mapToCaptions(map));
233        } else {
234            return loadSingle(file);
235        }
236    }
237
238    private @NonNull Map<TranslatableCaption, String> mapToCaptions(Map<String, String> map) {
239        return map.entrySet().stream().collect(
240                Collectors.toMap(
241                        entry -> TranslatableCaption.of(this.namespace, entry.getKey()),
242                        Map.Entry::getValue
243                ));
244    }
245
246    /**
247     * Add missing entries to the given map.
248     * Entries are missing if the key exists in {@link #defaultLocale} but isn't present
249     * in the given map. For a missing key, a value will be loaded either from
250     * the resource matching the given locale or from {@link #defaultLocale} if
251     * no matching resource was found or the key isn't present in the resource.
252     *
253     * @param map    the map to patch
254     * @param locale the locale to get the resource from
255     * @return {@code true} if the map was patched.
256     */
257    private boolean patch(final Map<String, String> map, final Locale locale) {
258        boolean modified = false;
259        Map<String, String> languageSpecific;
260        if (locale.equals(this.defaultLocale)) {
261            languageSpecific = this.defaultMessages;
262        } else {
263            languageSpecific = this.captionProvider.loadDefaults(locale);
264            if (languageSpecific == null) { // fallback for languages not provided
265                languageSpecific = this.defaultMessages;
266            }
267        }
268        for (Map.Entry<String, String> entry : this.defaultMessages.entrySet()) {
269            if (!map.containsKey(entry.getKey())) {
270                final String value = languageSpecific.getOrDefault(entry.getKey(), entry.getValue());
271                map.put(entry.getKey(), value);
272                modified = true;
273            }
274        }
275        return modified;
276    }
277
278}