diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..168e0ea --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,40 @@ +name: Build + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.event_name == 'pull_request' }} + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build + run: ./gradlew build + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: extractor-build + path: build/libs/*.jar + if-no-files-found: error diff --git a/README.md b/README.md index 99cfb32..f78869e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Extractor is a Fabric mod that extracts Minecraft data (blocks, items, entities, - [x] Chunk Status - [x] Game Event - [x] Game Rule -- [x] Translation (en_us) +- [x] Translations (all languages) - [x] Noise Parameters - [x] Particles - [x] Recipes diff --git a/src/main/kotlin/de/snowii/extractor/Extractor.kt b/src/main/kotlin/de/snowii/extractor/Extractor.kt index e56fd4c..82998b5 100644 --- a/src/main/kotlin/de/snowii/extractor/Extractor.kt +++ b/src/main/kotlin/de/snowii/extractor/Extractor.kt @@ -9,7 +9,6 @@ import de.snowii.extractor.extractors.structures.Structures import net.fabricmc.api.ModInitializer import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents import net.minecraft.server.MinecraftServer -import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.FileWriter import java.io.IOException @@ -21,11 +20,14 @@ import kotlin.system.measureTimeMillis class Extractor : ModInitializer { - private val modID: String = "pumpkin_extractor" - private val logger: Logger = LoggerFactory.getLogger(modID) + private val logger = LoggerFactory.getLogger("pumpkin_extractor") + + companion object { + const val OUTPUT_DIR = "pumpkin_extractor_output" + } override fun onInitialize() { - logger.info("Starting Pumpkin Extractor") + logger.info(Lang.fmt("extractor.log.starting")) val extractors = arrayOf( Dialog(), @@ -172,29 +174,36 @@ class Extractor : ModInitializer { val outputDirectory: Path try { - outputDirectory = Files.createDirectories(Paths.get("pumpkin_extractor_output")) + outputDirectory = Files.createDirectories(Paths.get(OUTPUT_DIR)) + logger.info(Lang.fmt("extractor.log.output_dir", outputDirectory.toAbsolutePath())) } catch (e: IOException) { - logger.info("Failed to create output directory.", e) + logger.error(Lang.fmt("extractor.log.output_dir_failed"), e) return } val gson = GsonBuilder().disableHtmlEscaping().create() ServerLifecycleEvents.SERVER_STARTED.register(ServerLifecycleEvents.ServerStarted { server: MinecraftServer -> - val timeInMillis = measureTimeMillis { - for (ext in extractors) { - try { - val out = outputDirectory.resolve(ext.fileName()) - val fileWriter = FileWriter(out.toFile(), StandardCharsets.UTF_8) - gson.toJson(ext.extract(server), fileWriter) - fileWriter.close() - logger.info("Wrote " + out.toAbsolutePath()) - } catch (e: java.lang.Exception) { - logger.error(("Extractor for \"" + ext.fileName()) + "\" failed.", e) + logger.info(Lang.fmt("extractor.log.server_started")) + try { + val timeInMillis = measureTimeMillis { + for (ext in extractors) { + try { + val out = outputDirectory.resolve(ext.fileName()) + Files.createDirectories(out.parent) + FileWriter(out.toFile(), StandardCharsets.UTF_8).use { writer -> + gson.toJson(ext.extract(server), writer) + } + logger.info(Lang.fmt("extractor.log.wrote", out.toAbsolutePath())) + } catch (e: Exception) { + logger.error(Lang.fmt("extractor.log.extractor_failed", ext.fileName()), e) + } } } + logger.info(Lang.fmt("extractor.log.done", timeInMillis)) + } catch (e: Throwable) { + logger.error(Lang.fmt("extractor.log.fatal_error"), e) } - logger.info("Done, took ${timeInMillis}ms") }) } diff --git a/src/main/kotlin/de/snowii/extractor/Lang.kt b/src/main/kotlin/de/snowii/extractor/Lang.kt new file mode 100644 index 0000000..e415c8d --- /dev/null +++ b/src/main/kotlin/de/snowii/extractor/Lang.kt @@ -0,0 +1,33 @@ +package de.snowii.extractor + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.InputStreamReader + +/** + * Loads the mod's en_us.json language file and exposes [fmt] for formatted lookups. + * Log strings are kept in the lang file as the single source of truth; + * at runtime only the English fallback is resolved (logs are developer-facing). + */ +object Lang { + private val entries: Map by lazy { + val stream = Lang::class.java.classLoader + .getResourceAsStream("assets/extractor/lang/en_us.json") + if (stream == null) { + emptyMap() + } else { + stream.use { s -> + Gson().fromJson( + InputStreamReader(s, Charsets.UTF_8), + object : TypeToken>() {}.type + ) + } + } + } + + /** Look up key and format with classic String.format semantics. */ + fun fmt(key: String, vararg args: Any?): String { + val template = entries[key] ?: key + return if (args.isEmpty()) template else template.format(*args) + } +} diff --git a/src/main/kotlin/de/snowii/extractor/extractors/non_registry/Translations.kt b/src/main/kotlin/de/snowii/extractor/extractors/non_registry/Translations.kt index 6c61051..bd0aaba 100644 --- a/src/main/kotlin/de/snowii/extractor/extractors/non_registry/Translations.kt +++ b/src/main/kotlin/de/snowii/extractor/extractors/non_registry/Translations.kt @@ -1,30 +1,111 @@ package de.snowii.extractor.extractors.non_registry -import com.google.gson.Gson import com.google.gson.GsonBuilder +import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject import de.snowii.extractor.Extractor +import de.snowii.extractor.Lang +import net.minecraft.resources.Identifier import net.minecraft.server.MinecraftServer +import org.slf4j.LoggerFactory +import java.io.FileWriter +import java.io.InputStream import java.io.InputStreamReader import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths class Translations : Extractor.Extractor { - private val gson: Gson = GsonBuilder().disableHtmlEscaping().create() + private val gson = GsonBuilder().disableHtmlEscaping().create() + private val logger = LoggerFactory.getLogger("translations_extractor") - override fun fileName(): String { - return "en_us.json" - } + /** Lazily reflect into net.minecraft.client.Minecraft to avoid compile-time dependency. */ + private val clientReflection by lazy { ClientReflection.resolve() } + + override fun fileName() = "translations.json" override fun extract(server: MinecraftServer): JsonElement { - val inputStream = this.javaClass.getResourceAsStream("/assets/minecraft/lang/en_us.json") - ?: throw IllegalArgumentException("Could not find lang en_us.json") - - return inputStream.use { stream -> - gson.fromJson( - InputStreamReader(stream, StandardCharsets.UTF_8), - JsonObject::class.java - ) as JsonObject + val langDir = Paths.get(Extractor.OUTPUT_DIR, "lang") + Files.createDirectories(langDir) + val summary = JsonArray() + + for (langCode in clientReflection?.languageCodes ?: listOf("en_us")) { + val stream = loadLangStream(langCode) ?: run { + logger.warn(Lang.fmt("extractor.translations.not_found", langCode)) + continue + } + try { + stream.use { s -> + val raw = gson.fromJson( + InputStreamReader(s, StandardCharsets.UTF_8), + JsonObject::class.java + ) as JsonObject + val sorted = JsonObject() + raw.entrySet().sortedBy { it.key }.forEach { (k, v) -> sorted.add(k, v) } + FileWriter(langDir.resolve("${langCode}_java.json").toFile(), StandardCharsets.UTF_8).use { w -> + gson.toJson(sorted, w) + } + } + summary.add(langCode) + logger.info(Lang.fmt("extractor.translations.wrote", langCode)) + } catch (e: Exception) { + logger.warn(Lang.fmt("extractor.translations.failed", langCode), e) + } + } + + logger.info(Lang.fmt("extractor.translations.summary", summary.size(), langDir.toAbsolutePath())) + return summary + } + + private fun loadLangStream(langCode: String): InputStream? { + clientReflection?.let { ref -> + try { + val id = Identifier.fromNamespaceAndPath("minecraft", "lang/$langCode.json") + ref.openResource(id)?.let { return it } + } catch (_: Exception) { } + } + return this.javaClass.getResourceAsStream("/assets/minecraft/lang/$langCode.json") + } + + /** + * Cached reflective handle to the client Minecraft instance. + * Null when running on a dedicated server (no client available). + */ + private class ClientReflection private constructor( + private val resourceManager: Any, + private val getResourceStackMethod: java.lang.reflect.Method, + val languageCodes: List + ) { + @Suppress("UNCHECKED_CAST") + fun openResource(identifier: Identifier): InputStream? { + val resources = getResourceStackMethod.invoke(resourceManager, identifier) as List<*> + if (resources.isEmpty()) return null + val resource = resources.first() ?: return null + return resource.javaClass.getMethod("open").invoke(resource) as InputStream + } + + companion object { + fun resolve(): ClientReflection? { + return try { + val mc = Class.forName("net.minecraft.client.Minecraft") + val client = mc.getMethod("getInstance").invoke(null) ?: return null + val rm = mc.getMethod("getResourceManager").invoke(client) + val stackMethod = rm.javaClass.getMethod("getResourceStack", Identifier::class.java) + + val lm = mc.getMethod("getLanguageManager").invoke(client) + @Suppress("UNCHECKED_CAST") + val languages = lm.javaClass.getMethod("getLanguages").invoke(lm) as Map + + ClientReflection( + resourceManager = rm, + getResourceStackMethod = stackMethod, + languageCodes = languages.keys.sorted() + ) + } catch (_: Exception) { + null + } + } } } -} \ No newline at end of file +} diff --git a/src/main/resources/assets/extractor/lang/en_us.json b/src/main/resources/assets/extractor/lang/en_us.json new file mode 100644 index 0000000..193cb44 --- /dev/null +++ b/src/main/resources/assets/extractor/lang/en_us.json @@ -0,0 +1,18 @@ +{ + "mod.extractor.name": "Pumpkin Extractor", + "mod.extractor.description": "Extracts Minecraft data (blocks, items, entities, translations, etc.) into JSON files on server start.", + + "extractor.log.starting": "Starting Pumpkin Extractor", + "extractor.log.output_dir": "Output directory: %s", + "extractor.log.output_dir_failed": "Failed to create output directory.", + "extractor.log.server_started": "Server started — running extractors...", + "extractor.log.wrote": "Wrote %s", + "extractor.log.extractor_failed": "Extractor \"%s\" failed.", + "extractor.log.done": "Done, took %dms", + "extractor.log.fatal_error": "Extraction failed with fatal error", + + "extractor.translations.not_found": "Could not find lang file for: %s", + "extractor.translations.wrote": "Wrote language: %s", + "extractor.translations.failed": "Failed to write language: %s", + "extractor.translations.summary": "Extracted %d languages to %s" +} diff --git a/src/main/resources/assets/extractor/lang/zh_cn.json b/src/main/resources/assets/extractor/lang/zh_cn.json new file mode 100644 index 0000000..d7829ee --- /dev/null +++ b/src/main/resources/assets/extractor/lang/zh_cn.json @@ -0,0 +1,18 @@ +{ + "mod.extractor.name": "Pumpkin 数据提取器", + "mod.extractor.description": "在服务器启动时将 Minecraft 数据(方块、物品、实体、翻译等)提取为 JSON 文件。", + + "extractor.log.starting": "正在启动 Pumpkin Extractor", + "extractor.log.output_dir": "输出目录:%s", + "extractor.log.output_dir_failed": "无法创建输出目录。", + "extractor.log.server_started": "服务器已启动 — 开始执行提取器...", + "extractor.log.wrote": "已写入 %s", + "extractor.log.extractor_failed": "提取器 \"%s\" 失败。", + "extractor.log.done": "完成,耗时 %dms", + "extractor.log.fatal_error": "提取过程发生致命错误", + + "extractor.translations.not_found": "找不到语言文件:%s", + "extractor.translations.wrote": "已写入语言:%s", + "extractor.translations.failed": "写入语言失败:%s", + "extractor.translations.summary": "已将 %d 种语言提取到 %s" +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 6b3f31d..ea0e61a 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "id": "extractor", "version": "${version}", - "name": "pumpkin-extractor", - "description": "", + "name": "mod.extractor.name", + "description": "mod.extractor.description", "authors": [], "contact": {}, "license": "MIT",