diff --git a/src/main/java/org/maxgamer/quickshop/util/MsgUtil.java b/src/main/java/org/maxgamer/quickshop/util/MsgUtil.java
index 1bf152f44..a8adc3d28 100644
--- a/src/main/java/org/maxgamer/quickshop/util/MsgUtil.java
+++ b/src/main/java/org/maxgamer/quickshop/util/MsgUtil.java
@@ -1464,6 +1464,7 @@ public class MsgUtil {
}
setAndUpdate("_comment", "Please edit this file after format with json formatter");
}
+ public static final int languageVersion = 61;
private static void setAndUpdate(@NotNull String path, @Nullable Object object) {
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/Formatter.java b/src/main/java/org/maxgamer/quickshop/util/language/Formatter.java
deleted file mode 100644
index f57824a67..000000000
--- a/src/main/java/org/maxgamer/quickshop/util/language/Formatter.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * This file is a part of project QuickShop, the name is Formatter.java
- * Copyright (C) PotatoCraft Studio and contributors
- *
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the
- * Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
- * for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- *
- */
-
-package org.maxgamer.quickshop.util.language;
-
-import org.bukkit.command.CommandSender;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-public interface Formatter {
- /**
- * Calling while a string need to be formatted
- *
- * @param raw Raw string (but it might formatted by other formatter)
- * @param sender The command sender
- * @param args The args you can use it to fill
- * @return Formatted string (and it will transfer to other formatters)
- */
- @NotNull String format(@NotNull String raw, @Nullable CommandSender sender, @Nullable String... args);
-}
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/formatter/FilledFormatter.java b/src/main/java/org/maxgamer/quickshop/util/language/formatter/FilledFormatter.java
deleted file mode 100644
index f29e664a3..000000000
--- a/src/main/java/org/maxgamer/quickshop/util/language/formatter/FilledFormatter.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * This file is a part of project QuickShop, the name is FilledFormatter.java
- * Copyright (C) PotatoCraft Studio and contributors
- *
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the
- * Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
- * for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- *
- */
-
-package org.maxgamer.quickshop.util.language.formatter;
-
-import org.bukkit.command.CommandSender;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-import org.maxgamer.quickshop.util.MsgUtil;
-import org.maxgamer.quickshop.util.language.Formatter;
-
-public class FilledFormatter implements Formatter {
-
- @Override
- public @NotNull String format(@NotNull String raw, @Nullable CommandSender sender, @Nullable String... args) {
- return MsgUtil.fillArgs(raw, args);
- }
-}
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/formatter/PAPIFormatter.java b/src/main/java/org/maxgamer/quickshop/util/language/formatter/PAPIFormatter.java
deleted file mode 100644
index f146a64f1..000000000
--- a/src/main/java/org/maxgamer/quickshop/util/language/formatter/PAPIFormatter.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * This file is a part of project QuickShop, the name is PAPIFormatter.java
- * Copyright (C) PotatoCraft Studio and contributors
- *
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the
- * Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
- * for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- *
- */
-
-package org.maxgamer.quickshop.util.language.formatter;
-
-import me.clip.placeholderapi.PlaceholderAPI;
-import org.bukkit.OfflinePlayer;
-import org.bukkit.command.CommandSender;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-import org.maxgamer.quickshop.util.language.Formatter;
-
-public class PAPIFormatter implements Formatter {
-
- @Override
- public @NotNull String format(@NotNull String raw, @Nullable CommandSender sender, @Nullable String... args) {
- if (sender instanceof OfflinePlayer) {
- return PlaceholderAPI.setPlaceholders((OfflinePlayer) sender, raw);
- }
- return raw;
- }
-}
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/text/TextManager.java b/src/main/java/org/maxgamer/quickshop/util/language/text/TextManager.java
new file mode 100644
index 000000000..975c09de5
--- /dev/null
+++ b/src/main/java/org/maxgamer/quickshop/util/language/text/TextManager.java
@@ -0,0 +1,137 @@
+package org.maxgamer.quickshop.util.language.text;
+
+import com.dumptruckman.bukkit.configuration.json.JsonConfiguration;
+import org.bukkit.command.CommandSender;
+import org.bukkit.configuration.InvalidConfigurationException;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.maxgamer.quickshop.QuickShop;
+import org.maxgamer.quickshop.util.Util;
+import org.maxgamer.quickshop.util.language.text.distributions.Distribution;
+import org.maxgamer.quickshop.util.language.text.distributions.crowdin.CrowdinOTA;
+import org.maxgamer.quickshop.util.language.text.postprocessing.PostProcessor;
+import org.maxgamer.quickshop.util.language.text.postprocessing.impl.ColorProcessor;
+import org.maxgamer.quickshop.util.language.text.postprocessing.impl.FillerProcessor;
+import org.maxgamer.quickshop.util.language.text.postprocessing.impl.PlaceHolderApiProcessor;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Level;
+
+public class TextManager {
+ private final QuickShop plugin;
+ private final Distribution distribution;
+ private final File overrideFilesFolder;
+ // >
+ private final Map> locale2ContentMapping = new HashMap<>();
+ private final static String languageFileCrowdin = "/master/src/main/resources/lang/%locale%/messages.json";
+ public List postProcessors = new ArrayList<>();
+ private JsonConfiguration bundledLang = new JsonConfiguration();
+
+ public TextManager(QuickShop plugin) {
+ this.plugin = plugin;
+ this.distribution = new CrowdinOTA(plugin);
+ this.overrideFilesFolder = new File(plugin.getDataFolder(), "lang-override");
+ load();
+ }
+
+ public void load() {
+ locale2ContentMapping.clear();
+ postProcessors.clear();
+ // Load mapping
+ //for (String availableFile : distribution.getAvailableFiles()) {
+ try (InputStream stream = plugin.getResource("lang-original/messages.json")) {
+ if (stream != null)
+ bundledLang.loadFromString(new String(Util.inputStream2ByteArray(stream), StandardCharsets.UTF_8));
+ } catch (IOException | InvalidConfigurationException ignored) {
+ bundledLang = new JsonConfiguration();
+ }
+ for (String availableLanguage : distribution.getAvailableLanguages()) {
+ try {
+ // load OTA text
+ String localeFileContent = distribution.getFile(languageFileCrowdin, availableLanguage);
+ Map fileLocaleMapping = locale2ContentMapping.computeIfAbsent(languageFileCrowdin, k -> new HashMap<>());
+ JsonConfiguration configuration = new JsonConfiguration();
+ fileLocaleMapping.put(availableLanguage, configuration);
+ configuration.loadFromString(localeFileContent);
+ // load override text
+ JsonConfiguration override = new JsonConfiguration();
+ File localOverrideFile = new File(overrideFilesFolder, availableLanguage + ".json");
+ if (localOverrideFile.exists()) {
+ override.loadFromString(Files.readString(localOverrideFile.toPath()));
+ for (String key : override.getKeys(true)) {
+ configuration.set(key, override.get(key));
+ }
+ }
+ } catch (Exception e) {
+ plugin.getLogger().log(Level.WARNING, "Couldn't update the translation for locale " + availableLanguage, e);
+ }
+ }
+ postProcessors.add(new FillerProcessor());
+ postProcessors.add(new ColorProcessor());
+ postProcessors.add(new PlaceHolderApiProcessor());
+ }
+
+ public Text of(CommandSender sender, String path, String... args) {
+ return new Text(this, sender, locale2ContentMapping.get(languageFileCrowdin), path, args);
+ }
+
+ static class Text {
+ private final TextManager manager;
+ private final String path;
+ private final QuickShop plugin;
+ private final Map mapping;
+ private final CommandSender sender;
+ private final String[] args;
+
+ private Text(TextManager manager, CommandSender sender, Map mapping, String path, String... args) {
+ this.plugin = manager.plugin;
+ this.manager = manager;
+ this.sender = sender;
+ this.mapping = mapping;
+ this.path = path;
+ this.args = args;
+ }
+
+ @Nullable
+ private String fallbackLocal() {
+ return manager.bundledLang.getString(path);
+ }
+
+ @NotNull
+ private String postProcess(@NotNull String text) {
+ for (PostProcessor postProcessor : this.manager.postProcessors)
+ text = postProcessor.process(text, sender, args);
+ return text;
+ }
+
+ @NotNull
+ public String forLocale(@NotNull String locale) {
+ JsonConfiguration index = mapping.get(locale);
+ if (index == null && locale.equals("en")) {
+ String str = fallbackLocal();
+ if (str == null)
+ return "Fallback Missing Language Key: " + path + ", report to QuickShop!";
+ }
+ if (index == null)
+ return forLocale("en");
+ String str = index.getString(locale);
+ if (str == null)
+ return "Missing Language Key: " + path;
+ return postProcess(str);
+ }
+
+ @NotNull
+ public String forLocale(@NotNull Player player){
+ return forLocale(player.getLocale());
+ }
+ }
+}
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/text/distributions/Distribution.java b/src/main/java/org/maxgamer/quickshop/util/language/text/distributions/Distribution.java
new file mode 100644
index 000000000..30ccac181
--- /dev/null
+++ b/src/main/java/org/maxgamer/quickshop/util/language/text/distributions/Distribution.java
@@ -0,0 +1,15 @@
+package org.maxgamer.quickshop.util.language.text.distributions;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+
+public interface Distribution {
+ @NotNull List getAvailableLanguages();
+
+ @NotNull List getAvailableFiles();
+
+ @NotNull String getFile(String fileCrowdinPath, String crowdinLocale) throws Exception;
+
+ @NotNull String getFile(String fileCrowdinPath, String crowdinLocale, boolean forceFlush) throws Exception;
+}
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/text/distributions/crowdin/CrowdinOTA.java b/src/main/java/org/maxgamer/quickshop/util/language/text/distributions/crowdin/CrowdinOTA.java
new file mode 100644
index 000000000..85a74c782
--- /dev/null
+++ b/src/main/java/org/maxgamer/quickshop/util/language/text/distributions/crowdin/CrowdinOTA.java
@@ -0,0 +1,118 @@
+package org.maxgamer.quickshop.util.language.text.distributions.crowdin;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.maxgamer.quickshop.QuickShop;
+import org.maxgamer.quickshop.nonquickshopstuff.com.sk89q.worldedit.util.net.HttpRequest;
+import org.maxgamer.quickshop.util.JsonUtil;
+import org.maxgamer.quickshop.util.Util;
+import org.maxgamer.quickshop.util.language.text.distributions.Distribution;
+import org.maxgamer.quickshop.util.language.text.distributions.crowdin.bean.Manifest;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class CrowdinOTA implements Distribution {
+ protected static final String CROWDIN_OTA_HOST = "https://distributions.crowdin.net/daf1a8db40f132ce157c457xrm4/";
+ protected final Cache requestCachePool = CacheBuilder.newBuilder()
+ .expireAfterWrite(7, TimeUnit.DAYS)
+ .recordStats()
+ .build();
+ private QuickShop plugin;
+
+ public CrowdinOTA(QuickShop plugin) {
+ this.plugin = plugin;
+ Util.getCacheFolder().mkdirs();
+ }
+
+ private @Nullable String requestWithCache(String url) {
+ String data = requestCachePool.getIfPresent(url);
+ if (data == null) {
+ try {
+ data = HttpRequest.get(new URL(url))
+ .execute()
+ .expectResponseCode(200)
+ .returnContent()
+ .asString("UTF-8");
+ } catch (IOException e) {
+ e.printStackTrace();
+ return null;
+ }
+ if (data != null)
+ requestCachePool.put(url, data);
+ }
+ return data;
+ }
+
+ @Nullable
+ public Manifest getManifest() {
+ String url = CROWDIN_OTA_HOST + "manifest.json";
+ String data = requestWithCache(url);
+ if (data == null)
+ return null;
+ return JsonUtil.getGson().fromJson(data, Manifest.class);
+ }
+
+ @NotNull
+ public List getAvailableLanguages() {
+ Manifest manifest = getManifest();
+ if (manifest == null)
+ return Collections.emptyList();
+ return manifest.getLanguages();
+ }
+
+ @NotNull
+ public List getAvailableFiles() {
+ Manifest manifest = getManifest();
+ if (manifest == null)
+ return Collections.emptyList();
+ return manifest.getFiles();
+ }
+
+ @Override
+ public @NotNull String getFile(String fileCrowdinPath, String crowdinLocale) throws Exception {
+ return getFile(fileCrowdinPath,crowdinLocale,false);
+ }
+
+ @NotNull
+ public String getFile(String fileCrowdinPath, String crowdinLocale, boolean forceFlush)throws Exception {
+ Manifest manifest = getManifest();
+ if (manifest == null)
+ throw new IllegalStateException("Failed to get project manifest");
+ if (!manifest.getFiles().contains(fileCrowdinPath))
+ throw new IllegalArgumentException("The file " + fileCrowdinPath + " not exists on Crowdin");
+ if (!manifest.getCustomLanguages().contains(crowdinLocale))
+ throw new IllegalArgumentException("The locale " + crowdinLocale + " not exists on Crowdin");
+ String postProcessingPath = fileCrowdinPath.replace("%locale%", crowdinLocale);
+ String pathHash = DigestUtils.sha1Hex(postProcessingPath);
+ File metadataFile = new File(Util.getCacheFolder(), "i18n.metadata");
+ YamlConfiguration cacheMetadata = YamlConfiguration.loadConfiguration(metadataFile);
+ long localeTimestamp = cacheMetadata.getLong(pathHash + ".timestamp");
+ String data = Files.readString(new File(Util.getCacheFolder(), pathHash).toPath());
+ // invalidate cache, flush it
+ if (localeTimestamp != manifest.getTimestamp() || forceFlush) {
+ String url = CROWDIN_OTA_HOST + "content/" + fileCrowdinPath.replace("%locale%", postProcessingPath);
+ data = requestWithCache(url);
+ if (data == null)
+ throw new IOException("Couldn't download translation from remote server");
+ // update cache index
+ cacheMetadata.set(pathHash + ".timestamp", manifest.getTimestamp());
+ cacheMetadata.save(metadataFile);
+ }
+ if (data == null) {
+ cacheMetadata.set(pathHash, null);
+ cacheMetadata.save(metadataFile);
+ throw new IOException("Couldn't read translation from local cache, please try again");
+ }
+ return data;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/text/distributions/crowdin/bean/Manifest.java b/src/main/java/org/maxgamer/quickshop/util/language/text/distributions/crowdin/bean/Manifest.java
new file mode 100644
index 000000000..8371c99d5
--- /dev/null
+++ b/src/main/java/org/maxgamer/quickshop/util/language/text/distributions/crowdin/bean/Manifest.java
@@ -0,0 +1,23 @@
+package org.maxgamer.quickshop.util.language.text.distributions.crowdin.bean;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@NoArgsConstructor
+@Data
+public class Manifest {
+
+ @JsonProperty("files")
+ private List files;
+ @JsonProperty("languages")
+ private List languages;
+ @JsonProperty("language_mapping")
+ private List> languageMapping;
+ @JsonProperty("custom_languages")
+ private List> customLanguages;
+ @JsonProperty("timestamp")
+ private Integer timestamp;
+}
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/PostProcessor.java b/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/PostProcessor.java
new file mode 100644
index 000000000..ddc97aa91
--- /dev/null
+++ b/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/PostProcessor.java
@@ -0,0 +1,10 @@
+package org.maxgamer.quickshop.util.language.text.postprocessing;
+
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+public interface PostProcessor {
+ @NotNull
+ String process(@NotNull String text, @Nullable CommandSender sender, String... args);
+}
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/impl/ColorProcessor.java b/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/impl/ColorProcessor.java
new file mode 100644
index 000000000..a79425369
--- /dev/null
+++ b/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/impl/ColorProcessor.java
@@ -0,0 +1,14 @@
+package org.maxgamer.quickshop.util.language.text.postprocessing.impl;
+
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.maxgamer.quickshop.util.Util;
+import org.maxgamer.quickshop.util.language.text.postprocessing.PostProcessor;
+
+public class ColorProcessor implements PostProcessor {
+ @Override
+ public @NotNull String process(@NotNull String text, @Nullable CommandSender sender, String... args) {
+ return Util.parseColours(text);
+ }
+}
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/impl/FillerProcessor.java b/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/impl/FillerProcessor.java
new file mode 100644
index 000000000..2fcd80564
--- /dev/null
+++ b/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/impl/FillerProcessor.java
@@ -0,0 +1,14 @@
+package org.maxgamer.quickshop.util.language.text.postprocessing.impl;
+
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.maxgamer.quickshop.util.MsgUtil;
+import org.maxgamer.quickshop.util.language.text.postprocessing.PostProcessor;
+
+public class FillerProcessor implements PostProcessor {
+ @Override
+ public @NotNull String process(@NotNull String text, @Nullable CommandSender sender, String... args) {
+ return MsgUtil.fillArgs(text,args);
+ }
+}
diff --git a/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/impl/PlaceHolderApiProcessor.java b/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/impl/PlaceHolderApiProcessor.java
new file mode 100644
index 000000000..6f9bf3853
--- /dev/null
+++ b/src/main/java/org/maxgamer/quickshop/util/language/text/postprocessing/impl/PlaceHolderApiProcessor.java
@@ -0,0 +1,21 @@
+package org.maxgamer.quickshop.util.language.text.postprocessing.impl;
+
+import me.clip.placeholderapi.PlaceholderAPI;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.maxgamer.quickshop.util.language.text.postprocessing.PostProcessor;
+
+public class PlaceHolderApiProcessor implements PostProcessor {
+ @Override
+ public @NotNull String process(@NotNull String text, @Nullable CommandSender sender, String... args) {
+ if(sender instanceof OfflinePlayer player) {
+ if (Bukkit.getPluginManager().isPluginEnabled("PlaceHolderAPI")) {
+ return PlaceholderAPI.setPlaceholders(player,text);
+ }
+ }
+ return text;
+ }
+}