diff --git a/src/main/java/com/terraformersmc/modmenu/gui/widget/DescriptionListWidget.java b/src/main/java/com/terraformersmc/modmenu/gui/widget/DescriptionListWidget.java index 46c9bc76..d855979e 100644 --- a/src/main/java/com/terraformersmc/modmenu/gui/widget/DescriptionListWidget.java +++ b/src/main/java/com/terraformersmc/modmenu/gui/widget/DescriptionListWidget.java @@ -39,7 +39,6 @@ public class DescriptionListWidget extends EntryListWidget implements ResultList private static final Text HAS_UPDATE_TEXT = new TranslatableText("modmenu.hasUpdate"); private static final Text EXPERIMENTAL_TEXT = new TranslatableText("modmenu.experimental").setStyle(new Style().setColor(Formatting.GOLD)); - private static final Text MODRINTH_TEXT = new TranslatableText("modmenu.modrinth"); private static final Text DOWNLOAD_TEXT = new TranslatableText("modmenu.downloadLink").setStyle(new Style().setColor(Formatting.BLUE).setUnderlined(true)); private static final Text CHILD_HAS_UPDATE_TEXT = new TranslatableText("modmenu.childHasUpdate"); private static final Text LINKS_TEXT = new TranslatableText("modmenu.links"); @@ -129,30 +128,20 @@ public void render(int mouseX, int mouseY, float delta) { this.entries.add(new DescriptionEntry((String) line, 8)); } - if (updateInfo instanceof ModrinthUpdateInfo) { - ModrinthUpdateInfo modrinthUpdateInfo = (ModrinthUpdateInfo) updateInfo; - Text updateText = new TranslatableText("modmenu.updateText", VersionUtil.stripPrefix(modrinthUpdateInfo.getVersionNumber()), MODRINTH_TEXT) - .setStyle(new Style().setColor(Formatting.BLUE).setUnderlined(true)); - - for (Object line : textRenderer.split(updateText.getFormattedString(), wrapWidth - 16)) { - this.entries.add(new LinkEntry((String) line, modrinthUpdateInfo.getDownloadLink(), 8)); - } + Text updateMessage = updateInfo.getUpdateMessage(); + String downloadLink = updateInfo.getDownloadLink(); + if (updateMessage == null) { + updateMessage = DOWNLOAD_TEXT; } else { - Text updateMessage = updateInfo.getUpdateMessage(); - String downloadLink = updateInfo.getDownloadLink(); - if (updateMessage == null) { - updateMessage = DOWNLOAD_TEXT; - } else { - if (downloadLink != null) { - updateMessage = updateMessage.copy().setStyle(new Style().setColor(Formatting.BLUE).setUnderlined(true)); - } + if (downloadLink != null) { + updateMessage = updateMessage.copy().setStyle(new Style().setColor(Formatting.BLUE).setUnderlined(true)); } - for (Object line : textRenderer.split(updateMessage.getFormattedString(), wrapWidth - 16)) { - if (downloadLink != null) { - this.entries.add(new LinkEntry((String) line, downloadLink, 8)); - } else { - this.entries.add(new DescriptionEntry((String) line, 8)); - } + } + for (Object line : textRenderer.split(updateMessage.getFormattedString(), wrapWidth - 16)) { + if (downloadLink != null) { + this.entries.add(new LinkEntry((String) line, downloadLink, 8)); + } else { + this.entries.add(new DescriptionEntry((String) line, 8)); } } } diff --git a/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerThread.java b/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerThread.java deleted file mode 100644 index e29cb8d6..00000000 --- a/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerThread.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.terraformersmc.modmenu.util; - -import com.terraformersmc.modmenu.util.mod.Mod; - -public class UpdateCheckerThread extends Thread { - - protected UpdateCheckerThread(Mod mod, Runnable runnable) { - super(runnable); - setDaemon(true); - setName(String.format("Update Checker/%s", mod.getName())); - } - - public static void run(Mod mod, Runnable runnable) { - new UpdateCheckerThread(mod, runnable).start(); - } - -} diff --git a/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerThreadFactory.java b/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerThreadFactory.java new file mode 100644 index 00000000..e0eed63e --- /dev/null +++ b/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerThreadFactory.java @@ -0,0 +1,16 @@ +package com.terraformersmc.modmenu.util; + +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +public class UpdateCheckerThreadFactory implements ThreadFactory { + static final AtomicInteger COUNT = new AtomicInteger(-1); + + @Override + public Thread newThread(@NotNull Runnable runnable) { + int index = COUNT.incrementAndGet(); + return new Thread(runnable, "ModMenu/Update Checker/" + index); + } +} diff --git a/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerUtil.java b/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerUtil.java index 81dc4ed0..10277b36 100644 --- a/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerUtil.java +++ b/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerUtil.java @@ -19,11 +19,18 @@ import org.apache.http.util.EntityUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.net.URI; +import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; public class UpdateCheckerUtil { public static final Logger LOGGER = LogManager.getLogger("Mod Menu/Update Checker"); @@ -40,39 +47,90 @@ public static void checkForUpdates() { } LOGGER.info("Checking mod updates..."); - - CompletableFuture.runAsync(UpdateCheckerUtil::checkForModrinthUpdates); - checkForCustomUpdates(); + CompletableFuture.runAsync(UpdateCheckerUtil::checkForUpdates0); } - public static void checkForCustomUpdates() { + private static void checkForUpdates0() { + ExecutorService executor = Executors.newCachedThreadPool(new UpdateCheckerThreadFactory()); + List withoutUpdateChecker = new ArrayList<>(); + ModMenu.MODS.values().stream().filter(UpdateCheckerUtil::allowsUpdateChecks).forEach(mod -> { UpdateChecker updateChecker = mod.getUpdateChecker(); if (updateChecker == null) { - return; - } + withoutUpdateChecker.add(mod); // Fall back to update checking via Modrinth + } else { + executor.submit(() -> { + // We don't know which mod the thread is for yet in the thread factory + Thread.currentThread().setName("ModMenu/Update Checker/" + mod.getName()); - UpdateCheckerThread.run(mod, () -> { - UpdateInfo update = updateChecker.checkForUpdates(); + UpdateInfo update = updateChecker.checkForUpdates(); - if (update == null) { - return; - } + if (update == null) { + return; + } - mod.setUpdateInfo(update); - LOGGER.info("Update available for '{}@{}'", mod.getId(), mod.getVersion()); - }); + mod.setUpdateInfo(update); + LOGGER.info("Update available for '{}@{}'", mod.getId(), mod.getVersion()); + }); + } }); - } - public static void checkForModrinthUpdates() { if (modrinthApiV2Deprecated) { return; } - Map> modHashes = new HashMap<>(); - new ArrayList<>(ModMenu.MODS.values()).stream().filter(UpdateCheckerUtil::allowsUpdateChecks).filter(mod -> mod.getUpdateChecker() == null).forEach(mod -> { + Map> modHashes = getModHashes(withoutUpdateChecker); + + Future> currentVersionsFuture = executor.submit(() -> getCurrentVersions(modHashes.keySet())); + Future> updatedVersionsFuture = executor.submit(() -> getUpdatedVersions(modHashes.keySet())); + + Map currentVersions = null; + Map updatedVersions = null; + + try { + currentVersions = currentVersionsFuture.get(); + updatedVersions = updatedVersionsFuture.get(); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + if (currentVersions == null || updatedVersions == null) { + return; + } + + for (String hash : modHashes.keySet()) { + Instant date = currentVersions.get(hash); + VersionUpdate data = updatedVersions.get(hash); + + if (date == null || data == null) { + continue; + } + + // Current version is still the newest + if (Objects.equals(hash, data.hash)) { + continue; + } + + // Current version is newer than what's + // Available on our preferred update channel + if (date.compareTo(data.releaseDate) >= 0) { + continue; + } + + for (Mod mod : modHashes.get(hash)) { + mod.setUpdateInfo(data.asUpdateInfo()); + LOGGER.info("Update available for '{}@{}', (-> {})", mod.getId(), mod.getVersion(), data.versionNumber); + } + } + } + + private static Map> getModHashes(Collection mods) { + Map> results = new HashMap<>(); + + for (Mod mod : mods) { String modId = mod.getId(); try { @@ -80,18 +138,82 @@ public static void checkForModrinthUpdates() { if (hash != null) { LOGGER.debug("Hash for {} is {}", modId, hash); - modHashes.putIfAbsent(hash, new HashSet<>()); - modHashes.get(hash).add(mod); + results.putIfAbsent(hash, new HashSet<>()); + results.get(hash).add(mod); } } catch (IOException e) { LOGGER.error("Error getting mod hash for mod {}: ", modId, e); } - }); + } - List loaders = ModMenu.runningQuilt ? Arrays.asList("fabric", "quilt") : Arrays.asList("fabric"); + return results; + } + + /** + * @return a map of file hash to its release date on Modrinth. + */ + private static @Nullable Map getCurrentVersions(Collection modHashes) { + String body = ModMenu.GSON_MINIFIED.toJson(new CurrentVersionsFromHashes(modHashes)); + + try { + RequestBuilder request = RequestBuilder.post() + .setEntity(new StringEntity(body)) + .addHeader("Content-Type", "application/json") + .setUri(URI.create("https://api.modrinth.com/v2/version_files")); + + HttpResponse response = HttpUtil.request(request); + int status = response.getStatusLine().getStatusCode(); + + if (status == 410) { + modrinthApiV2Deprecated = true; + LOGGER.warn("Modrinth API v2 is deprecated, unable to check for mod updates."); + } else if (status == 200) { + Map results = new HashMap<>(); + JsonObject data = new JsonParser().parse(EntityUtils.toString(response.getEntity())).getAsJsonObject(); + + data.entrySet().forEach((Map.Entry entry) -> { + Instant date; + JsonObject version = entry.getValue().getAsJsonObject(); + + try { + date = Instant.parse(version.get("date_published").getAsString()); + } catch (DateTimeParseException e) { + return; + } + + results.put(entry.getKey(), date); + }); + + return results; + } + } catch (IOException e) { + LOGGER.error("Error checking for versions: ", e); + } + return null; + } + + public static class CurrentVersionsFromHashes { + public Collection hashes; + public String algorithm = "sha512"; + + public CurrentVersionsFromHashes(Collection hashes) { + this.hashes = hashes; + } + } + + private static UpdateChannel getUpdateChannel(String versionType) { + try { + return UpdateChannel.valueOf(versionType.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException | NullPointerException e) { + return UpdateChannel.RELEASE; + } + } + + private static @Nullable Map getUpdatedVersions(Collection modHashes) { String mcVer = FabricLoader.getInstance().getModContainer("minecraft").get() .getMetadata().getVersion().getFriendlyString(); + List loaders = ModMenu.runningQuilt ? Arrays.asList("fabric", "quilt") : Arrays.asList("fabric"); List updateChannels; UpdateChannel preferredChannel = UpdateChannel.getUserPreference(); @@ -104,7 +226,7 @@ public static void checkForModrinthUpdates() { updateChannels = Arrays.asList(UpdateChannel.ALPHA, UpdateChannel.BETA, UpdateChannel.RELEASE); } - String body = ModMenu.GSON_MINIFIED.toJson(new LatestVersionsFromHashesBody(modHashes.keySet(), loaders, mcVer, updateChannels)); + String body = ModMenu.GSON_MINIFIED.toJson(new LatestVersionsFromHashesBody(modHashes, loaders, mcVer, updateChannels)); LOGGER.debug("Body: " + body); @@ -114,15 +236,16 @@ public static void checkForModrinthUpdates() { .addHeader("Content-Type", "application/json") .setUri(URI.create("https://api.modrinth.com/v2/version_files/update")); - HttpResponse latestVersionsResponse = HttpUtil.request(latestVersionsRequest); + HttpResponse response = HttpUtil.request(latestVersionsRequest); - int status = latestVersionsResponse.getStatusLine().getStatusCode(); + int status = response.getStatusLine().getStatusCode(); LOGGER.debug("Status: " + status); if (status == 410) { modrinthApiV2Deprecated = true; LOGGER.warn("Modrinth API v2 is deprecated, unable to check for mod updates."); } else if (status == 200) { - JsonObject responseObject = new JsonParser().parse(EntityUtils.toString(latestVersionsResponse.getEntity())).getAsJsonObject(); + Map results = new HashMap<>(); + JsonObject responseObject = new JsonParser().parse(EntityUtils.toString(response.getEntity())).getAsJsonObject(); LOGGER.debug(String.valueOf(responseObject)); responseObject.entrySet().forEach(entry -> { String lookupHash = entry.getKey(); @@ -140,28 +263,48 @@ public static void checkForModrinthUpdates() { return; } + Instant date; + + try { + date = Instant.parse(versionObj.get("date_published").getAsString()); + } catch (DateTimeParseException e) { + return; + } + UpdateChannel updateChannel = UpdateCheckerUtil.getUpdateChannel(versionType); String versionHash = primaryFile.get().getAsJsonObject().get("hashes").getAsJsonObject().get("sha512").getAsString(); - if (!Objects.equals(versionHash, lookupHash)) { - // hashes different, there's an update. - modHashes.get(lookupHash).forEach(mod -> { - LOGGER.info("Update available for '{}@{}', (-> {})", mod.getId(), mod.getVersion(), versionNumber); - mod.setUpdateInfo(new ModrinthUpdateInfo(projectId, versionId, versionNumber, updateChannel)); - }); - } + results.put(lookupHash, new VersionUpdate(projectId, versionId, versionNumber, date, updateChannel, versionHash)); }); + + return results; } } catch (IOException e) { LOGGER.error("Error checking for updates: ", e); } + + return null; } - private static UpdateChannel getUpdateChannel(String versionType) { - try { - return UpdateChannel.valueOf(versionType.toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException | NullPointerException e) { - return UpdateChannel.RELEASE; + private static class VersionUpdate { + String projectId; + String versionId; + String versionNumber; + Instant releaseDate; + UpdateChannel updateChannel; + String hash; + + public VersionUpdate(String projectId, String versionId, String versionNumber, Instant releaseDate, UpdateChannel updateChannel, String has) { + this.projectId = projectId; + this.versionId = versionId; + this.versionNumber = versionNumber; + this.releaseDate = releaseDate; + this.updateChannel = updateChannel; + this.hash = has; + } + + private UpdateInfo asUpdateInfo() { + return new ModrinthUpdateInfo(this.projectId, this.versionId, this.versionNumber, this.updateChannel); } } diff --git a/src/main/java/com/terraformersmc/modmenu/util/mod/ModrinthUpdateInfo.java b/src/main/java/com/terraformersmc/modmenu/util/mod/ModrinthUpdateInfo.java index 254aa4f6..ae542f32 100644 --- a/src/main/java/com/terraformersmc/modmenu/util/mod/ModrinthUpdateInfo.java +++ b/src/main/java/com/terraformersmc/modmenu/util/mod/ModrinthUpdateInfo.java @@ -1,7 +1,13 @@ package com.terraformersmc.modmenu.util.mod; +import org.jetbrains.annotations.Nullable; + import com.terraformersmc.modmenu.api.UpdateChannel; import com.terraformersmc.modmenu.api.UpdateInfo; +import com.terraformersmc.modmenu.util.VersionUtil; + +import net.minecraft.text.Text; +import net.minecraft.text.TranslatableText; public class ModrinthUpdateInfo implements UpdateInfo { protected final String projectId; @@ -9,6 +15,8 @@ public class ModrinthUpdateInfo implements UpdateInfo { protected final String versionNumber; protected final UpdateChannel updateChannel; + private static final Text MODRINTH_TEXT = new TranslatableText("modmenu.modrinth"); + public ModrinthUpdateInfo(String projectId, String versionId, String versionNumber, UpdateChannel updateChannel) { this.projectId = projectId; this.versionId = versionId; @@ -21,6 +29,11 @@ public boolean isUpdateAvailable() { return true; } + @Override + public @Nullable Text getUpdateMessage() { + return new TranslatableText("modmenu.updateText", VersionUtil.stripPrefix(this.versionNumber), MODRINTH_TEXT); + } + @Override public String getDownloadLink() { return String.format("https://modrinth.com/project/%s/version/%s", projectId, versionId); diff --git a/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricLoaderUpdateChecker.java b/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricLoaderUpdateChecker.java index 726b842f..54a4262c 100644 --- a/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricLoaderUpdateChecker.java +++ b/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricLoaderUpdateChecker.java @@ -10,6 +10,7 @@ import org.apache.http.util.EntityUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -25,6 +26,8 @@ import net.fabricmc.loader.api.SemanticVersion; import net.fabricmc.loader.api.Version; import net.fabricmc.loader.api.VersionParsingException; +import net.minecraft.text.Text; +import net.minecraft.text.TranslatableText; public class FabricLoaderUpdateChecker implements UpdateChecker { public static final Logger LOGGER = LogManager.getLogger("Mod Menu/Fabric Update Checker"); @@ -116,7 +119,7 @@ private static UpdateInfo checkForUpdates0() throws IOException, InterruptedExce } LOGGER.debug("Fabric Loader has a matching update available!"); - return new FabricLoaderUpdateInfo(stableVersion); + return new FabricLoaderUpdateInfo(match.getFriendlyString(), stableVersion); } private static boolean isNewer(Version self, Version other) { @@ -128,9 +131,11 @@ private static Version getCurrentVersion() { } private static class FabricLoaderUpdateInfo implements UpdateInfo { + private final String version; private final boolean isStable; - private FabricLoaderUpdateInfo(boolean isStable) { + private FabricLoaderUpdateInfo(String version, boolean isStable) { + this.version = version; this.isStable = isStable; } @@ -139,6 +144,11 @@ public boolean isUpdateAvailable() { return true; } + @Override + public @Nullable Text getUpdateMessage() { + return new TranslatableText("modmenu.install_version", this.version); + } + @Override public String getDownloadLink() { return "https://fabricmc.net/use/installer"; diff --git a/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricMod.java b/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricMod.java index f801810f..d9ab0acc 100644 --- a/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricMod.java +++ b/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricMod.java @@ -21,7 +21,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.lwjgl.system.CallbackI.V; import java.io.File; import java.io.IOException; diff --git a/src/main/java/com/terraformersmc/modmenu/util/mod/quilt/QuiltLoaderUpdateChecker.java b/src/main/java/com/terraformersmc/modmenu/util/mod/quilt/QuiltLoaderUpdateChecker.java index 35dee024..0e006de8 100644 --- a/src/main/java/com/terraformersmc/modmenu/util/mod/quilt/QuiltLoaderUpdateChecker.java +++ b/src/main/java/com/terraformersmc/modmenu/util/mod/quilt/QuiltLoaderUpdateChecker.java @@ -10,6 +10,7 @@ import org.apache.http.util.EntityUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; import org.quiltmc.loader.api.QuiltLoader; import org.quiltmc.loader.api.Version; import org.quiltmc.loader.api.VersionFormatException; @@ -23,6 +24,9 @@ import com.terraformersmc.modmenu.util.HttpUtil; import com.terraformersmc.modmenu.util.JsonUtil; +import net.minecraft.text.Text; +import net.minecraft.text.TranslatableText; + public class QuiltLoaderUpdateChecker implements UpdateChecker { public static final Logger LOGGER = LogManager.getLogger("Mod Menu/Quilt Update Checker"); private static final URI LOADER_VERSIONS = URI.create("https://meta.quiltmc.org/v3/versions/loader"); @@ -110,19 +114,7 @@ private static UpdateInfo checkForUpdates0() throws IOException, InterruptedExce } LOGGER.debug("Quilt Loader has a matching update available!"); - - UpdateChannel updateChannel; - String preRelease = match.preRelease(); - - if (preRelease.isEmpty()) { - updateChannel = UpdateChannel.RELEASE; - } else if (isStableOrBeta(preRelease)) { - updateChannel = UpdateChannel.BETA; - } else { - updateChannel = UpdateChannel.ALPHA; - } - - return new QuiltLoaderUpdateInfo(updateChannel); + return new QuiltLoaderUpdateInfo(match); } private static boolean isNewer(Version.Semantic self, Version.Semantic other) { @@ -138,10 +130,10 @@ private static boolean isStableOrBeta(String preRelease) { } private static class QuiltLoaderUpdateInfo implements UpdateInfo { - private final UpdateChannel updateChannel; + private final Version.Semantic version; - private QuiltLoaderUpdateInfo(UpdateChannel updateChannel) { - this.updateChannel = updateChannel; + private QuiltLoaderUpdateInfo(Version.Semantic version) { + this.version = version; } @Override @@ -149,6 +141,11 @@ public boolean isUpdateAvailable() { return true; } + @Override + public @Nullable Text getUpdateMessage() { + return new TranslatableText("modmenu.install_version", this.version.raw()); + } + @Override public String getDownloadLink() { return "https://quiltmc.org/en/install/client"; @@ -156,7 +153,15 @@ public String getDownloadLink() { @Override public UpdateChannel getUpdateChannel() { - return this.updateChannel; + String preRelease = this.version.preRelease(); + + if (preRelease.isEmpty()) { + return UpdateChannel.RELEASE; + } else if (isStableOrBeta(preRelease)) { + return UpdateChannel.BETA; + } else { + return UpdateChannel.ALPHA; + } } } } diff --git a/src/main/resources/assets/modmenu/lang/en_US.lang b/src/main/resources/assets/modmenu/lang/en_US.lang index 60ccd58b..e88bad19 100644 --- a/src/main/resources/assets/modmenu/lang/en_US.lang +++ b/src/main/resources/assets/modmenu/lang/en_US.lang @@ -3,6 +3,7 @@ key.modmenu.open_menu=Open Mod Menu modmenu.title=Mods modmenu.nameTranslation.modmenu=Mod Menu modmenu.descriptionTranslation.modmenu=Adds a mod menu to view the list of mods you have installed. + modmenu.loaded=(%s Loaded) modmenu.loaded.short=(%s) modmenu.loaded.69.secret=(%s Loaded...nice) @@ -11,6 +12,7 @@ modmenu.mods.n= (%s Mods) modmenu.mods.1= (%s Mod) modmenu.mods.69.secret= (%s Mods...nice) modmenu.mods.420.secret= (%s Mods...blaze it) + modmenu.search=Search for mods modmenu.searchTerms.library=api library modmenu.searchTerms.patchwork=patchwork forge fml @@ -19,6 +21,7 @@ modmenu.searchTerms.deprecated=deprecated outdated old modmenu.searchTerms.clientside=clientside gameside modmenu.searchTerms.configurable=configurations configs configures configurable options settings modmenu.searchTerms.hasUpdate=updates version + modmenu.toggleFilterOptions=Toggle Filter Options modmenu.showingMods.n=Showing %s mods modmenu.showingMods.1=Showing %s mod @@ -28,25 +31,28 @@ modmenu.showingModsLibraries.n.n=Showing %s mods and %s libraries modmenu.showingModsLibraries.n.1=Showing %s mods and %s library modmenu.showingModsLibraries.1.n=Showing %s mod and %s libraries modmenu.showingModsLibraries.1.1=Showing %s mod and %s library + modmenu.badge.library=Library modmenu.badge.clientsideOnly=Client modmenu.badge.deprecated=Deprecated modmenu.badge.forge=Forge modmenu.badge.modpack=Modpack modmenu.badge.minecraft=Minecraft + modmenu.dropInfo.line1=Drag and drop files into modmenu.dropInfo.line2=this window to add mods modmenu.dropConfirm=Do you want to copy the following mods into the mods directory? modmenu.dropSuccessful.line1=Successfully copied mods modmenu.dropSuccessful.line2=Restart game to load mods + modmenu.modIdToolTip=Mod ID: %s modmenu.authorPrefix=By %s modmenu.config=Edit Config modmenu.configure=Configure... -modmenu.configure.error=Failed to load config screen for '%s' -Report to '%s', not Mod Menu +modmenu.configure.error=Failed to load config screen for '%s'\nReport to '%s', not Mod Menu modmenu.website=Website modmenu.issues=Issues + modmenu.credits=Credits: modmenu.viewCredits=View Credits modmenu.license=License: @@ -56,7 +62,9 @@ modmenu.hasUpdate=An update is available: modmenu.experimental=(Mod Menu update checker is experimental!) modmenu.childHasUpdate=A child of this mod has an update available. modmenu.updateText=v%s on %s +modmenu.install_version=Install version %s modmenu.downloadLink=Download + modmenu.buymeacoffee=Buy Me a Coffee modmenu.coindrop=Coindrop modmenu.crowdin=Crowdin @@ -78,6 +86,7 @@ modmenu.twitch=Twitch modmenu.twitter=X (Twitter) modmenu.wiki=Wiki modmenu.youtube=YouTube + modmenu.credits.role.author=Authors modmenu.credits.role.contributor=Contributors modmenu.credits.role.translator=Translators @@ -85,13 +94,16 @@ modmenu.credits.role.maintainer=Maintainers modmenu.credits.role.playtester=Playtesters modmenu.credits.role.illustrator=Illustrators modmenu.credits.role.owner=Owners + modmenu.modsFolder=Open Mods Folder modmenu.configsFolder=Open Configs Folder + modmenu.nameTranslation.minecraft=Minecraft modmenu.descriptionTranslation.minecraft=The base game. modmenu.nameTranslation.java=Java modmenu.descriptionTranslation.java=The Java runtime environment. modmenu.javaDistributionName=Running: %s + modmenu.options=Mod Menu Options option.value_label=%s: %s option.modmenu.sorting=Sort