diff --git a/src/main/java/world/bentobox/challenges/ChallengesAddon.java b/src/main/java/world/bentobox/challenges/ChallengesAddon.java index 764dd78..c35901b 100644 --- a/src/main/java/world/bentobox/challenges/ChallengesAddon.java +++ b/src/main/java/world/bentobox/challenges/ChallengesAddon.java @@ -21,6 +21,7 @@ import world.bentobox.challenges.config.Settings; import world.bentobox.challenges.handlers.*; import world.bentobox.challenges.listeners.ResetListener; import world.bentobox.challenges.listeners.SaveListener; +import world.bentobox.challenges.web.WebManager; import world.bentobox.level.Level; @@ -39,6 +40,11 @@ public class ChallengesAddon extends Addon { private ChallengesImportManager importManager; + /** + * This class manages web content loading. + */ + private WebManager webManager; + private Settings settings; private boolean hooked; @@ -138,6 +144,9 @@ public class ChallengesAddon extends Addon { // Challenge import setup this.importManager = new ChallengesImportManager(this); + // Web content loading + this.webManager = new WebManager(this); + List hookedGameModes = new ArrayList<>(); this.getPlugin().getAddonsManager().getGameModeAddons().forEach(gameModeAddon -> { @@ -315,6 +324,14 @@ public class ChallengesAddon extends Addon { } + /** + * @return the webManager + */ + public WebManager getWebManager() { + return this.webManager; + } + + /** * @return the challenge settings. */ diff --git a/src/main/java/world/bentobox/challenges/ChallengesImportManager.java b/src/main/java/world/bentobox/challenges/ChallengesImportManager.java index 42b487f..11f71a4 100644 --- a/src/main/java/world/bentobox/challenges/ChallengesImportManager.java +++ b/src/main/java/world/bentobox/challenges/ChallengesImportManager.java @@ -126,6 +126,77 @@ public class ChallengesImportManager } + /** + * This method loads downloaded challenges into memory. + * @param user User who calls downloaded challenge loading + * @param world Target world. + * @param downloadString String that need to be loaded via DefaultDataHolder. + * @return true if everything was successful, otherwise false. + */ + public boolean loadDownloadedChallenges(User user, World world, String downloadString) + { + ChallengesManager manager = this.addon.getChallengesManager(); + + // If exist any challenge or level that is bound to current world, then do not load default challenges. + if (manager.hasAnyChallengeData(world.getName())) + { + if (user.isPlayer()) + { + user.sendMessage("challenges.errors.exist-challenges-or-levels"); + } + else + { + this.addon.logWarning("challenges.errors.exist-challenges-or-levels"); + } + + return false; + } + + try + { + // This prefix will be used to all challenges. That is a unique way how to separate challenged for + // each game mode. + String uniqueIDPrefix = Utils.getGameMode(world) + "_"; + DefaultDataHolder downloadedChallenges = new DefaultJSONHandler(this.addon).loadWebObject(downloadString); + + // All new challenges should get correct ID. So we need to map it to loaded challenges. + downloadedChallenges.getChallengeList().forEach(challenge -> { + // Set correct challenge ID + challenge.setUniqueId(uniqueIDPrefix + challenge.getUniqueId()); + // Set up correct level ID if it is necessary + if (!challenge.getLevel().isEmpty()) + { + challenge.setLevel(uniqueIDPrefix + challenge.getLevel()); + } + // Load challenge in memory + manager.loadChallenge(challenge, false, user, user == null); + }); + + downloadedChallenges.getLevelList().forEach(challengeLevel -> { + // Set correct level ID + challengeLevel.setUniqueId(uniqueIDPrefix + challengeLevel.getUniqueId()); + // Set correct world name + challengeLevel.setWorld(Util.getWorld(world).getName()); + // Reset names for all challenges. + challengeLevel.setChallenges(challengeLevel.getChallenges().stream(). + map(challenge -> uniqueIDPrefix + challenge). + collect(Collectors.toSet())); + // Load level in memory + manager.loadLevel(challengeLevel, false, user, user == null); + }); + } + catch (Exception e) + { + e.printStackTrace(); + return false; + } + + this.addon.getChallengesManager().save(); + + return true; + } + + // --------------------------------------------------------------------- // Section: Default generation // --------------------------------------------------------------------- @@ -320,6 +391,16 @@ public class ChallengesImportManager } + /** + * This method creates and adds to list all objects from default.json file. + * @return List of all objects from default.json that is with T instance. + */ + DefaultDataHolder loadWebObject(String downloadedObject) + { + return this.gson.fromJson(downloadedObject, DefaultDataHolder.class); + } + + // --------------------------------------------------------------------- // Section: Variables // --------------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/challenges/panel/CommonGUI.java b/src/main/java/world/bentobox/challenges/panel/CommonGUI.java index 7830b45..5f9e71a 100644 --- a/src/main/java/world/bentobox/challenges/panel/CommonGUI.java +++ b/src/main/java/world/bentobox/challenges/panel/CommonGUI.java @@ -53,7 +53,7 @@ public abstract class CommonGUI /** * This variable stores parent gui. */ - private CommonGUI parentGUI; + protected CommonGUI parentGUI; /** * Variable stores Challenges addon. @@ -136,6 +136,7 @@ public abstract class CommonGUI protected static final String COMPLETE = "complete"; + protected static final String DOWNLOAD = "download"; // --------------------------------------------------------------------- // Section: Constructors @@ -205,6 +206,40 @@ public abstract class CommonGUI } + /** + * Default constructor that inits panels with minimal requirements. + * @param parentGUI Parent panel for current panel. + */ + public CommonGUI(CommonGUI parentGUI) + { + this.addon = parentGUI.addon; + this.world = parentGUI.world; + this.user = parentGUI.user; + + this.topLabel = parentGUI.topLabel; + this.permissionPrefix = parentGUI.permissionPrefix; + + this.parentGUI = parentGUI; + + this.pageIndex = 0; + + this.returnButton = new PanelItemBuilder(). + name(this.user.getTranslation("challenges.gui.buttons.return")). + icon(Material.OAK_DOOR). + clickHandler((panel, user1, clickType, i) -> { + + if (this.parentGUI == null) + { + this.user.closeInventory(); + return true; + } + + this.parentGUI.build(); + return true; + }).build(); + } + + // --------------------------------------------------------------------- // Section: Common methods // --------------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/challenges/panel/admin/AdminGUI.java b/src/main/java/world/bentobox/challenges/panel/admin/AdminGUI.java index 58bec9a..4447657 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/AdminGUI.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/AdminGUI.java @@ -63,7 +63,8 @@ public class AdminGUI extends CommonGUI EDIT_SETTINGS, DEFAULT_IMPORT_CHALLENGES, DEFAULT_EXPORT_CHALLENGES, - COMPLETE_WIPE + COMPLETE_WIPE, + LIBRARY } @@ -122,6 +123,8 @@ public class AdminGUI extends CommonGUI // Import Challenges panelBuilder.item(15, this.createButton(Button.DEFAULT_IMPORT_CHALLENGES)); + panelBuilder.item(24, this.createButton(Button.LIBRARY)); + // Not added as I do not think admins should use it. It still will be able via command. // panelBuilder.item(33, this.createButton(Button.DEFAULT_EXPORT_CHALLENGES)); @@ -439,6 +442,21 @@ public class AdminGUI extends CommonGUI break; } + case LIBRARY: + { + permissionSuffix = DOWNLOAD; + + name = this.user.getTranslation("challenges.gui.buttons.admin.library"); + description = this.user.getTranslation("challenges.gui.descriptions.admin.library"); + icon = new ItemStack(Material.COBWEB); + clickHandler = (panel, user, clickType, slot) -> { + ListLibraryGUI.open(this); + return true; + }; + glow = false; + + break; + } default: // This should never happen. return null; diff --git a/src/main/java/world/bentobox/challenges/panel/admin/ListLibraryGUI.java b/src/main/java/world/bentobox/challenges/panel/admin/ListLibraryGUI.java new file mode 100644 index 0000000..77f4867 --- /dev/null +++ b/src/main/java/world/bentobox/challenges/panel/admin/ListLibraryGUI.java @@ -0,0 +1,212 @@ +package world.bentobox.challenges.panel.admin; + + +import org.bukkit.ChatColor; +import org.bukkit.World; +import java.util.ArrayList; +import java.util.List; + +import world.bentobox.bentobox.api.panels.PanelItem; +import world.bentobox.bentobox.api.panels.builders.PanelBuilder; +import world.bentobox.bentobox.api.panels.builders.PanelItemBuilder; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.challenges.ChallengesAddon; +import world.bentobox.challenges.panel.CommonGUI; +import world.bentobox.challenges.utils.GuiUtils; +import world.bentobox.challenges.web.object.LibraryEntry; + + +/** + * This class contains all necessary elements to create GUI that lists all challenges. + * It allows to edit them or remove, depending on given input mode. + */ +public class ListLibraryGUI extends CommonGUI +{ + // --------------------------------------------------------------------- + // Section: Constructor + // --------------------------------------------------------------------- + + /** + * @param parentGUI ParentGUI object. + */ + public ListLibraryGUI(CommonGUI parentGUI) + { + super(parentGUI); + } + + + /** + * @param addon Addon where panel operates. + * @param world World from which panel was created. + * @param user User who created panel. + * @param topLabel Command top label which creates panel (f.e. island or ai) + * @param permissionPrefix Command permission prefix (f.e. bskyblock.) + */ + public ListLibraryGUI(ChallengesAddon addon, + World world, + User user, + String topLabel, + String permissionPrefix) + { + super(addon, world, user, topLabel, permissionPrefix, null); + } + + + /** + * This static method allows to easier open Library GUI. + * @param parentGui ParentGUI object. + */ + public static void open(CommonGUI parentGui) + { + new ListLibraryGUI(parentGui).build(); + } + + + // --------------------------------------------------------------------- + // Section: Methods + // --------------------------------------------------------------------- + + + /** + * {@inheritDoc} + */ + @Override + public void build() + { + PanelBuilder panelBuilder = new PanelBuilder().user(this.user).name( + this.user.getTranslation("challenges.gui.title.admin.library-title")); + + GuiUtils.fillBorder(panelBuilder); + + List libraryEntries = this.addon.getWebManager().getLibraryEntries(); + + final int MAX_ELEMENTS = 21; + + if (this.pageIndex < 0) + { + this.pageIndex = libraryEntries.size() / MAX_ELEMENTS; + } + else if (this.pageIndex > (libraryEntries.size() / MAX_ELEMENTS)) + { + this.pageIndex = 0; + } + + int entryIndex = MAX_ELEMENTS * this.pageIndex; + + // I want first row to be only for navigation and return button. + int index = 10; + + while (entryIndex < ((this.pageIndex + 1) * MAX_ELEMENTS) && + entryIndex < libraryEntries.size() && + index < 36) + { + if (!panelBuilder.slotOccupied(index)) + { + panelBuilder.item(index, this.createEntryIcon(libraryEntries.get(entryIndex++))); + } + + index++; + } + + // Navigation buttons only if necessary + if (libraryEntries.size() > MAX_ELEMENTS) + { + panelBuilder.item(18, this.getButton(CommonButtons.PREVIOUS)); + panelBuilder.item(26, this.getButton(CommonButtons.NEXT)); + } + + panelBuilder.item(44, this.returnButton); + + panelBuilder.build(); + } + + + /** + * This method creates button for given library entry. + * @param libraryEntry LibraryEntry which button must be created. + * @return Entry button. + */ + private PanelItem createEntryIcon(LibraryEntry libraryEntry) + { + PanelItemBuilder itemBuilder = new PanelItemBuilder(). + name(ChatColor.translateAlternateColorCodes('&', libraryEntry.getName())). + description(this.generateEntryDescription(libraryEntry)). + icon(libraryEntry.getIcon()). + glow(false); + + itemBuilder.clickHandler((panel, user1, clickType, i) -> { + + if (!this.blockedForDownland) + { + this.blockedForDownland = true; + + this.user.sendMessage("challenges.messages.admin.start-downloading"); + + // Run download task after 5 ticks. + this.addon.getPlugin().getServer().getScheduler(). + runTaskLaterAsynchronously( + this.addon.getPlugin(), + () -> this.addon.getWebManager().requestEntryGitHubData(this.user, this.world, libraryEntry), + 5L); + + if (this.parentGUI != null) + { + this.parentGUI.build(); + } + else + { + this.user.closeInventory(); + } + } + + return true; + }); + + return itemBuilder.build(); + } + + + /** + * This method generated description for LibraryEntry object. + * @param entry LibraryEntry object which description must be generated. + * @return List of strings that will be placed in ItemStack lore message. + */ + private List generateEntryDescription(LibraryEntry entry) + { + List description = new ArrayList<>(); + + description.add(this.user.getTranslation(REFERENCE_DESCRIPTION + "library-author", + "[author]", + entry.getAuthor())); + description.add(entry.getDescription()); + + description.add(this.user.getTranslation(REFERENCE_DESCRIPTION + "library-gamemode", + "[gamemode]", + entry.getForGameMode())); + description.add(this.user.getTranslation(REFERENCE_DESCRIPTION + "library-lang", + "[lang]", + entry.getLanguage())); + description.add(this.user.getTranslation(REFERENCE_DESCRIPTION + "library-version", + "[version]", + entry.getVersion())); + + return GuiUtils.stringSplit(description, + this.addon.getChallengesSettings().getLoreLineLength()); + } + + +// --------------------------------------------------------------------- +// Section: Instance Variables +// --------------------------------------------------------------------- + + + /** + * This variable will protect against spam-click. + */ + private boolean blockedForDownland; + + /** + * Reference string to description. + */ + private static final String REFERENCE_DESCRIPTION = "challenges.gui.descriptions.admin."; +} diff --git a/src/main/java/world/bentobox/challenges/web/WebManager.java b/src/main/java/world/bentobox/challenges/web/WebManager.java new file mode 100644 index 0000000..64f1b23 --- /dev/null +++ b/src/main/java/world/bentobox/challenges/web/WebManager.java @@ -0,0 +1,225 @@ +package world.bentobox.challenges.web; + + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.bukkit.World; +import java.nio.charset.StandardCharsets; +import java.util.*; + +import io.github.TheBusyBiscuit.GitHubWebAPI4Java.GitHubWebAPI; +import io.github.TheBusyBiscuit.GitHubWebAPI4Java.objects.repositories.GitHubRepository; +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.challenges.ChallengesAddon; +import world.bentobox.challenges.web.object.LibraryEntry; + + +/** + * This class manages content downloading from web repository. + */ +public class WebManager +{ + /** + * Default constructor + * @param addon Challenges Addon object. + */ + public WebManager(ChallengesAddon addon) + { + this.addon = addon; + this.plugin = addon.getPlugin(); + + this.library = new ArrayList<>(0); + + if (this.plugin.getSettings().isGithubDownloadData()) + { + long connectionInterval = this.plugin.getSettings().getGithubConnectionInterval() * 20L * 60L; + + if (connectionInterval <= 0) + { + // If below 0, it means we shouldn't run this as a repeating task. + this.plugin.getServer().getScheduler().runTaskLaterAsynchronously(this.plugin, + () -> this.requestCatalogGitHubData(true), + 20L); + } + else + { + // Set connection interval to be at least 60 minutes. + connectionInterval = Math.max(connectionInterval, 60 * 20 * 60L); + this.plugin.getServer().getScheduler().runTaskTimerAsynchronously(this.plugin, + () -> this.requestCatalogGitHubData(true), + 20L, + connectionInterval); + } + } + } + + + /** + * This method requests catalog entries from challenges library. + * @param clearCache Boolean that indicates if all cached values must be cleared. + */ + public void requestCatalogGitHubData(boolean clearCache) + { + this.plugin.getWebManager().getGitHub().ifPresent(gh -> + { + if (this.plugin.getSettings().isLogGithubDownloadData()) + { + this.plugin.log("Downloading data from GitHub..."); + } + + GitHubRepository repo = new GitHubRepository(gh, "BentoBoxWorld/weblink"); + + String catalogContent = ""; + + // Downloading the data + try + { + catalogContent = repo.getContent("challenges/catalog.json").getContent().replaceAll("\\n", ""); + } + catch (IllegalAccessException e) + { + if (this.plugin.getSettings().isLogGithubDownloadData()) + { + this.plugin.log("Could not connect to GitHub."); + } + } + catch (Exception e) + { + this.plugin.logError("An error occurred when downloading data from GitHub..."); + this.plugin.logStacktrace(e); + } + + // People were concerned that the download took ages, so we need to tell them it's over now. + if (this.plugin.getSettings().isLogGithubDownloadData()) + { + this.plugin.log("Successfully downloaded data from GitHub."); + } + + // Decoding the Base64 encoded contents + catalogContent = new String(Base64.getDecoder().decode(catalogContent), + StandardCharsets.UTF_8); + + /* Parsing the data */ + + // Register the catalog data + if (!catalogContent.isEmpty()) + { + if (clearCache) + { + this.library.clear(); + } + + JsonObject catalog = new JsonParser().parse(catalogContent).getAsJsonObject(); + catalog.getAsJsonArray("challenges").forEach(gamemode -> + this.library.add(new LibraryEntry(gamemode.getAsJsonObject()))); + } + }); + } + + + /** + * This method requests GitHub data for given LibraryEntry object. + * @param user User who inits request. + * @param world Target world where challenges should be loaded. + * @param entry Entry that contains information about requested object. + * @return {@code true} if request was successful, {@code false} otherwise. + */ + public boolean requestEntryGitHubData(User user, World world, LibraryEntry entry) + { + Optional gitAPI = this.plugin.getWebManager().getGitHub(); + + if (gitAPI.isPresent()) + { + if (this.plugin.getSettings().isLogGithubDownloadData()) + { + this.plugin.log("Downloading data from GitHub..."); + } + + GitHubRepository repo = new GitHubRepository(gitAPI.get(), "BentoBoxWorld/weblink"); + + String challengeLibrary = ""; + + // Downloading the data + try + { + + challengeLibrary = repo.getContent("challenges/library/" + entry.getRepository() + ".json"). + getContent(). + replaceAll("\\n", ""); + } + catch (IllegalAccessException e) + { + if (this.plugin.getSettings().isLogGithubDownloadData()) + { + this.plugin.log("Could not connect to GitHub."); + } + } + catch (Exception e) + { + this.plugin.logError("An error occurred when downloading data from GitHub..."); + this.plugin.logStacktrace(e); + } + + // People were concerned that the download took ages, so we need to tell them it's over now. + if (this.plugin.getSettings().isLogGithubDownloadData()) + { + this.plugin.log("Successfully downloaded data from GitHub."); + } + + // Decoding the Base64 encoded contents + challengeLibrary = new String(Base64.getDecoder().decode(challengeLibrary), + StandardCharsets.UTF_8); + + /* Parsing the data */ + + // Process downloaded library data + if (!challengeLibrary.isEmpty()) + { + this.addon.getImportManager().loadDownloadedChallenges(user, world, challengeLibrary); + return true; + } + } + + return false; + } + + +// --------------------------------------------------------------------- +// Section: Getters +// --------------------------------------------------------------------- + + + /** + * This method returns all library entries that are downlaoded. + * @return existing Library entries. + */ + public List getLibraryEntries() + { + List entries = new ArrayList<>(this.library); + entries.sort(Comparator.comparingInt(LibraryEntry::getSlot)); + + return entries; + } + + +// --------------------------------------------------------------------- +// Section: Variables +// --------------------------------------------------------------------- + + + /** + * Challenges Addon variable. + */ + private ChallengesAddon addon; + + /** + * BentoBox plugin variable. + */ + private BentoBox plugin; + + /** + * This list contains all entries that were downloaded from GitHub. + */ + private List library; +} diff --git a/src/main/java/world/bentobox/challenges/web/object/LibraryEntry.java b/src/main/java/world/bentobox/challenges/web/object/LibraryEntry.java new file mode 100644 index 0000000..15f384c --- /dev/null +++ b/src/main/java/world/bentobox/challenges/web/object/LibraryEntry.java @@ -0,0 +1,186 @@ +package world.bentobox.challenges.web.object; + + +import com.google.gson.JsonObject; +import org.bukkit.Material; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + + +/** + * This objects allows to load each Challenges Catalog library entry. + */ +public class LibraryEntry +{ + /** + * Default constructor. + * @param object Json Object that must be translated to LibraryEntry. + */ + public LibraryEntry(@NonNull JsonObject object) + { + this.name = object.get("name").getAsString(); + + Material material = Material.matchMaterial(object.get("icon").getAsString()); + this.icon = (material != null) ? material : Material.PAPER; + + this.description = object.get("description").getAsString(); + this.repository = object.get("repository").getAsString(); + this.language = object.get("language").getAsString(); + + this.slot = object.get("slot").getAsInt(); + + this.forGameMode = object.get("for").getAsString(); + this.author = object.get("author").getAsString(); + this.version = object.get("version").getAsString(); + } + + + /** + * This method returns the name value. + * @return the value of name. + */ + @NonNull + public String getName() + { + return name; + } + + + /** + * This method returns the icon value. + * @return the value of icon. + */ + @NonNull + public Material getIcon() + { + return icon; + } + + + /** + * This method returns the description value. + * @return the value of description. + */ + @NonNull + public String getDescription() + { + return description; + } + + + /** + * This method returns the repository value. + * @return the value of repository. + */ + @NonNull + public String getRepository() + { + return repository; + } + + + /** + * This method returns the language value. + * @return the value of language. + */ + @NonNull + public String getLanguage() + { + return language; + } + + + /** + * This method returns the slot value. + * @return the value of slot. + */ + @NonNull + public int getSlot() + { + return slot; + } + + + /** + * This method returns the forGameMode value. + * @return the value of forGameMode. + */ + @NonNull + public String getForGameMode() + { + return forGameMode; + } + + + /** + * This method returns the author value. + * @return the value of author. + */ + @NonNull + public String getAuthor() + { + return author; + } + + + /** + * This method returns the version value. + * @return the value of version. + */ + @NonNull + public String getVersion() + { + return version; + } + + +// --------------------------------------------------------------------- +// Section: Variables +// --------------------------------------------------------------------- + + + /** + * Name of entry object + */ + private @NonNull String name; + + /** + * Defaults to {@link Material#PAPER}. + */ + private @NonNull Material icon; + + /** + * Description of entry object. + */ + private @NonNull String description; + + /** + * File name in challenges library. + */ + private @NonNull String repository; + + /** + * Language of content. + */ + private @Nullable String language; + + /** + * Desired slot number. + */ + private int slot; + + /** + * Main GameMode for which challenges were created. + */ + private @Nullable String forGameMode; + + /** + * Author (-s) who created current configuration. + */ + private @Nullable String author; + + /** + * Version of Challenges Addon, for which challenges were created. + */ + private @Nullable String version; +} \ No newline at end of file diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 10b8dec..0f18b12 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -67,6 +67,8 @@ challenges: select-entity: '&aSelect Entity' toggle-environment: '&aToggle Environment' edit-text-fields: '&aEdit Text Fields' + + library-title: '&aDownloadable Libraries' challenges: '&6Challenges' game-modes: '&6Choose GameMode' @@ -161,6 +163,8 @@ challenges: default-import: 'Import Default Challenges' default-export: 'Export Existing Challenges' complete-wipe: 'Wipe Addon Databases' + + library: 'Web Library' next: 'Next' previous: 'Previous' return: 'Return' @@ -261,6 +265,14 @@ challenges: default-import: 'Allows to import default challenges.' default-export: 'Allows to export existing challenges into defaults.json file.' complete-wipe: 'Allows to completely clear all challenges addon databases. Includes player data!' + + library: 'Opens GUI that shows all available public Challenges Libraries.' + + library-author: 'by &e[author]' + library-version: '&9Made on Challenges [version]' + library-lang: '&aLanguage: [lang]' + library-gamemode: '&aPrimary for [gamemode]' + current-value: '|&6Current value: [value].' enabled: 'Active' disabled: 'Disabled' @@ -368,6 +380,8 @@ challenges: migrate-start: '&2Start migrating challenges addon data.' migrate-end: '&2Challenges addon data is updated to new format.' migrate-not: '&2All data is valid.' + + start-downloading: '&5Starting to download and import Challenges Library.' you-completed-challenge: '&2You completed the [value] &r&2challenge!' you-repeated-challenge: '&2You repeated the [value] &r&2challenge!' you-repeated-challenge-multiple: '&2You repeated the [value] &r&2challenge [count] times!'