diff --git a/bukkit/src/main/resources/luckperms.commodore b/bukkit/src/main/resources/luckperms.commodore index a6a202ee4..c41c48862 100644 --- a/bukkit/src/main/resources/luckperms.commodore +++ b/bukkit/src/main/resources/luckperms.commodore @@ -60,6 +60,9 @@ luckperms { migration { plugin brigadier:string single_word; } + translations { + install; + } creategroup { name brigadier:string single_word; } diff --git a/common/src/main/java/me/lucko/luckperms/common/command/CommandManager.java b/common/src/main/java/me/lucko/luckperms/common/command/CommandManager.java index 6a4ea1b6e..9b4a53e78 100644 --- a/common/src/main/java/me/lucko/luckperms/common/command/CommandManager.java +++ b/common/src/main/java/me/lucko/luckperms/common/command/CommandManager.java @@ -50,6 +50,7 @@ import me.lucko.luckperms.common.commands.misc.NetworkSyncCommand; import me.lucko.luckperms.common.commands.misc.ReloadConfigCommand; import me.lucko.luckperms.common.commands.misc.SearchCommand; import me.lucko.luckperms.common.commands.misc.SyncCommand; +import me.lucko.luckperms.common.commands.misc.TranslationsCommand; import me.lucko.luckperms.common.commands.misc.TreeCommand; import me.lucko.luckperms.common.commands.misc.VerboseCommand; import me.lucko.luckperms.common.commands.track.CreateTrack; @@ -114,6 +115,7 @@ public class CommandManager { .add(new ReloadConfigCommand()) .add(new BulkUpdateCommand()) .add(new MigrationParentCommand()) + .add(new TranslationsCommand()) .add(new ApplyEditsCommand()) .add(new CreateGroup()) .add(new DeleteGroup()) diff --git a/common/src/main/java/me/lucko/luckperms/common/command/access/CommandPermission.java b/common/src/main/java/me/lucko/luckperms/common/command/access/CommandPermission.java index 01dd7a0ee..087caab49 100644 --- a/common/src/main/java/me/lucko/luckperms/common/command/access/CommandPermission.java +++ b/common/src/main/java/me/lucko/luckperms/common/command/access/CommandPermission.java @@ -47,6 +47,7 @@ public enum CommandPermission { BULK_UPDATE("bulkupdate", Type.NONE), APPLY_EDITS("applyedits", Type.NONE), MIGRATION("migration", Type.NONE), + TRANSLATIONS("translations", Type.NONE), CREATE_GROUP("creategroup", Type.NONE), DELETE_GROUP("deletegroup", Type.NONE), diff --git a/common/src/main/java/me/lucko/luckperms/common/command/spec/CommandSpec.java b/common/src/main/java/me/lucko/luckperms/common/command/spec/CommandSpec.java index 24b27f0c5..ef0048d39 100644 --- a/common/src/main/java/me/lucko/luckperms/common/command/spec/CommandSpec.java +++ b/common/src/main/java/me/lucko/luckperms/common/command/spec/CommandSpec.java @@ -85,6 +85,9 @@ public enum CommandSpec { arg("constraint...", false) ), MIGRATION("/%s migration"), + TRANSLATIONS("/%s translations", + arg("install", false) + ), APPLY_EDITS("/%s applyedits [target]", arg("code", true), arg("target", false) diff --git a/common/src/main/java/me/lucko/luckperms/common/commands/misc/TranslationsCommand.java b/common/src/main/java/me/lucko/luckperms/common/commands/misc/TranslationsCommand.java new file mode 100644 index 000000000..a794a13bc --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/commands/misc/TranslationsCommand.java @@ -0,0 +1,189 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.commands.misc; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import me.lucko.luckperms.common.command.CommandResult; +import me.lucko.luckperms.common.command.abstraction.SingleCommand; +import me.lucko.luckperms.common.command.access.CommandPermission; +import me.lucko.luckperms.common.command.spec.CommandSpec; +import me.lucko.luckperms.common.command.utils.ArgumentList; +import me.lucko.luckperms.common.http.UnsuccessfulRequestException; +import me.lucko.luckperms.common.locale.Message; +import me.lucko.luckperms.common.locale.TranslationManager; +import me.lucko.luckperms.common.plugin.LuckPermsPlugin; +import me.lucko.luckperms.common.sender.Sender; +import me.lucko.luckperms.common.util.MoreFiles; +import me.lucko.luckperms.common.util.Predicates; +import me.lucko.luckperms.common.util.gson.GsonProvider; + +import net.kyori.adventure.text.Component; + +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public class TranslationsCommand extends SingleCommand { + private static final String TRANSLATIONS_INFO_ENDPOINT = "https://metadata.luckperms.net/data/translations"; + private static final String TRANSLATIONS_DOWNLOAD_ENDPOINT = "https://metadata.luckperms.net/translation/"; + + public TranslationsCommand() { + super(CommandSpec.TRANSLATIONS, "Translations", CommandPermission.TRANSLATIONS, Predicates.notInRange(0, 1)); + } + + @Override + public CommandResult execute(LuckPermsPlugin plugin, Sender sender, ArgumentList args, String label) { + Message.TRANSLATIONS_SEARCHING.send(sender); + + List availableTranslations; + try { + availableTranslations = getAvailableTranslations(plugin); + } catch (IOException | UnsuccessfulRequestException e) { + Message.TRANSLATIONS_SEARCHING_ERROR.send(sender); + e.printStackTrace(); + return CommandResult.FAILURE; + } + + if (args.size() >= 1 && args.get(0).equalsIgnoreCase("install")) { + Message.TRANSLATIONS_DOWNLOADING.send(sender); + + downloadTranslations(plugin, availableTranslations, sender); + plugin.getTranslationManager().reload(); + + Message.TRANSLATIONS_INSTALL_COMPLETE.send(sender); + return CommandResult.SUCCESS; + } + + Message.INSTALLED_TRANSLATIONS.send(sender, plugin.getTranslationManager().getInstalledLocales().stream().map(Locale::toString).collect(Collectors.toList())); + + Message.AVAILABLE_TRANSLATIONS_HEADER.send(sender); + for (LanguageInfo language : availableTranslations) { + Message.AVAILABLE_TRANSLATIONS_ENTRY.send(sender, language.locale.toString(), language.locale.getDisplayLanguage(language.locale), language.progress, language.contributors); + } + sender.sendMessage(Message.prefixed(Component.empty())); + Message.TRANSLATIONS_DOWNLOAD_PROMPT.send(sender, label); + return CommandResult.SUCCESS; + } + + private static void downloadTranslations(LuckPermsPlugin plugin, List languages, Sender sender) { + try { + MoreFiles.createDirectoriesIfNotExists(plugin.getTranslationManager().getTranslationsDirectory()); + } catch (IOException e) { + // ignore + } + + for (LanguageInfo language : languages) { + Message.TRANSLATIONS_INSTALLING.send(sender, language.locale.toString()); + + Request request = new Request.Builder() + .header("User-Agent", plugin.getBytebin().getUserAgent()) + .url(TRANSLATIONS_DOWNLOAD_ENDPOINT + language.id) + .build(); + + Path file = plugin.getTranslationManager().getTranslationsDirectory().resolve(language.locale.toString() + ".properties"); + + try (Response response = plugin.getBytebin().makeHttpRequest(request)) { + try (ResponseBody responseBody = response.body()) { + if (responseBody == null) { + throw new RuntimeException("No response"); + } + + try (InputStream inputStream = responseBody.byteStream()) { + Files.copy(inputStream, file, StandardCopyOption.REPLACE_EXISTING); + } + } + } catch (UnsuccessfulRequestException | IOException e) { + Message.TRANSLATIONS_DOWNLOAD_ERROR.send(sender, language.locale.toString()); + e.printStackTrace(); + } + } + } + + public static List getAvailableTranslations(LuckPermsPlugin plugin) throws IOException, UnsuccessfulRequestException { + Request request = new Request.Builder() + .header("User-Agent", plugin.getBytebin().getUserAgent()) + .url(TRANSLATIONS_INFO_ENDPOINT) + .build(); + + JsonObject jsonResponse; + try (Response response = plugin.getBytebin().makeHttpRequest(request)) { + try (ResponseBody responseBody = response.body()) { + if (responseBody == null) { + throw new RuntimeException("No response"); + } + + try (InputStream inputStream = responseBody.byteStream()) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + jsonResponse = GsonProvider.normal().fromJson(reader, JsonObject.class); + } + } + } + } + + List languages = new ArrayList<>(); + for (Map.Entry language : jsonResponse.get("languages").getAsJsonObject().entrySet()) { + languages.add(new LanguageInfo(language.getKey(), language.getValue().getAsJsonObject())); + } + languages.removeIf(language -> language.progress <= 0); + return languages; + } + + private static final class LanguageInfo { + private final String id; + private final String name; + private final Locale locale; + private final int progress; + private final List contributors; + + LanguageInfo(String id, JsonObject data) { + this.id = id; + this.name = data.get("name").getAsString(); + this.locale = Objects.requireNonNull(TranslationManager.parseLocale(data.get("localeTag").getAsString(), null)); + this.progress = data.get("progress").getAsInt(); + this.contributors = new ArrayList<>(); + for (JsonElement contributor : data.get("contributors").getAsJsonArray()) { + this.contributors.add(contributor.getAsJsonObject().get("name").getAsString()); + } + } + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/locale/Message.java b/common/src/main/java/me/lucko/luckperms/common/locale/Message.java index 20da665c1..81d12b0b6 100644 --- a/common/src/main/java/me/lucko/luckperms/common/locale/Message.java +++ b/common/src/main/java/me/lucko/luckperms/common/locale/Message.java @@ -2570,6 +2570,104 @@ public interface Message { .append(FULL_STOP) ); + Args0 TRANSLATIONS_SEARCHING = () -> prefixed(translatable() + // "&7Searching for available translations, please wait..." + .key("luckperms.command.translations.searching") + .color(GRAY) + ); + + Args0 TRANSLATIONS_SEARCHING_ERROR = () -> prefixed(text() + // "&cUnable to obtain a list of available translations. Check the console for errors." + .color(RED) + .append(translatable("luckperms.command.translations.searching-error")) + .append(FULL_STOP) + .append(space()) + .append(translatable("luckperms.command.misc.check-console-for-errors")) + .append(FULL_STOP) + ); + + Args1> INSTALLED_TRANSLATIONS = locales -> prefixed(translatable() + // "&aInstalled Translations:" + .key("luckperms.command.translations.installed-translations") + .color(GREEN) + .append(text(':')) + .append(space()) + .append(formatStringList(locales)) + ); + + Args0 AVAILABLE_TRANSLATIONS_HEADER = () -> prefixed(translatable() + // "&aAvailable Translations:" + .key("luckperms.command.translations.available-translations") + .color(GREEN) + .append(text(':')) + ); + + Args4> AVAILABLE_TRANSLATIONS_ENTRY = (tag, name, percentComplete, contributors) -> prefixed(text() + // - {} ({}) - {}% translated - by {} + .color(GRAY) + .append(text('-')) + .append(space()) + .append(text(tag, AQUA)) + .append(space()) + .append(OPEN_BRACKET) + .append(text(name, WHITE)) + .append(CLOSE_BRACKET) + .append(text(" - ")) + .append(translatable("luckperms.command.translations.percent-translated", text(percentComplete, GREEN))) + .apply(builder -> { + if (!contributors.isEmpty()) { + builder.append(text(" - ")); + builder.append(translatable("luckperms.command.translations.translations-by")); + builder.append(space()); + builder.append(formatStringList(contributors)); + } + }) + ); + + Args1 TRANSLATIONS_DOWNLOAD_PROMPT = label -> join(newline(), + // "Use /lp translations install to download and install up-to-date versions of these translations provided by the community." + // "Please note that this will override any changes you've made for these languages." + prefixed(translatable() + .key("luckperms.command.translations.download-prompt") + .color(AQUA) + .args(text("/" + label + " translations install", GREEN)) + .append(FULL_STOP)), + prefixed(translatable() + .key("luckperms.command.translations.download-override-warning") + .color(GRAY) + .append(FULL_STOP)) + ); + + Args0 TRANSLATIONS_DOWNLOADING = () -> prefixed(translatable() + // "&bDownloading translations, please wait..." + .key("luckperms.command.translations.downloading") + .color(AQUA) + ); + + Args1 TRANSLATIONS_INSTALLING = name -> prefixed(translatable() + // "&aInstalling language {}..." + .key("luckperms.command.translations.installing") + .color(GREEN) + .args(text((name))) + ); + + Args0 TRANSLATIONS_INSTALL_COMPLETE = () -> prefixed(translatable() + // "&bInstallation complete." + .key("luckperms.command.translations.install-complete") + .color(AQUA) + .append(FULL_STOP) + ); + + Args1 TRANSLATIONS_DOWNLOAD_ERROR = name -> prefixed(text() + // "&cUnable download translation for {}. Check the console for errors." + .color(RED) + .append(translatable("luckperms.command.translations.download-error", text(name, DARK_RED))) + .append(FULL_STOP) + .append(space()) + .append(translatable("luckperms.command.misc.check-console-for-errors")) + .append(FULL_STOP) + ); + Args4 USER_INFO_GENERAL = (username, uuid, mojang, online) -> join(newline(), // "&b&l> &bUser Info: &f{}" // "&f- &3UUID: &f{}" diff --git a/common/src/main/java/me/lucko/luckperms/common/locale/TranslationManager.java b/common/src/main/java/me/lucko/luckperms/common/locale/TranslationManager.java index 549587f3e..02b56df7a 100644 --- a/common/src/main/java/me/lucko/luckperms/common/locale/TranslationManager.java +++ b/common/src/main/java/me/lucko/luckperms/common/locale/TranslationManager.java @@ -40,6 +40,8 @@ import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.ResourceBundle; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -48,23 +50,40 @@ public class TranslationManager { public static final Locale DEFAULT_LOCALE = Locale.ENGLISH; private final LuckPermsPlugin plugin; - private final TranslationRegistry registry; + private final Path translationsDirectory; + private final Set installed = ConcurrentHashMap.newKeySet(); + private TranslationRegistry registry; public TranslationManager(LuckPermsPlugin plugin) { this.plugin = plugin; + this.translationsDirectory = this.plugin.getBootstrap().getConfigDirectory().resolve("translations"); + } - // create a translation registry for luckperms + public Path getTranslationsDirectory() { + return this.translationsDirectory; + } + + public Set getInstalledLocales() { + return Collections.unmodifiableSet(this.installed); + } + + public void reload() { + // remove any previous registry + if (this.registry != null) { + GlobalTranslator.get().removeSource(this.registry); + this.installed.clear(); + } + + // create a translation registry this.registry = TranslationRegistry.create(Key.key("luckperms", "main")); this.registry.defaultLocale(DEFAULT_LOCALE); - // register it to the global source, so our translations can be picked up by adventure-platform - GlobalTranslator.get().addSource(this.registry); - } - - public void load() { // load custom translations first, then the base (built-in) translations after. loadCustom(); loadBase(); + + // register it to the global source, so our translations can be picked up by adventure-platform + GlobalTranslator.get().addSource(this.registry); } /** @@ -80,7 +99,7 @@ public class TranslationManager { */ public void loadCustom() { List translationFiles; - try (Stream stream = Files.list(this.plugin.getBootstrap().getConfigDirectory().resolve("translations"))) { + try (Stream stream = Files.list(this.translationsDirectory)) { translationFiles = stream.filter(path -> path.getFileName().toString().endsWith(".properties")).collect(Collectors.toList()); } catch (IOException e) { translationFiles = Collections.emptyList(); @@ -106,7 +125,8 @@ public class TranslationManager { } this.registry.registerAll(locale, translationFile, true); - this.plugin.getLogger().info("Registered additional translations for " + locale.toLanguageTag()); + this.plugin.getLogger().info("Registered additional translations for " + locale.toString()); + this.installed.add(locale); } public static Locale parseLocale(String locale, Locale defaultLocale) { diff --git a/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java b/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java index 697a63cd0..cf4ab4fac 100644 --- a/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java +++ b/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java @@ -78,6 +78,7 @@ public abstract class AbstractLuckPermsPlugin implements LuckPermsPlugin { private PermissionRegistry permissionRegistry; private LogDispatcher logDispatcher; private LuckPermsConfiguration configuration; + private OkHttpClient httpClient; private BytebinClient bytebin; private FileWatcher fileWatcher = null; private Storage storage; @@ -98,7 +99,7 @@ public abstract class AbstractLuckPermsPlugin implements LuckPermsPlugin { this.dependencyManager.loadDependencies(getGlobalDependencies()); this.translationManager = new TranslationManager(this); - this.translationManager.load(); + this.translationManager.reload(); } public final void enable() { @@ -118,11 +119,11 @@ public abstract class AbstractLuckPermsPlugin implements LuckPermsPlugin { this.configuration = new LuckPermsConfiguration(this, provideConfigurationAdapter()); // setup a bytebin instance - OkHttpClient httpClient = new OkHttpClient.Builder() + this.httpClient = new OkHttpClient.Builder() .callTimeout(15, TimeUnit.SECONDS) .build(); - this.bytebin = new BytebinClient(httpClient, getConfiguration().get(ConfigKeys.BYTEBIN_URL), "luckperms"); + this.bytebin = new BytebinClient(this.httpClient, getConfiguration().get(ConfigKeys.BYTEBIN_URL), "luckperms"); // now the configuration is loaded, we can create a storage factory and load initial dependencies StorageFactory storageFactory = new StorageFactory(this); @@ -316,6 +317,11 @@ public abstract class AbstractLuckPermsPlugin implements LuckPermsPlugin { return this.configuration; } + @Override + public OkHttpClient getHttpClient() { + return this.httpClient; + } + @Override public BytebinClient getBytebin() { return this.bytebin; diff --git a/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java b/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java index ffbd27351..accb59459 100644 --- a/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java +++ b/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java @@ -57,6 +57,8 @@ import me.lucko.luckperms.common.verbose.VerboseHandler; import net.luckperms.api.query.QueryOptions; +import okhttp3.OkHttpClient; + import java.util.Collections; import java.util.List; import java.util.Optional; @@ -232,6 +234,13 @@ public interface LuckPermsPlugin { */ Optional getFileWatcher(); + /** + * Gets the http client used by the plugin. + * + * @return the http client + */ + OkHttpClient getHttpClient(); + /** * Gets the bytebin instance in use by platform. * diff --git a/common/src/main/resources/luckperms_en.properties b/common/src/main/resources/luckperms_en.properties index 88f24e991..79ff826c5 100644 --- a/common/src/main/resources/luckperms_en.properties +++ b/common/src/main/resources/luckperms_en.properties @@ -302,6 +302,18 @@ luckperms.command.update-task.push.error=Error whilst pushing changes to other s luckperms.command.update-task.push.error-not-setup=Cannot push changes to other servers as a messaging service has not been configured luckperms.command.reload-config.success=The configuration file was reloaded luckperms.command.reload-config.restart-note=some options will only apply after the server has restarted +luckperms.command.translations.searching=Searching for available translations, please wait... +luckperms.command.translations.searching-error=Unable to obtain a list of available translations +luckperms.command.translations.installed-translations=Installed Translations +luckperms.command.translations.available-translations=Available Translations +luckperms.command.translations.percent-translated={0}% translated +luckperms.command.translations.translations-by=by +luckperms.command.translations.downloading=Downloading translations, please wait... +luckperms.command.translations.download-error=Unable download translation for {0} +luckperms.command.translations.installing=Installing language {0}... +luckperms.command.translations.install-complete=Installation complete +luckperms.command.translations.download-prompt=Use {0} to download and install up-to-date versions of these translations provided by the community +luckperms.command.translations.download-override-warning=Please note that this will override any changes you''ve made for these languages luckperms.usage.user.description=A set of commands for managing users within LuckPerms. (A ''user'' in LuckPerms is just a player, and can refer to a UUID or username) luckperms.usage.group.description=A set of commands for managing groups within LuckPerms. Groups are just collections of permission assignments that can be given to users. New groups are made using the ''creategroup'' command. luckperms.usage.track.description=A set of commands for managing tracks within LuckPerms. Tracks are a ordered collection of groups which can be used for defining promotions and demotions. @@ -340,6 +352,8 @@ luckperms.usage.bulk-update.argument.action-field=the field to act upon. only re luckperms.usage.bulk-update.argument.action-value=the value to replace with. only required for ''update''. luckperms.usage.bulk-update.argument.constraint=the constraints required for the update luckperms.usage.migration.description=Migration commands +luckperms.usage.translations.description=Manage translations +luckperms.usage.translations.argument.install=subcommand to install translations luckperms.usage.apply-edits.description=Applies permission changes made from the web editor luckperms.usage.apply-edits.argument.code=the unique code for the data luckperms.usage.apply-edits.argument.target=who to apply the data to