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}