commit 7de4b57baa64176fa15113d3af58d3e51eea1595 Author: River Date: Sun Aug 23 15:19:49 2026 +0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f27282d --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Gradle +.gradle/ +build/ + +# IDEs +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.classpath +.project +.settings/ +bin/ +eclipse/ + +# Run directories (dev server/client data) +run/ +run-client/ +run-server/ +run-data/ +runs/ + +# Logs and dumps +*.log +crash-reports/ + +# OS junk +Thumbs.db +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb8502e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rechka + +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. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..edb9504 --- /dev/null +++ b/build.gradle @@ -0,0 +1,64 @@ +plugins { + id 'java-library' + id 'idea' + id 'net.neoforged.moddev' version '2.0.144' +} + +version = mod_version +group = mod_group_id + +base { + archivesName = mod_id +} + +repositories { + mavenCentral() +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + version = project.neo_version + + runs { + client { + client() + systemProperty 'forge.enabledGameTestNamespaces', mod_id + } + server { + server() + programArgument '--nogui' + systemProperty 'forge.enabledGameTestNamespaces', mod_id + } + } + + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } +} + +tasks.withType(ProcessResources).configureEach { + var replaceProperties = [ + minecraft_version : minecraft_version, + minecraft_version_range: minecraft_version_range, + neo_version : neo_version, + neo_version_range : neo_version_range, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : mod_version, + mod_authors : mod_authors, + mod_description : mod_description + ] + inputs.properties replaceProperties + filesMatching(['META-INF/neoforge.mods.toml']) { + expand replaceProperties + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..1413ebe --- /dev/null +++ b/gradle.properties @@ -0,0 +1,17 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.parallel=true +org.gradle.caching=true + +minecraft_version=1.21.1 +minecraft_version_range=[1.21.1,1.22) +neo_version=21.1.172 +neo_version_range=[21.1,) +loader_version_range=[1,) + +mod_id=chattranslator +mod_name=Chat Translator +mod_license=MIT +mod_version=1.0.0 +mod_group_id=dev.rechka +mod_authors=Rechka +mod_description=Translates chat messages to each player's language automatically. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..61b77d7 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..16e26a1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..83ba414 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { url = 'https://maven.neoforged.net/releases' } + gradlePluginPortal() + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.9.0' +} + +rootProject.name = 'chattranslator' diff --git a/src/main/java/dev/rechka/chattranslator/ChatTranslator.java b/src/main/java/dev/rechka/chattranslator/ChatTranslator.java new file mode 100644 index 0000000..ad1b9d0 --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/ChatTranslator.java @@ -0,0 +1,37 @@ +package dev.rechka.chattranslator; + +import com.mojang.logging.LogUtils; +import dev.rechka.chattranslator.command.TranslateCommand; +import dev.rechka.chattranslator.config.TranslatorConfig; +import dev.rechka.chattranslator.server.ChatEventHandler; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import org.slf4j.Logger; + +@Mod(ChatTranslator.MOD_ID) +public class ChatTranslator { + + public static final String MOD_ID = "chattranslator"; + public static final Logger LOGGER = LogUtils.getLogger(); + + public ChatTranslator(IEventBus modEventBus, ModContainer modContainer) { + TranslatorConfig.init(modContainer); + + modEventBus.addListener(this::commonSetup); + + NeoForge.EVENT_BUS.addListener(this::onRegisterCommands); + NeoForge.EVENT_BUS.register(new ChatEventHandler()); + } + + private void commonSetup(FMLCommonSetupEvent event) { + LOGGER.info("[ChatTranslator] Common setup complete."); + } + + private void onRegisterCommands(RegisterCommandsEvent event) { + TranslateCommand.register(event.getDispatcher()); + } +} diff --git a/src/main/java/dev/rechka/chattranslator/client/ClientPayloadHandlers.java b/src/main/java/dev/rechka/chattranslator/client/ClientPayloadHandlers.java new file mode 100644 index 0000000..ba6d6a2 --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/client/ClientPayloadHandlers.java @@ -0,0 +1,38 @@ +package dev.rechka.chattranslator.client; + +import dev.rechka.chattranslator.network.S2CTranslatedMessagePacket; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; + +/** + * Client-only payload handling. FML strips {@code @OnlyIn} members on the + * dedicated server, so this class must never be referenced from common code + * except inside lazily-loaded lambdas. + */ +@OnlyIn(Dist.CLIENT) +public final class ClientPayloadHandlers { + + private ClientPayloadHandlers() { + } + + /** Must be called on the main/render thread. */ + public static void handleTranslatedMessage(S2CTranslatedMessagePacket pkt) { + Minecraft mc = Minecraft.getInstance(); + if (mc.gui == null || mc.player == null) return; + + String header = "\u00A77<\u00A7r" + pkt.senderName() + "\u00A77>\u00A7r "; + Component msg; + + if (pkt.originalText().isEmpty() || pkt.originalText().equals(pkt.translatedText())) { + msg = Component.literal(header + pkt.translatedText()); + } else { + String line1 = header + pkt.translatedText(); + String line2 = "\u00A78\u00A7o [" + pkt.originalText() + "]\u00A7r"; + msg = Component.literal(line1 + "\n" + line2); + } + + mc.gui.getChat().addMessage(msg); + } +} diff --git a/src/main/java/dev/rechka/chattranslator/client/ClientSetup.java b/src/main/java/dev/rechka/chattranslator/client/ClientSetup.java new file mode 100644 index 0000000..b84b2a5 --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/client/ClientSetup.java @@ -0,0 +1,52 @@ +package dev.rechka.chattranslator.client; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.rechka.chattranslator.ChatTranslator; +import dev.rechka.chattranslator.client.gui.TranslatorScreen; +import dev.rechka.chattranslator.network.C2SPlayerLocalePacket; +import dev.rechka.chattranslator.network.PacketHandler; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; +import net.neoforged.neoforge.client.event.InputEvent; +import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent; +import org.lwjgl.glfw.GLFW; + +@EventBusSubscriber(modid = ChatTranslator.MOD_ID, bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT) +public class ClientSetup { + + public static KeyMapping OPEN_GUI_KEY; + + @SubscribeEvent + public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) { + OPEN_GUI_KEY = new KeyMapping( + "chattranslator.keybind.open_gui", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_UNKNOWN, + "key.categories.chattranslator" + ); + event.register(OPEN_GUI_KEY); + } +} + +@EventBusSubscriber(modid = ChatTranslator.MOD_ID, bus = EventBusSubscriber.Bus.GAME, value = Dist.CLIENT) +class ClientGameEvents { + + @SubscribeEvent + public static void onPlayerJoin(ClientPlayerNetworkEvent.LoggingIn event) { + Minecraft mc = Minecraft.getInstance(); + String locale = mc.getLanguageManager().getSelected(); + PacketHandler.sendToServer(new C2SPlayerLocalePacket(locale)); + } + + @SubscribeEvent + public static void onKeyInput(InputEvent.Key event) { + Minecraft mc = Minecraft.getInstance(); + if (ClientSetup.OPEN_GUI_KEY.consumeClick() && mc.screen == null) { + mc.setScreen(new TranslatorScreen()); + } + } +} diff --git a/src/main/java/dev/rechka/chattranslator/client/gui/TranslatorScreen.java b/src/main/java/dev/rechka/chattranslator/client/gui/TranslatorScreen.java new file mode 100644 index 0000000..f6d26bf --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/client/gui/TranslatorScreen.java @@ -0,0 +1,190 @@ +package dev.rechka.chattranslator.client.gui; + +import dev.rechka.chattranslator.config.TranslatorConfig; +import dev.rechka.chattranslator.network.C2SPlayerLocalePacket; +import dev.rechka.chattranslator.network.PacketHandler; +import dev.rechka.chattranslator.server.TranslationService; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; + +import java.util.List; + +@OnlyIn(Dist.CLIENT) +public class TranslatorScreen extends Screen { + + private static final int PANEL_W = 320; + private static final int PANEL_H = 230; + private static final int ROW_H = 24; + private static final int FIELD_W = 200; + + private String selectedLocale; + private String statusMessage = ""; + private int statusColor = 0xAAAAAA; + + private CycleButton languageButton; + private EditBox urlField; + private EditBox apiKeyField; + private CycleButton showOriginalButton; + private Button testButton; + private Button saveButton; + private Button cancelButton; + + // LibreTranslate-supported source languages + private static final List LOCALES = List.of( + "af", "sq", "am", "ar", "az", "be", "bn", "bg", "ca", "zh", "hr", + "cs", "da", "nl", "en", "eo", "et", "fi", "fr", "gl", "ka", "de", + "el", "gu", "he", "hi", "hu", "is", "id", "ga", "it", "ja", "kn", + "kk", "ko", "ky", "lv", "lt", "mk", "ms", "ml", "mr", "mn", "ne", + "nb", "fa", "pl", "pt", "ro", "ru", "sr", "sk", "sl", "es", "sw", + "sv", "tl", "ta", "te", "th", "tr", "uk", "ur", "uz", "vi", "cy" + ); + + public TranslatorScreen() { + super(Component.translatable("chattranslator.gui.title")); + String full = Minecraft.getInstance().getLanguageManager().getSelected(); // e.g. "ru_ru" + selectedLocale = full.length() >= 2 ? full.substring(0, 2) : "en"; + } + + @Override + protected void init() { + super.init(); + int cx = this.width / 2; + int cy = this.height / 2; + int left = cx - PANEL_W / 2 + 10; + int top = cy - PANEL_H / 2 + 30; + + languageButton = CycleButton.builder(lang -> Component.literal(lang.toUpperCase())) + .withValues(LOCALES) + .withInitialValue(LOCALES.contains(selectedLocale) ? selectedLocale : "en") + .create(left, top, FIELD_W, 20, + Component.translatable("chattranslator.gui.language"), + (btn, val) -> selectedLocale = val); + addRenderableWidget(languageButton); + top += ROW_H + 4; + + urlField = new EditBox(this.font, left, top, FIELD_W, 20, + Component.translatable("chattranslator.gui.libretranslate_url")); + urlField.setMaxLength(256); + urlField.setValue(TranslatorConfig.LIBRETRANSLATE_URL.get()); + addRenderableWidget(urlField); + top += ROW_H + 4; + + apiKeyField = new EditBox(this.font, left, top, FIELD_W, 20, + Component.translatable("chattranslator.gui.api_key")); + apiKeyField.setMaxLength(128); + apiKeyField.setValue(TranslatorConfig.API_KEY.get()); + addRenderableWidget(apiKeyField); + top += ROW_H + 4; + + showOriginalButton = CycleButton.onOffBuilder(TranslatorConfig.SHOW_ORIGINAL.get()) + .create(left, top, FIELD_W, 20, + Component.translatable("chattranslator.gui.show_original"), + (btn, val) -> { }); + addRenderableWidget(showOriginalButton); + top += ROW_H + 4; + + testButton = Button.builder( + Component.translatable("chattranslator.gui.test"), + btn -> doTest() + ).bounds(left, top, 90, 20).build(); + addRenderableWidget(testButton); + + int btnY = cy + PANEL_H / 2 - 30; + saveButton = Button.builder( + Component.translatable("chattranslator.gui.save"), + btn -> doSave() + ).bounds(cx - 105, btnY, 100, 20).build(); + addRenderableWidget(saveButton); + + cancelButton = Button.builder( + Component.translatable("chattranslator.gui.cancel"), + btn -> this.onClose() + ).bounds(cx + 5, btnY, 100, 20).build(); + addRenderableWidget(cancelButton); + } + + private void doTest() { + statusMessage = "Testing..."; + statusColor = 0xFFFFAA; + TranslationService.testConnection(urlField.getValue(), apiKeyField.getValue()).thenAccept(result -> + // Result arrives on a worker thread — hop back to the render thread + Minecraft.getInstance().execute(() -> { + if (result.ok()) { + statusMessage = "\u2714 Connection OK"; + statusColor = 0x55FF55; + } else { + statusMessage = "\u2718 " + result.detail(); + statusColor = 0xFF5555; + } + }) + ); + } + + private void doSave() { + // Server config is editable here only in singleplayer; dedicated servers + // use config/chattranslator-server.toml or /chattranslate seturl + TranslatorConfig.LIBRETRANSLATE_URL.set(urlField.getValue().trim()); + TranslatorConfig.API_KEY.set(apiKeyField.getValue().trim()); + TranslatorConfig.SHOW_ORIGINAL.set(showOriginalButton.getValue()); + TranslatorConfig.SERVER_SPEC.save(); + + PacketHandler.sendToServer(new C2SPlayerLocalePacket(selectedLocale)); + TranslationService.invalidateCache(); + + this.onClose(); + } + + @Override + public void render(GuiGraphics gfx, int mouseX, int mouseY, float partialTick) { + // Screen.render paints renderBackground() first, then all widgets on top. + super.render(gfx, mouseX, mouseY, partialTick); + } + + /** + * The panel must be drawn here: in 1.21 vanilla paints a blurred background + * inside Screen.render, which covers anything drawn before it. + */ + @Override + public void renderBackground(GuiGraphics gfx, int mouseX, int mouseY, float partialTick) { + super.renderBackground(gfx, mouseX, mouseY, partialTick); + + int cx = this.width / 2; + int cy = this.height / 2; + int px = cx - PANEL_W / 2; + int py = cy - PANEL_H / 2; + + gfx.fill(px, py, px + PANEL_W, py + PANEL_H, 0xE0101010); + gfx.renderOutline(px, py, PANEL_W, PANEL_H, 0xFF444466); + + gfx.drawCenteredString(this.font, + Component.translatable("chattranslator.gui.title"), + cx, py + 8, 0xFFFFFF); + + gfx.drawString(this.font, + Component.translatable("chattranslator.gui.language"), + languageButton.getX(), languageButton.getY() - 11, 0xAAAAAA); + gfx.drawString(this.font, + Component.translatable("chattranslator.gui.libretranslate_url"), + urlField.getX(), urlField.getY() - 11, 0xAAAAAA); + gfx.drawString(this.font, + Component.translatable("chattranslator.gui.api_key"), + apiKeyField.getX(), apiKeyField.getY() - 11, 0xAAAAAA); + + if (!statusMessage.isEmpty()) { + gfx.drawCenteredString(this.font, statusMessage, cx, + testButton.getY() + 26, statusColor); + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/dev/rechka/chattranslator/command/TranslateCommand.java b/src/main/java/dev/rechka/chattranslator/command/TranslateCommand.java new file mode 100644 index 0000000..5d119ef --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/command/TranslateCommand.java @@ -0,0 +1,94 @@ +package dev.rechka.chattranslator.command; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import dev.rechka.chattranslator.config.TranslatorConfig; +import dev.rechka.chattranslator.server.TranslationService; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; + +public class TranslateCommand { + + public static void register(CommandDispatcher dispatcher) { + LiteralArgumentBuilder root = Commands.literal("chattranslate") + .requires(src -> src.hasPermission(2)); + + root.then(Commands.literal("status") + .executes(ctx -> { + boolean en = TranslatorConfig.SERVER_ENABLED.get(); + ctx.getSource().sendSuccess(() -> Component.literal( + "[ChatTranslator] Status: " + (en ? "§aENABLED" : "§cDISABLED") + + " | URL: " + TranslatorConfig.LIBRETRANSLATE_URL.get() + ), false); + return 1; + }) + ); + + root.then(Commands.literal("enable").executes(ctx -> { + TranslatorConfig.SERVER_ENABLED.set(true); + TranslatorConfig.SERVER_SPEC.save(); + ctx.getSource().sendSuccess(() -> Component.literal("[ChatTranslator] §aEnabled."), true); + return 1; + })); + root.then(Commands.literal("disable").executes(ctx -> { + TranslatorConfig.SERVER_ENABLED.set(false); + TranslatorConfig.SERVER_SPEC.save(); + ctx.getSource().sendSuccess(() -> Component.literal("[ChatTranslator] §cDisabled."), true); + return 1; + })); + + root.then(Commands.literal("seturl") + .then(Commands.argument("url", StringArgumentType.greedyString()) + .executes(ctx -> { + String url = StringArgumentType.getString(ctx, "url").trim(); + TranslatorConfig.LIBRETRANSLATE_URL.set(url); + TranslatorConfig.SERVER_SPEC.save(); + TranslationService.invalidateCache(); + ctx.getSource().sendSuccess(() -> Component.literal( + "[ChatTranslator] LibreTranslate URL set to: §e" + url + ), true); + return 1; + }) + ) + ); + + root.then(Commands.literal("setkey") + .then(Commands.argument("key", StringArgumentType.greedyString()) + .executes(ctx -> { + String key = StringArgumentType.getString(ctx, "key").trim(); + TranslatorConfig.API_KEY.set(key); + TranslatorConfig.SERVER_SPEC.save(); + ctx.getSource().sendSuccess(() -> Component.literal( + "[ChatTranslator] API key updated." + ), false); + return 1; + }) + ) + ); + + root.then(Commands.literal("clearcache").executes(ctx -> { + TranslationService.invalidateCache(); + ctx.getSource().sendSuccess(() -> Component.literal("[ChatTranslator] Cache cleared."), false); + return 1; + })); + + root.then(Commands.literal("showoriginal") + .then(Commands.literal("true").executes(ctx -> { + TranslatorConfig.SHOW_ORIGINAL.set(true); + TranslatorConfig.SERVER_SPEC.save(); + ctx.getSource().sendSuccess(() -> Component.literal("[ChatTranslator] Show original: §atrue"), true); + return 1; + })) + .then(Commands.literal("false").executes(ctx -> { + TranslatorConfig.SHOW_ORIGINAL.set(false); + TranslatorConfig.SERVER_SPEC.save(); + ctx.getSource().sendSuccess(() -> Component.literal("[ChatTranslator] Show original: §cfalse"), true); + return 1; + })) + ); + + dispatcher.register(root); + } +} diff --git a/src/main/java/dev/rechka/chattranslator/config/TranslatorConfig.java b/src/main/java/dev/rechka/chattranslator/config/TranslatorConfig.java new file mode 100644 index 0000000..3085a61 --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/config/TranslatorConfig.java @@ -0,0 +1,58 @@ +package dev.rechka.chattranslator.config; + +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.config.ModConfig; +import net.neoforged.neoforge.common.ModConfigSpec; + +public class TranslatorConfig { + + public static final ModConfigSpec SERVER_SPEC; + public static ModConfigSpec.BooleanValue SERVER_ENABLED; + public static ModConfigSpec.ConfigValue LIBRETRANSLATE_URL; + public static ModConfigSpec.ConfigValue API_KEY; + public static ModConfigSpec.BooleanValue SHOW_ORIGINAL; + public static ModConfigSpec.BooleanValue AUTODETECT_LANGUAGE; + public static ModConfigSpec.IntValue CACHE_MINUTES; + public static ModConfigSpec.IntValue REQUEST_TIMEOUT_MS; + + static { + ModConfigSpec.Builder serverBuilder = new ModConfigSpec.Builder(); + + serverBuilder.comment("Chat Translator — Server Settings").push("server"); + + SERVER_ENABLED = serverBuilder + .comment("Enable chat translation globally") + .define("enabled", true); + + LIBRETRANSLATE_URL = serverBuilder + .comment("LibreTranslate instance URL") + .define("libretranslate_url", "https://translate.argosopentech.com"); + + API_KEY = serverBuilder + .comment("API key for LibreTranslate (leave empty if not required)") + .define("api_key", ""); + + SHOW_ORIGINAL = serverBuilder + .comment("Append original message below the translation") + .define("show_original", true); + + AUTODETECT_LANGUAGE = serverBuilder + .comment("Detect each message's language via LibreTranslate /detect instead of trusting the sender's client language") + .define("autodetect_language", false); + + CACHE_MINUTES = serverBuilder + .comment("How long to cache translations and detections (minutes)") + .defineInRange("cache_minutes", 30, 1, 1440); + + REQUEST_TIMEOUT_MS = serverBuilder + .comment("HTTP request timeout in milliseconds") + .defineInRange("request_timeout_ms", 5000, 500, 30000); + + serverBuilder.pop(); + SERVER_SPEC = serverBuilder.build(); + } + + public static void init(ModContainer container) { + container.registerConfig(ModConfig.Type.SERVER, SERVER_SPEC); + } +} diff --git a/src/main/java/dev/rechka/chattranslator/network/C2SPlayerLocalePacket.java b/src/main/java/dev/rechka/chattranslator/network/C2SPlayerLocalePacket.java new file mode 100644 index 0000000..f96469a --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/network/C2SPlayerLocalePacket.java @@ -0,0 +1,37 @@ +package dev.rechka.chattranslator.network; + +import dev.rechka.chattranslator.ChatTranslator; +import dev.rechka.chattranslator.server.PlayerLocaleCache; +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.network.handling.IPayloadContext; + +public record C2SPlayerLocalePacket(String locale) implements CustomPacketPayload { + + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(ResourceLocation.fromNamespaceAndPath(ChatTranslator.MOD_ID, "player_locale")); + + public static final StreamCodec STREAM_CODEC = + ByteBufCodecs.STRING_UTF8.map(C2SPlayerLocalePacket::new, C2SPlayerLocalePacket::locale); + + @Override + public CustomPacketPayload.Type type() { + return TYPE; + } + + public static void handle(C2SPlayerLocalePacket packet, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + if (ctx.player() instanceof ServerPlayer sp) { + String locale = packet.locale(); + if (locale != null && locale.length() >= 2 && locale.length() <= 16) { + PlayerLocaleCache.set(sp.getUUID(), locale); + ChatTranslator.LOGGER.debug("[ChatTranslator] {} locale: {}", sp.getName().getString(), locale); + } + } + }); + } +} diff --git a/src/main/java/dev/rechka/chattranslator/network/PacketHandler.java b/src/main/java/dev/rechka/chattranslator/network/PacketHandler.java new file mode 100644 index 0000000..922975d --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/network/PacketHandler.java @@ -0,0 +1,43 @@ +package dev.rechka.chattranslator.network; + +import dev.rechka.chattranslator.ChatTranslator; +import dev.rechka.chattranslator.client.ClientPayloadHandlers; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; + +@EventBusSubscriber(modid = ChatTranslator.MOD_ID, bus = EventBusSubscriber.Bus.MOD) +public class PacketHandler { + + @SubscribeEvent + public static void onRegisterPayloads(RegisterPayloadHandlersEvent event) { + PayloadRegistrar registrar = event.registrar(ChatTranslator.MOD_ID) + .versioned("1") + .optional(); // lets vanilla clients join + + registrar.playToServer( + C2SPlayerLocalePacket.TYPE, + C2SPlayerLocalePacket.STREAM_CODEC, + C2SPlayerLocalePacket::handle + ); + + // The lambda body loads ClientPayloadHandlers lazily — never resolved on dedicated servers + registrar.playToClient( + S2CTranslatedMessagePacket.TYPE, + S2CTranslatedMessagePacket.STREAM_CODEC, + (packet, ctx) -> ctx.enqueueWork(() -> + ClientPayloadHandlers.handleTranslatedMessage(packet)) + ); + } + + public static void sendToPlayer(ServerPlayer player, S2CTranslatedMessagePacket packet) { + PacketDistributor.sendToPlayer(player, packet); + } + + public static void sendToServer(C2SPlayerLocalePacket packet) { + PacketDistributor.sendToServer(packet); + } +} diff --git a/src/main/java/dev/rechka/chattranslator/network/S2CTranslatedMessagePacket.java b/src/main/java/dev/rechka/chattranslator/network/S2CTranslatedMessagePacket.java new file mode 100644 index 0000000..a196933 --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/network/S2CTranslatedMessagePacket.java @@ -0,0 +1,41 @@ +package dev.rechka.chattranslator.network; + +import dev.rechka.chattranslator.ChatTranslator; +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +/** + * Server → client translated chat message. Empty {@code originalText} means "hide original". + * Handled client-side by {@link dev.rechka.chattranslator.client.ClientPayloadHandlers}. + */ +public record S2CTranslatedMessagePacket( + String senderName, + String translatedText, + String originalText +) implements CustomPacketPayload { + + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(ResourceLocation.fromNamespaceAndPath(ChatTranslator.MOD_ID, "translated_msg")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.STRING_UTF8, S2CTranslatedMessagePacket::senderName, + ByteBufCodecs.STRING_UTF8, S2CTranslatedMessagePacket::translatedText, + ByteBufCodecs.STRING_UTF8, pkt -> pkt.originalText() != null ? pkt.originalText() : "", + S2CTranslatedMessagePacket::new + ); + + public S2CTranslatedMessagePacket(String senderName, String translatedText, String originalText) { + this.senderName = senderName; + this.translatedText = translatedText; + this.originalText = originalText != null ? originalText : ""; + } + + @Override + public CustomPacketPayload.Type type() { + return TYPE; + } +} diff --git a/src/main/java/dev/rechka/chattranslator/server/ChatEventHandler.java b/src/main/java/dev/rechka/chattranslator/server/ChatEventHandler.java new file mode 100644 index 0000000..97dc2a0 --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/server/ChatEventHandler.java @@ -0,0 +1,99 @@ +package dev.rechka.chattranslator.server; + +import dev.rechka.chattranslator.ChatTranslator; +import dev.rechka.chattranslator.config.TranslatorConfig; +import dev.rechka.chattranslator.network.PacketHandler; +import dev.rechka.chattranslator.network.S2CTranslatedMessagePacket; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.bus.api.EventPriority; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.ServerChatEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public class ChatEventHandler { + + @SubscribeEvent(priority = EventPriority.HIGH) + public void onServerChat(ServerChatEvent event) { + if (!TranslatorConfig.SERVER_ENABLED.get()) return; + + // Cancel the vanilla broadcast — we deliver messages ourselves + event.setCanceled(true); + + String rawText = event.getMessage().getString(); + ServerPlayer sender = event.getPlayer(); + String senderName = sender.getName().getString(); + + List recipients = sender.getServer().getPlayerList().getPlayers(); + + resolveSourceLanguage(sender, rawText) + .exceptionally(ex -> { + ChatTranslator.LOGGER.warn( + "[ChatTranslator] Source language detection failed, using sender locale", ex); + return null; + }) + .thenAccept(sourceLang -> broadcast(recipients, senderName, rawText, sourceLang)); + } + + /** + * Source language of a message: auto-detected from the text itself + * (LibreTranslate /detect) or taken from the sender's configured language. + */ + private CompletableFuture resolveSourceLanguage(ServerPlayer sender, String rawText) { + String fallback = PlayerLocaleCache.getLanguage(sender); + if (TranslatorConfig.AUTODETECT_LANGUAGE.get()) { + return TranslationService.detect(rawText) + .handle((detected, ex) -> + (detected == null || detected.isBlank()) ? fallback : detected); + } + return CompletableFuture.completedFuture(fallback); + } + + private void broadcast(List recipients, String senderName, String rawText, String sourceLang) { + final String src = PlayerLocaleCache.toLanguage(sourceLang); + + for (ServerPlayer recipient : recipients) { + if (!PlayerLocaleCache.hasClientMod(recipient.getUUID())) { + // Vanilla / modless client can't read our payload — plain system message + recipient.sendSystemMessage(Component.literal("<" + senderName + "> " + rawText)); + continue; + } + + String targetLang = PlayerLocaleCache.getLanguage(recipient); + + if (targetLang.equals(src)) { + sendTranslated(recipient, senderName, rawText, null); + continue; + } + + TranslationService.translate(rawText, src, targetLang) + .thenAccept(translated -> { + String original = TranslatorConfig.SHOW_ORIGINAL.get() ? rawText : null; + sendTranslated(recipient, senderName, translated, original); + }) + .exceptionally(ex -> { + ChatTranslator.LOGGER.error("[ChatTranslator] Async translation error", ex); + sendTranslated(recipient, senderName, rawText, null); + return null; + }); + } + } + + private void sendTranslated(ServerPlayer player, String senderName, String text, String original) { + try { + PacketHandler.sendToPlayer(player, new S2CTranslatedMessagePacket(senderName, text, original)); + } catch (Exception e) { + player.sendSystemMessage(Component.literal("<" + senderName + "> " + text)); + } + } + + @SubscribeEvent + public void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity() instanceof ServerPlayer sp) { + PlayerLocaleCache.remove(sp.getUUID()); + } + } +} diff --git a/src/main/java/dev/rechka/chattranslator/server/PlayerLocaleCache.java b/src/main/java/dev/rechka/chattranslator/server/PlayerLocaleCache.java new file mode 100644 index 0000000..09d4d71 --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/server/PlayerLocaleCache.java @@ -0,0 +1,66 @@ +package dev.rechka.chattranslator.server; + +import net.minecraft.server.level.ServerPlayer; + +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-player language storage. Players running our client mod report their + * locale via C2SPlayerLocalePacket; everyone else falls back to the vanilla + * client information language (every client sends it, modded or not). + */ +public class PlayerLocaleCache { + + private static final Map OVERRIDES = new ConcurrentHashMap<>(); + private static final Set MODDED_CLIENTS = ConcurrentHashMap.newKeySet(); + + public static void set(UUID playerId, String locale) { + if (locale == null || locale.isBlank()) return; + OVERRIDES.put(playerId, locale.trim().toLowerCase(Locale.ROOT)); + MODDED_CLIENTS.add(playerId); + } + + /** True when the client runs this mod and can read S2CTranslatedMessagePacket. */ + public static boolean hasClientMod(UUID playerId) { + return MODDED_CLIENTS.contains(playerId); + } + + public static String getLanguage(ServerPlayer player) { + String override = OVERRIDES.get(player.getUUID()); + String locale = override != null ? override : safeVanillaLocale(player); + return toLanguage(locale); + } + + private static String safeVanillaLocale(ServerPlayer player) { + try { + String vanilla = player.getLanguage(); + return vanilla != null && !vanilla.isBlank() ? vanilla : "en_us"; + } catch (Exception e) { + return "en_us"; + } + } + + /** "ru_RU" → "ru"; garbage → "en". */ + public static String toLanguage(String locale) { + if (locale == null || locale.isBlank()) return "en"; + String l = locale.trim().toLowerCase(Locale.ROOT); + int sep = l.indexOf('_'); + if (sep > 0) l = l.substring(0, sep); + if (l.length() < 2 || l.length() > 8) return "en"; + return l; + } + + public static void remove(UUID playerId) { + OVERRIDES.remove(playerId); + MODDED_CLIENTS.remove(playerId); + } + + public static void clear() { + OVERRIDES.clear(); + MODDED_CLIENTS.clear(); + } +} diff --git a/src/main/java/dev/rechka/chattranslator/server/TranslationService.java b/src/main/java/dev/rechka/chattranslator/server/TranslationService.java new file mode 100644 index 0000000..36ce4c2 --- /dev/null +++ b/src/main/java/dev/rechka/chattranslator/server/TranslationService.java @@ -0,0 +1,224 @@ +package dev.rechka.chattranslator.server; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.rechka.chattranslator.ChatTranslator; +import dev.rechka.chattranslator.config.TranslatorConfig; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.*; + +public class TranslationService { + + private static final Gson GSON = new Gson(); + private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "ChatTranslator-Worker"); + t.setDaemon(true); + return t; + }); + + /** Key format: "sourceLang|targetLang|text", or "detect|text" for language detections. */ + private static final Map CACHE = new ConcurrentHashMap<>(); + + private static HttpClient httpClient; + + public static void invalidateCache() { + CACHE.clear(); + } + + private static HttpClient getClient() { + if (httpClient == null) { + httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(TranslatorConfig.REQUEST_TIMEOUT_MS.get())) + .build(); + } + return httpClient; + } + + /** + * Adds http:// when the scheme is missing and prefers 127.0.0.1 over + * localhost ("localhost" can resolve to IPv6 ::1, where LibreTranslate is + * often unreachable). Returns null for garbage input. + */ + static String normalizeBaseUrl(String raw) { + if (raw == null) return null; + String url = raw.trim(); + if (url.isEmpty()) return null; + while (url.endsWith("/")) { + url = url.substring(0, url.length() - 1); + } + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "http://" + url; + } + return url.replace("//localhost", "//127.0.0.1"); + } + + private static String configuredBaseUrl() { + String base = normalizeBaseUrl(TranslatorConfig.LIBRETRANSLATE_URL.get()); + return base != null ? base : "https://translate.argosopentech.com"; + } + + public static CompletableFuture translate(String text, String sourceLang, String targetLang) { + if (sourceLang.equals(targetLang)) { + return CompletableFuture.completedFuture(text); + } + + String cacheKey = sourceLang + "|" + targetLang + "|" + text; + CacheEntry cached = CACHE.get(cacheKey); + if (cached != null && !cached.isExpired()) { + return CompletableFuture.completedFuture(cached.value); + } + + return CompletableFuture.supplyAsync(() -> { + try { + String result = doTranslate(text, sourceLang, targetLang); + CACHE.put(cacheKey, new CacheEntry(result, TranslatorConfig.CACHE_MINUTES.get())); + return result; + } catch (Exception e) { + ChatTranslator.LOGGER.warn("[ChatTranslator] Translation failed: {}", e.getMessage()); + return text; // fall back to the original on any error + } + }, EXECUTOR); + } + + /** + * Detects the language of {@code text} via LibreTranslate /detect. + * Completes exceptionally on failure; callers should fall back to the + * sender's configured language. + */ + public static CompletableFuture detect(String text) { + String cacheKey = "detect|" + text; + CacheEntry cached = CACHE.get(cacheKey); + if (cached != null && !cached.isExpired()) { + return CompletableFuture.completedFuture(cached.value); + } + + return CompletableFuture.supplyAsync(() -> { + try { + String lang = doDetect(text); + CACHE.put(cacheKey, new CacheEntry(lang, TranslatorConfig.CACHE_MINUTES.get())); + return lang; + } catch (Exception e) { + ChatTranslator.LOGGER.warn("[ChatTranslator] Language detection failed: {}", e.getMessage()); + throw new CompletionException(e); + } + }, EXECUTOR); + } + + public record TestResult(boolean ok, String detail) { + public static TestResult ok(String detail) { return new TestResult(true, detail); } + public static TestResult fail(String reason) { return new TestResult(false, reason); } + } + + public static CompletableFuture testConnection(String rawUrl, String apiKeyUnused) { + return CompletableFuture.supplyAsync(() -> { + String base = normalizeBaseUrl(rawUrl); + if (base == null) { + return TestResult.fail("URL is empty"); + } + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(base + "/languages")) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + HttpResponse resp = getClient().send(request, HttpResponse.BodyHandlers.ofString()); + int code = resp.statusCode(); + if (code >= 200 && code < 300) { + return TestResult.ok("HTTP " + code); + } + if (code == 403) { + return TestResult.fail("HTTP 403 — API key required?"); + } + return TestResult.fail("HTTP " + code); + } catch (HttpConnectTimeoutException e) { + return TestResult.fail("timeout"); + } catch (ConnectException e) { + return TestResult.fail("connection refused"); + } catch (Exception e) { + ChatTranslator.LOGGER.debug("[ChatTranslator] Connection test failed", e); + String msg = e.getMessage(); + return TestResult.fail(msg != null && msg.length() <= 64 ? msg : e.getClass().getSimpleName()); + } + }, EXECUTOR); + } + + private static String doTranslate(String text, String sourceLang, String targetLang) throws IOException, InterruptedException { + JsonObject body = new JsonObject(); + body.addProperty("q", text); + body.addProperty("source", sourceLang); + body.addProperty("target", targetLang); + body.addProperty("format", "text"); + addApiKey(body); + + HttpResponse response = post(configuredBaseUrl() + "/translate", body); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode() + ": " + response.body()); + } + + JsonObject json = GSON.fromJson(response.body(), JsonObject.class); + if (json.has("translatedText")) { + return json.get("translatedText").getAsString(); + } + throw new IOException("No translatedText in response: " + response.body()); + } + + private static String doDetect(String text) throws IOException, InterruptedException { + JsonObject body = new JsonObject(); + body.addProperty("q", text); + addApiKey(body); + + HttpResponse response = post(configuredBaseUrl() + "/detect", body); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode() + ": " + response.body()); + } + + // LibreTranslate /detect returns [{"confidence":0.6,"language":"en"}, ...] + JsonArray arr = GSON.fromJson(response.body(), JsonArray.class); + if (arr != null && arr.size() > 0 && arr.get(0).isJsonObject()) { + JsonObject first = arr.get(0).getAsJsonObject(); + if (first.has("language")) { + return first.get("language").getAsString(); + } + } + throw new IOException("No language in detect response: " + response.body()); + } + + private static void addApiKey(JsonObject body) { + String apiKey = TranslatorConfig.API_KEY.get().trim(); + if (!apiKey.isEmpty()) { + body.addProperty("api_key", apiKey); + } + } + + private static HttpResponse post(String url, JsonObject body) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofMillis(TranslatorConfig.REQUEST_TIMEOUT_MS.get())) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(body))) + .build(); + return getClient().send(request, HttpResponse.BodyHandlers.ofString()); + } + + private record CacheEntry(String value, long expiresAt) { + CacheEntry(String value, int ttlMinutes) { + this(value, System.currentTimeMillis() + (long) ttlMinutes * 60_000); + } + + boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + } +} diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..e1636da --- /dev/null +++ b/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,24 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" + +[[mods]] + modId="${mod_id}" + version="${mod_version}" + displayName="${mod_name}" + description='''${mod_description}''' + authors="${mod_authors}" + +[[dependencies.${mod_id}]] + modId="neoforge" + type="required" + versionRange="${neo_version_range}" + ordering="NONE" + side="BOTH" + +[[dependencies.${mod_id}]] + modId="minecraft" + type="required" + versionRange="${minecraft_version_range}" + ordering="NONE" + side="BOTH" diff --git a/src/main/resources/assets/chattranslator/lang/en_us.json b/src/main/resources/assets/chattranslator/lang/en_us.json new file mode 100644 index 0000000..8436887 --- /dev/null +++ b/src/main/resources/assets/chattranslator/lang/en_us.json @@ -0,0 +1,12 @@ +{ + "chattranslator.gui.title": "Chat Translator Settings", + "chattranslator.gui.language": "My Language", + "chattranslator.gui.libretranslate_url": "LibreTranslate URL", + "chattranslator.gui.api_key": "API Key (optional)", + "chattranslator.gui.show_original": "Show Original Message", + "chattranslator.gui.save": "Save", + "chattranslator.gui.cancel": "Cancel", + "chattranslator.gui.test": "Test Connection", + "chattranslator.keybind.open_gui": "Open Translator Settings", + "key.categories.chattranslator": "Chat Translator" +} diff --git a/src/main/resources/pack.mcmeta b/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..4315dc1 --- /dev/null +++ b/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "Chat Translator Resources", + "pack_format": 15 + } +}