From 646891daf4ce073650b39d62c1bec66692aab74d Mon Sep 17 00:00:00 2001 From: Matt Sturgeon Date: Wed, 7 Feb 2024 07:24:34 +0000 Subject: [PATCH 1/5] Rename :metadata -> :data In preparation for it also handling other data --- {metadata => data}/build.gradle | 0 {metadata => data}/src/fabric/fabric.mod.json | 0 {metadata => data}/src/neoforge/META-INF/mods.toml | 0 fabric/build.gradle | 8 ++++---- neoforge/build.gradle | 10 +++++----- settings.gradle | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) rename {metadata => data}/build.gradle (100%) rename {metadata => data}/src/fabric/fabric.mod.json (100%) rename {metadata => data}/src/neoforge/META-INF/mods.toml (100%) diff --git a/metadata/build.gradle b/data/build.gradle similarity index 100% rename from metadata/build.gradle rename to data/build.gradle diff --git a/metadata/src/fabric/fabric.mod.json b/data/src/fabric/fabric.mod.json similarity index 100% rename from metadata/src/fabric/fabric.mod.json rename to data/src/fabric/fabric.mod.json diff --git a/metadata/src/neoforge/META-INF/mods.toml b/data/src/neoforge/META-INF/mods.toml similarity index 100% rename from metadata/src/neoforge/META-INF/mods.toml rename to data/src/neoforge/META-INF/mods.toml diff --git a/fabric/build.gradle b/fabric/build.gradle index c1e3594f..5e4bd3f7 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -50,10 +50,10 @@ variants.each { variant -> } def shadowConfig = configurations.create("${variant}Shadow") - // Add a dependency on metadata - def metadata = dependencies.project(path: ":metadata", configuration: "fabric${variant == "normal" ? "" : variant.capitalize()}") - dependencies.add(set.implementationConfigurationName, metadata) - dependencies.add(shadowConfig.name, metadata) + // Add a dependency on mod data + def data = dependencies.project(path: ":data", configuration: "fabric${variant == "normal" ? "" : variant.capitalize()}") + dependencies.add(set.implementationConfigurationName, data) + dependencies.add(shadowConfig.name, data) // Add a dependency on variant's service provider dependencies.add(set.implementationConfigurationName, dependencies.project(path: ":variant:${variant}", configuration: "namedElements")) diff --git a/neoforge/build.gradle b/neoforge/build.gradle index 33d81805..9647c8b6 100644 --- a/neoforge/build.gradle +++ b/neoforge/build.gradle @@ -42,10 +42,10 @@ variants.each { variant -> } def shadowConfig = configurations.create("${variant}Shadow") - // Add a dependency on metadata - def metadata = dependencies.project(path: ":metadata", configuration: "neoforge${variant == "normal" ? "" : variant.capitalize()}") - dependencies.add(set.implementationConfigurationName, metadata) - dependencies.add(shadowConfig.name, metadata) + // Add a dependency on mod data + def data = dependencies.project(path: ":data", configuration: "neoforge${variant == "normal" ? "" : variant.capitalize()}") + dependencies.add(set.implementationConfigurationName, data) + dependencies.add(shadowConfig.name, data) // Add a dependency on variant's service provider def spi = dependencies.project(path: ":variant:${variant}", configuration: "namedElements") @@ -70,7 +70,7 @@ variants.each { variant -> run.mods.register(rootProject.name) { sourceSet project.sourceSets.main sourceSet set - dependency metadata + dependency data dependency spi } diff --git a/settings.gradle b/settings.gradle index a949b863..1ebd598a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,7 +11,7 @@ pluginManagement { include("common") include("fabric") include("neoforge") -include("metadata") +include("data") include("variant", "variant:api", "variant:normal", "variant:modrinth") rootProject.name = "freecam" From 9b155f6ab1e28a8a1074ac0579e5c37b47157dc1 Mon Sep 17 00:00:00 2001 From: Matt Sturgeon Date: Wed, 7 Feb 2024 07:25:11 +0000 Subject: [PATCH 2/5] General `:data` cleanup Modularise the tasks in preparation for adding more, and do some general cleanup: - Drop special jar names - Force ide to recognise `platform` & `variant` as `String`s - Split metadata & jar tasks --- data/build.gradle | 55 +++++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/data/build.gradle b/data/build.gradle index d9c344e1..daab7a06 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -1,35 +1,38 @@ -def platforms = rootProject.enabled_platforms.split(',') -def variants = rootProject.build_variants.split(',') +def platforms = (rootProject.enabled_platforms as String).split(',') +def variants = (rootProject.build_variants as String).split(',') -base { - archivesName = "metadata" -} - -// For each platform/variant combination, provide a jar containing the generated metadata. -// This allows us to include the metadata files as a dependency, which is more flexible. +// For each platform/variant combination, provide a jar containing the generated data. +// This allows each build to depend on slightly different data. platforms.each { platform -> variants.each { variant -> def name = platform - def classifier = platform - def taskDescription = "Generate metadata for ${platform.capitalize()}" + def kebabName = platform if (variant != "normal") { name += variant.capitalize() - classifier += '-' + variant - taskDescription += " (${variant.capitalize()})" + kebabName += "-$variant" } - def task = tasks.register("${name}Jar", Zip) { - // Configure the task - group = "build" - description = taskDescription + def metadataTask = tasks.register("${name}GenMetadata", Copy) { + def modFile + switch (platform) { + case "fabric": + modFile = "fabric.mod.json" + break + case "forge": + case "neoforge": + modFile = "mods.toml" + break + default: modFile = "" + } - archiveClassifier = classifier - archiveExtension = "jar" + group = "metadata" + description = "Build ${modFile} file" // Include files under `src/platform` - from(layout.projectDirectory.dir("src").dir(platform)) + from layout.projectDirectory.dir("src").dir(platform) + into layout.buildDirectory.dir("metadata/${kebabName}") inputs.properties( mod_id: rootProject.name, @@ -68,10 +71,20 @@ platforms.each { platform -> variants.each { variant -> } } + def jarTask = tasks.register("${name}Jar", Zip) { + group = "build" + description = "Build ${kebabName} mod data" + + archiveClassifier = kebabName + archiveExtension = "jar" + + from metadataTask + } + // Make build extend this, for convenience - tasks.build.dependsOn task + tasks.build.dependsOn jarTask // Export the artifact so that other projects can use it configurations.register(name) - artifacts.add(name, task) + artifacts.add(name, jarTask) }} \ No newline at end of file From ecc3104542c179cd52245150a3111e0a4fad91e3 Mon Sep 17 00:00:00 2001 From: Matt Sturgeon Date: Fri, 9 Feb 2024 09:51:34 +0000 Subject: [PATCH 3/5] Build translations in `:data` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move lang files from `:common` to `:data` - Build them using a new `LangTask` Gradle task type 👀 - Update Crowdin config - Removed Crowdin `languages_mapping` 😃 `LangTask` is used to build/process i18n lang files, allowing for more control over what is included and how. --- .github/workflows/crowdin-upload-sources.yaml | 2 +- .../crowdin-upload-translations.yaml | 4 +- buildSrc/build.gradle.kts | 17 ++ .../net/xolt/freecam/extensions/File.kt | 5 + .../net/xolt/freecam/gradle/LangProcessor.kt | 10 ++ .../net/xolt/freecam/gradle/LangTask.kt | 124 +++++++++++++++ crowdin.yaml | 11 +- data/build.gradle | 150 +++++++++--------- .../src/lang/en-US/main.json | 0 .../src/lang/it-IT/main.json | 0 .../src/lang/ja-JP/main.json | 0 .../src/lang/zh-CN/main.json | 0 12 files changed, 238 insertions(+), 85 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/net/xolt/freecam/extensions/File.kt create mode 100644 buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangProcessor.kt create mode 100644 buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt rename common/src/main/resources/assets/freecam/lang/en_us.json => data/src/lang/en-US/main.json (100%) rename common/src/main/resources/assets/freecam/lang/it_it.json => data/src/lang/it-IT/main.json (100%) rename common/src/main/resources/assets/freecam/lang/ja_jp.json => data/src/lang/ja-JP/main.json (100%) rename common/src/main/resources/assets/freecam/lang/zh_cn.json => data/src/lang/zh-CN/main.json (100%) diff --git a/.github/workflows/crowdin-upload-sources.yaml b/.github/workflows/crowdin-upload-sources.yaml index 527a3658..ac8b4add 100644 --- a/.github/workflows/crowdin-upload-sources.yaml +++ b/.github/workflows/crowdin-upload-sources.yaml @@ -6,7 +6,7 @@ on: branches: - main paths: - - '**/lang/en_us.json' + - 'data/src/lang/en-US/*.json' # Queue this workflow after others in the 'crowdin' group, to avoid concurrency issues. concurrency: diff --git a/.github/workflows/crowdin-upload-translations.yaml b/.github/workflows/crowdin-upload-translations.yaml index 30404cd7..897c4274 100644 --- a/.github/workflows/crowdin-upload-translations.yaml +++ b/.github/workflows/crowdin-upload-translations.yaml @@ -7,8 +7,8 @@ on: # branches: # - main # paths: -# - '**/lang/*.json' -# - '!**/lang/en_us.json' +# - 'data/src/lang/*/*.json' +# - '!data/src/lang/en-US/*' # Queue this workflow after others in the 'crowdin' group, to avoid concurrency issues. concurrency: diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..7b77ec95 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + `kotlin-dsl` + kotlin("plugin.serialization") version "1.9.20" +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") + testImplementation(kotlin("test")) +} + +repositories { + mavenCentral() +} + +tasks.test { + useJUnitPlatform() +} diff --git a/buildSrc/src/main/kotlin/net/xolt/freecam/extensions/File.kt b/buildSrc/src/main/kotlin/net/xolt/freecam/extensions/File.kt new file mode 100644 index 00000000..8c1722e2 --- /dev/null +++ b/buildSrc/src/main/kotlin/net/xolt/freecam/extensions/File.kt @@ -0,0 +1,5 @@ +package net.xolt.freecam.extensions + +import java.io.File + +fun File.childDirectories(): Sequence = listFiles { file -> file.isDirectory }?.asSequence() ?: emptySequence() \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangProcessor.kt b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangProcessor.kt new file mode 100644 index 00000000..d7901ade --- /dev/null +++ b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangProcessor.kt @@ -0,0 +1,10 @@ +package net.xolt.freecam.gradle + +internal fun interface LangProcessor { + fun process( + modID: String, + variant: String, + translations: Map, + fallback: Map? + ): Map +} diff --git a/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt new file mode 100644 index 00000000..6ad7c53d --- /dev/null +++ b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt @@ -0,0 +1,124 @@ +package net.xolt.freecam.gradle + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import net.xolt.freecam.extensions.childDirectories +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * A Gradle task that builds translations into [variant]-specific lang files, compatible with minecraft. + */ +abstract class LangTask : DefaultTask() { + + /** + * The directory where language files should be loaded from. + */ + @get:InputDirectory + abstract val inputDirectory: DirectoryProperty + + /** + * The directory where language files should be written to. + */ + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + /** + * The "build variant" of the language files to target. + * + * E.g. `"normal"` or `"modrinth"`. + * + * @sample "normal" + * @sample "modrinth" + */ + @get:Input + abstract val variant: Property + + /** + * The "source" language that translations are based on. + * + * Defaults to `"en-US"`. + */ + @get:Input + abstract val source: Property + + /** + * The mod ID. + * + * Used in the output file structure as well as some translation keys. + */ + @get:Input + abstract val modId: Property + + private val json = Json { prettyPrint = true } + private val localeRegex = "^[a-z]{2}-[A-Z]{2}$".toRegex() + private val processors = listOf( + + ) + + init { + @Suppress("LeakingThis") + source.convention("en-US") + } + + /** + * Run by Gradle when executing implementing tasks. + */ + @TaskAction + fun build() { + val languages = inputDirectory.get().asFile + .childDirectories() + .filter { it.name.matches(localeRegex) } + .associate { it.name to readLangDir(it) } + + val base = languages[source.get()] + + languages.forEach { (lang, translations) -> + writeJsonFile(fileFor(lang), processLang(translations, base).toSortedMap()) + } + } + + private fun fileFor(lang: String) = outputDirectory.get().asFile + .resolve("assets") + .resolve(modId.get()) + .resolve("lang") + .resolve(normaliseMCLangCode(lang) + ".json") + + // NOTE: Some lang codes may need manual mapping... + // I couldn't find any examples though, so it's unlikely to affect us + private fun normaliseMCLangCode(lang: String) = lang.lowercase().replace('-', '_') + + // Applies all processors to the given translations. + // Does not use fallback to add missing translations, that is done in-game by MC + // Some processors may use fallback to fill in missing _parts_ of translations though. + private fun processLang(translations: Map, fallback: Map?) = + processors.fold(translations) { acc, processor -> + processor.process(modId.get(), variant.get().lowercase(), acc, fallback) + } + + // Read and combine translation files in dir + private fun readLangDir(dir: File) = dir + .listFiles { _, name -> name.endsWith(".json") } + ?.map { readJsonFile(it) } + ?.flatMap { it.entries } + ?.associate { it.toPair() } + ?: emptyMap() + + @OptIn(ExperimentalSerializationApi::class) + private fun readJsonFile(file: File): Map = json.decodeFromStream(file.inputStream()) + + @OptIn(ExperimentalSerializationApi::class) + private fun writeJsonFile(file: File, translations: Map) { + file.parentFile.mkdirs() + file.createNewFile() + json.encodeToStream(translations, file.outputStream()) + } +} diff --git a/crowdin.yaml b/crowdin.yaml index c5c322d4..b2324675 100644 --- a/crowdin.yaml +++ b/crowdin.yaml @@ -3,12 +3,5 @@ api_token_env: CROWDIN_PERSONAL_TOKEN preserve_hierarchy: true files: - - source: '/**/lang/en_us.json' - translation: '/**/lang/%locale%.json' - languages_mapping: - locale: - # Valid keys: https://developer.crowdin.com/language-codes - # Valid values ("in-game" locale column): https://minecraft.wiki/w/Language - it: it_it - ja: ja_jp - zh-CN: zh_cn + - source: '/data/src/lang/en-US/*.json' + translation: '/data/src/lang/%locale%/%original_file_name%' diff --git a/data/build.gradle b/data/build.gradle index daab7a06..80d34a24 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -1,90 +1,94 @@ +import net.xolt.freecam.gradle.LangTask def platforms = (rootProject.enabled_platforms as String).split(',') def variants = (rootProject.build_variants as String).split(',') // For each platform/variant combination, provide a jar containing the generated data. // This allows each build to depend on slightly different data. -platforms.each { platform -> variants.each { variant -> - - def name = platform - def kebabName = platform - - if (variant != "normal") { - name += variant.capitalize() - kebabName += "-$variant" +variants.each { variant -> + + def langTask = tasks.register("${variant}LangFiles", LangTask) { + group = "i18n" + description = "Build ${variant} lang files" + inputDirectory = layout.projectDirectory.dir("src").dir("lang") + outputDirectory = layout.buildDirectory.dir("i18n/${variant}") + modId = rootProject.name + setVariant(variant) } - def metadataTask = tasks.register("${name}GenMetadata", Copy) { - def modFile - switch (platform) { - case "fabric": - modFile = "fabric.mod.json" - break - case "forge": - case "neoforge": - modFile = "mods.toml" - break - default: modFile = "" - } + platforms.each { platform -> + def name = platform + def kebabName = platform - group = "metadata" - description = "Build ${modFile} file" - - // Include files under `src/platform` - from layout.projectDirectory.dir("src").dir(platform) - into layout.buildDirectory.dir("metadata/${kebabName}") - - inputs.properties( - mod_id: rootProject.name, - modrinth_name: project.modrinth_name, - version: project.mod_version, - authors: project.authors, - description: project.description, - modrinth_description: project.modrinth_description, - licence: project.licence, - homepage_url: project.homepage_url, - source_code_url: project.source_code_url, - issue_tracker_url: project.issue_tracker_url, - fabric_loader_req: project.fabric_loader_req, - fabric_mc_req: project.fabric_mc_req, - neoforge_mc_req: project.neoforge_mc_req, - neoforge_loader_req: project.neoforge_loader_req, - neoforge_req: project.neoforge_req, - ) - - def overrides = new TreeMap() - overrides.mod_id = inputs.properties.mod_id.toLowerCase() - overrides.name = overrides.mod_id.capitalize() - overrides.json_authors = inputs.properties.authors.split(',').collect { "\"$it\"" }.join(", ") - - if (variant == "modrinth") { - overrides.name += ' ' + inputs.properties.modrinth_name - overrides.description = inputs.properties.description + ' ' + inputs.properties.modrinth_description + if (variant != "normal") { + name += variant.capitalize() + kebabName += "-$variant" } - filesMatching("fabric.mod.json") { - expand inputs.properties + overrides + def metadataTask = tasks.register("${name}GenMetadata", Copy) { + group = "metadata" + description = { + def file = platform.endsWith "forge" ? "mods.toml" : "${platform}.mods.json" + return "Build $variant $file file" + }() + + // Include files under `src/platform` + from layout.projectDirectory.dir("src").dir(platform) + into layout.buildDirectory.dir("metadata/${kebabName}") + + inputs.properties( + mod_id: rootProject.name, + modrinth_name: project.modrinth_name, + version: project.mod_version, + authors: project.authors, + description: project.description, + modrinth_description: project.modrinth_description, + licence: project.licence, + homepage_url: project.homepage_url, + source_code_url: project.source_code_url, + issue_tracker_url: project.issue_tracker_url, + fabric_loader_req: project.fabric_loader_req, + fabric_mc_req: project.fabric_mc_req, + neoforge_mc_req: project.neoforge_mc_req, + neoforge_loader_req: project.neoforge_loader_req, + neoforge_req: project.neoforge_req, + ) + + def overrides = new TreeMap() + overrides.mod_id = inputs.properties.mod_id.toLowerCase() + overrides.name = overrides.mod_id.capitalize() + overrides.json_authors = inputs.properties.authors.split(',').collect { "\"$it\"" }.join(", ") + + if (variant == "modrinth") { + overrides.name += ' ' + inputs.properties.modrinth_name + overrides.description = inputs.properties.description + ' ' + inputs.properties.modrinth_description + } + + filesMatching("fabric.mod.json") { + expand inputs.properties + overrides + } + + filesMatching("META-INF/mods.toml") { + expand inputs.properties + overrides + } } - filesMatching("META-INF/mods.toml") { - expand inputs.properties + overrides - } - } + def jarTask = tasks.register("${name}Jar", Zip) { + group = "build" + description = "Build ${kebabName} mod data" - def jarTask = tasks.register("${name}Jar", Zip) { - group = "build" - description = "Build ${kebabName} mod data" + archiveClassifier = kebabName + archiveExtension = "jar" - archiveClassifier = kebabName - archiveExtension = "jar" - - from metadataTask - } + from langTask + from metadataTask + } - // Make build extend this, for convenience - tasks.build.dependsOn jarTask + // Make build extend this, for convenience + tasks.build.dependsOn jarTask - // Export the artifact so that other projects can use it - configurations.register(name) - artifacts.add(name, jarTask) -}} \ No newline at end of file + // Export the artifact so that other projects can use it + configurations.register(name) + artifacts.add(name, jarTask) + } +} \ No newline at end of file diff --git a/common/src/main/resources/assets/freecam/lang/en_us.json b/data/src/lang/en-US/main.json similarity index 100% rename from common/src/main/resources/assets/freecam/lang/en_us.json rename to data/src/lang/en-US/main.json diff --git a/common/src/main/resources/assets/freecam/lang/it_it.json b/data/src/lang/it-IT/main.json similarity index 100% rename from common/src/main/resources/assets/freecam/lang/it_it.json rename to data/src/lang/it-IT/main.json diff --git a/common/src/main/resources/assets/freecam/lang/ja_jp.json b/data/src/lang/ja-JP/main.json similarity index 100% rename from common/src/main/resources/assets/freecam/lang/ja_jp.json rename to data/src/lang/ja-JP/main.json diff --git a/common/src/main/resources/assets/freecam/lang/zh_cn.json b/data/src/lang/zh-CN/main.json similarity index 100% rename from common/src/main/resources/assets/freecam/lang/zh_cn.json rename to data/src/lang/zh-CN/main.json From 1aacfeb3f80eb97d0b7d99c7d051eb2a85125bf6 Mon Sep 17 00:00:00 2001 From: Matt Sturgeon Date: Wed, 7 Feb 2024 13:52:24 +0000 Subject: [PATCH 4/5] Process `VariantTooltip` at build time Introduce `VariantTooltipProcessor`, which `LangTask`s use to process `VariantTooltip`s. This allows the runtime implementation to be greatly simplified, it now only handles differences in line `count`. --- .../net/xolt/freecam/gradle/LangTask.kt | 2 +- .../freecam/gradle/VariantTooltipProcessor.kt | 39 ++++++++++++++++ .../net/xolt/freecam/gradle/ProcessorTest.kt | 22 ++++++++++ .../gradle/VariantTooltipProcessorTest.kt | 44 +++++++++++++++++++ .../xolt/freecam/config/ConfigExtensions.java | 44 ++++++------------- .../net/xolt/freecam/config/ModConfig.java | 2 +- .../xolt/freecam/config/VariantTooltip.java | 5 +-- 7 files changed, 122 insertions(+), 36 deletions(-) create mode 100644 buildSrc/src/main/kotlin/net/xolt/freecam/gradle/VariantTooltipProcessor.kt create mode 100644 buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ProcessorTest.kt create mode 100644 buildSrc/src/test/kotlin/net/xolt/freecam/gradle/VariantTooltipProcessorTest.kt diff --git a/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt index 6ad7c53d..4130aec6 100644 --- a/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt +++ b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt @@ -61,7 +61,7 @@ abstract class LangTask : DefaultTask() { private val json = Json { prettyPrint = true } private val localeRegex = "^[a-z]{2}-[A-Z]{2}$".toRegex() private val processors = listOf( - + VariantTooltipProcessor() ) init { diff --git a/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/VariantTooltipProcessor.kt b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/VariantTooltipProcessor.kt new file mode 100644 index 00000000..774f2cde --- /dev/null +++ b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/VariantTooltipProcessor.kt @@ -0,0 +1,39 @@ +package net.xolt.freecam.gradle + +internal class VariantTooltipProcessor : LangProcessor { + private val variantRegex = "\\.@(?[^.]+)Tooltip(?\\[\\d+])?${'$'}".toRegex() + + override fun process( + modID: String, + variant: String, + translations: Map, + fallback: Map? + ): Map { + val map = translations.toMutableMap() + // Iterate over fallback values, to ensure variant-tooltips aren't accidentally overridden due to missing translations + fallback?.forEach { (key, _) -> + variantRegex.find(key)?.let { result -> + map.remove(key) + map.remove(baseKey(key, result)) + } + } + // Then overwrite with actual values + translations.forEach { (key, value) -> + variantRegex.find(key)?.let { result -> + // This is normally handled by the first loop, but fallback is nullable... + map.remove(key) + + // Add the variant translation + if (variant == result.groups["variant"]?.value?.lowercase()) { + map[baseKey(key, result)] = value + } + } + } + return map + } + + private fun baseKey(variantKey: String, result: MatchResult): String { + val index = result.groups["index"]?.value ?: "" + return variantKey.replaceAfterLast('.', "@Tooltip${index}") + } +} diff --git a/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ProcessorTest.kt b/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ProcessorTest.kt new file mode 100644 index 00000000..c27f7939 --- /dev/null +++ b/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ProcessorTest.kt @@ -0,0 +1,22 @@ +package net.xolt.freecam.gradle + +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.function.Executable +import kotlin.test.assertEquals + +/** + * Declares a test for [LangProcessor] behavior. + */ +internal data class ProcessorTest( + val name: String, + val modID: String = "modid", + val variant: String = "normal", + val translations: Map, + val fallback: Map? = null, + val result: Map, +) { + fun buildTest(test: Executable): DynamicTest = DynamicTest.dynamicTest(name, test) + fun buildProcessorTest(processor: LangProcessor): DynamicTest = this.buildTest { + assertEquals(result, processor.process(modID, variant, translations, fallback)) + } +} \ No newline at end of file diff --git a/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/VariantTooltipProcessorTest.kt b/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/VariantTooltipProcessorTest.kt new file mode 100644 index 00000000..29c7352a --- /dev/null +++ b/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/VariantTooltipProcessorTest.kt @@ -0,0 +1,44 @@ +package net.xolt.freecam.gradle + +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import kotlin.test.BeforeTest + +class VariantTooltipProcessorTest { + + private lateinit var processor: LangProcessor + + @BeforeTest + fun setup() { + processor = VariantTooltipProcessor() + } + + @TestFactory + fun `Basic tests`(): List { + val twoVariants = mapOf( + "foo.@NormalTooltip" to "normal", + "foo.@SpecialTooltip" to "special" + ) + + return listOf( + ProcessorTest( + name = "Normal tooltip is used", + translations = twoVariants, + result = mapOf("foo.@Tooltip" to "normal") + ), + ProcessorTest( + name = "Normal tooltip is removed", + variant = "other", + translations = twoVariants, + result = emptyMap() + ), + ProcessorTest( + name = "Special tooltip is used", + variant = "special", + translations = twoVariants, + result = mapOf("foo.@Tooltip" to "special") + ) + ).map { it.buildProcessorTest(processor) } + } + +} \ No newline at end of file diff --git a/common/src/main/java/net/xolt/freecam/config/ConfigExtensions.java b/common/src/main/java/net/xolt/freecam/config/ConfigExtensions.java index 21082700..c90f68f4 100644 --- a/common/src/main/java/net/xolt/freecam/config/ConfigExtensions.java +++ b/common/src/main/java/net/xolt/freecam/config/ConfigExtensions.java @@ -7,16 +7,11 @@ import me.shedaniel.clothconfig2.api.AbstractConfigListEntry; import me.shedaniel.clothconfig2.gui.entries.TextListEntry; import me.shedaniel.clothconfig2.gui.entries.TooltipListEntry; -import net.minecraft.locale.Language; import net.minecraft.network.chat.Component; import net.xolt.freecam.variant.api.BuildVariant; -import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.Contract; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.function.Supplier; import java.util.stream.IntStream; @@ -52,7 +47,7 @@ public static void init(GuiRegistry registry) { } /** - * Adds the correct tooltip for the current build environment to the specified GUIs that support tooltips. + * Adds the tooltip to the specified GUIs that {@link TooltipListEntry support tooltips}. *

* Note: as-per {@link ConfigEntry.Gui.Tooltip}, tooltips will not be added to {@link TextListEntry}s. * @@ -64,12 +59,11 @@ public static void init(GuiRegistry registry) { @Contract(mutates = "param1") private static void applyVariantTooltip(List guis, List tooltipVariants, String i18n) { String variant = BuildVariant.getInstance().name(); - List variants = List.of(variant, "all"); // Number of tooltip lines defined for the current build variant (or "all") // (throw if there isn't exactly one matching definition) int count = tooltipVariants.stream() - .filter(entry -> variants.contains(entry.variant())) + .filter(entry -> Objects.equals(variant, entry.variant())) .mapToInt(VariantTooltip::count) .reduce((prev, next) -> { throw new IllegalArgumentException("%s: Multiple variants matching \"%s\" declared on \"%s\".".formatted(VariantTooltip.class.getSimpleName(), variant, i18n)); @@ -82,19 +76,18 @@ private static void applyVariantTooltip(List guis, List .filter(gui -> !(gui instanceof TextListEntry)) .filter(TooltipListEntry.class::isInstance) .map(gui -> (TooltipListEntry) gui) - .forEach(gui -> gui.setTooltipSupplier(getVariantTooltip(variant, i18n, count))); + .forEach(gui -> gui.setTooltipSupplier(getTooltip(i18n, count))); } /** - * Generates a tooltip supplier for the given variant & base i18n key combination. + * Generates a tooltip supplier for the given base i18n key. * - * @param variant the current build variant. * @param i18n the config entry's translation key. * @param count the number of lines in the tooltip. * @return A tooltip supplier accepted by {@link TooltipListEntry#setTooltipSupplier(Supplier)}. * @see TooltipListEntry */ - private static Supplier> getVariantTooltip(String variant, String i18n, int count) { + private static Supplier> getTooltip(String i18n, int count) { if (count == 0) { return Optional::empty; } @@ -102,39 +95,28 @@ private static Supplier> getVariantTooltip(String variant, // We can cache the tooltip since language can't change while config GUI is open. Optional tooltip; if (count == 1) { - tooltip = Optional.of(new Component[] { getVariantTooltipLine(variant, i18n, -1) }); + tooltip = Optional.of(new Component[] { getTooltipLine(i18n, -1) }); } else { tooltip = Optional.of(IntStream.range(0, count) - .mapToObj(i -> getVariantTooltipLine(variant, i18n, i)) + .mapToObj(i -> getTooltipLine(i18n, i)) .toArray(Component[]::new)); } return () -> tooltip; } /** - * Generates a tooltip line for the given variant, base i18n key & line index combination. - *

- * Falls back to the default {@code @Tooltip} line if no key exists for the specified variant. + * Generates a tooltip line for the given base i18n key & line index combination. * - * @param variant the current build variant. * @param i18n the config entry's translation key. * @param index the line's index (or {@code -1}). * @return A line of {@link Component text} to be included in a wider tooltip. - * @see #getVariantTooltip(String, String, int) + * @see #getTooltip(String, int) */ - private static Component getVariantTooltipLine(String variant, String i18n, int index) { - String key = "%s.@%sTooltip".formatted(i18n, StringUtils.capitalize(variant)); + private static Component getTooltipLine(String i18n, int index) { + String key = "%s.@Tooltip".formatted(i18n); if (index > -1) { key += "[%d]".formatted(index); } - // FIXME how will this behave for untranslated languages? - if (Language.getInstance().has(key)) { - return Component.translatable(key); - } - if (variant.isEmpty()) { - return Component.empty(); - } - // Fallback to default "@Tooltip" translation - return getVariantTooltipLine("", i18n, index); + return Component.translatable(key); } } diff --git a/common/src/main/java/net/xolt/freecam/config/ModConfig.java b/common/src/main/java/net/xolt/freecam/config/ModConfig.java index 8b00c2d8..6a9cb67c 100644 --- a/common/src/main/java/net/xolt/freecam/config/ModConfig.java +++ b/common/src/main/java/net/xolt/freecam/config/ModConfig.java @@ -83,7 +83,7 @@ public static class UtilityConfig { @ConfigEntry.Gui.Tooltip public boolean disableOnDamage = true; - @VariantTooltip(count = 2) + @ConfigEntry.Gui.Tooltip(count = 2) public boolean freezePlayer = false; @VariantTooltip(variant = "normal", count = 2) diff --git a/common/src/main/java/net/xolt/freecam/config/VariantTooltip.java b/common/src/main/java/net/xolt/freecam/config/VariantTooltip.java index 7ba4128d..e9d7a95a 100644 --- a/common/src/main/java/net/xolt/freecam/config/VariantTooltip.java +++ b/common/src/main/java/net/xolt/freecam/config/VariantTooltip.java @@ -7,8 +7,7 @@ /** * Applies a tooltip to list entries that support it, defined in your lang file. *

- * Will try to use translations defined for the current build variant (e.g. {@code @ModrinthTooltip}), but will - * fall back to using the default {@code @Tooltip} translations if variant-specific ones are not defined. + * Should be used over {@link ConfigEntry.Gui.Tooltip} when {@link #count()} varies between variants. *

* Can be declared multiple times on the same field. * @@ -19,7 +18,7 @@ @Repeatable(VariantTooltip.List.class) public @interface VariantTooltip { - String variant() default "all"; + String variant(); int count() default 1; From 6e67fbbcc522e3d74b00a348f7a4f840d39ee5a6 Mon Sep 17 00:00:00 2001 From: Matt Sturgeon Date: Wed, 7 Feb 2024 14:39:07 +0000 Subject: [PATCH 5/5] Support translating name & description Move name & description definition from `gradle.properties` to a lang file in `:data`. Introduce `ModNameProcessor` & `ModDescriptionProcessor`, along with a new method `LangTask.getTranslation()` which can be used to get a specific translation after the task has finished running. Make use of this in `:data` to get the name & description from the translations into the mod metadata file. Fixes #172 --- CHANGELOG.md | 2 + .../net/xolt/freecam/gradle/LangTask.kt | 32 ++++++++++- .../freecam/gradle/ModDescriptionProcessor.kt | 40 ++++++++++++++ .../xolt/freecam/gradle/ModNameProcessor.kt | 37 +++++++++++++ .../gradle/ModDescriptionProcessorTest.kt | 45 +++++++++++++++ .../freecam/gradle/ModNameProcessorTest.kt | 43 +++++++++++++++ data/build.gradle | 55 ++++++++++--------- data/src/lang/en-US/mod.json | 6 ++ gradle.properties | 3 - 9 files changed, 233 insertions(+), 30 deletions(-) create mode 100644 buildSrc/src/main/kotlin/net/xolt/freecam/gradle/ModDescriptionProcessor.kt create mode 100644 buildSrc/src/main/kotlin/net/xolt/freecam/gradle/ModNameProcessor.kt create mode 100644 buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ModDescriptionProcessorTest.kt create mode 100644 buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ModNameProcessorTest.kt create mode 100644 data/src/lang/en-US/mod.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e654726e..39096bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and Freecam's versioning is based on [Semantic Versioning](https://semver.org/sp ### Added +- \[Fabric]: The name & description shown in the Mod Menu can now be translated ([#172](https://github.com/MinecraftFreecam/Freecam/issues/172)). + ### Changed ### Removed diff --git a/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt index 4130aec6..4101cc86 100644 --- a/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt +++ b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt @@ -60,8 +60,10 @@ abstract class LangTask : DefaultTask() { private val json = Json { prettyPrint = true } private val localeRegex = "^[a-z]{2}-[A-Z]{2}$".toRegex() - private val processors = listOf( - VariantTooltipProcessor() + private val processors = listOf( + VariantTooltipProcessor(), + ModDescriptionProcessor(), + ModNameProcessor() ) init { @@ -86,6 +88,32 @@ abstract class LangTask : DefaultTask() { } } + /** + * Get the given translation, for the given language. + * + * Will fall back to using the [source language][source] if the key isn't + * found in the specified language or if language isn't specified. + * + * Should only be used **after** this task has finished executing. + * I.e. **not** during Gradle's configuration step. + * + * @param key the translation key + * @param lang the locale, e.g. en-US, en_us, or zh-CN + * @return the translation, or null if not found + */ + @JvmOverloads + fun getTranslation(key: String, lang: String = source.get()): String? { + val file = fileFor(lang) + val translation = readJsonFile(file)[key] + + // Check "source" translation if key wasn't found + return if (translation == null && file != fileFor(source.get())) { + getTranslation(key) + } else { + translation + } + } + private fun fileFor(lang: String) = outputDirectory.get().asFile .resolve("assets") .resolve(modId.get()) diff --git a/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/ModDescriptionProcessor.kt b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/ModDescriptionProcessor.kt new file mode 100644 index 00000000..9c689b9b --- /dev/null +++ b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/ModDescriptionProcessor.kt @@ -0,0 +1,40 @@ +package net.xolt.freecam.gradle + +internal class ModDescriptionProcessor : LangProcessor { + override fun process( + modID: String, + variant: String, + translations: Map, + fallback: Map? + ): Map { + val firstID = "${modID}.description" + val secondID = "${modID}.description.${variant}" + val ids = listOf(firstID, secondID) + + // Nothing to do if this language has no "description" translations + if (ids.none(translations.keys::contains)) { + return translations + } + + val map = translations.toMutableMap() + + // Remove any description.variant keys + map.keys + .filter { it.startsWith("${firstID}.") } + .forEach(map::remove) + + // Set modmenu summary if this language has a translation for firstID + translations[firstID]?.let { map["modmenu.summaryTranslation.${modID}"] = it } + + // Set "full" description + // Use fallback if either part is missing from this language + ids.mapNotNull { translations[it] ?: fallback?.get(it) } + .joinToString(" ") + .let { description -> + map[firstID] = description + map["modmenu.descriptionTranslation.${modID}"] = description + } + + return map + } +} diff --git a/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/ModNameProcessor.kt b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/ModNameProcessor.kt new file mode 100644 index 00000000..fa1ca5c7 --- /dev/null +++ b/buildSrc/src/main/kotlin/net/xolt/freecam/gradle/ModNameProcessor.kt @@ -0,0 +1,37 @@ +package net.xolt.freecam.gradle + +internal class ModNameProcessor : LangProcessor { + override fun process( + modID: String, + variant: String, + translations: Map, + fallback: Map? + ): Map { + val firstID = "${modID}.name" + val secondID = "${modID}.name.${variant}" + val ids = listOf(firstID, secondID) + + // Nothing to do if this language has no "name" translations + if (ids.none(translations.keys::contains)) { + return translations + } + + val map = translations.toMutableMap() + + // Remove any name.variant keys + map.keys + .filter { it.startsWith("${firstID}.") } + .forEach(map::remove) + + // Set "full" name + // Use fallback if either part is missing from this language + ids.mapNotNull { translations[it] ?: fallback?.get(it) } + .joinToString(" ") + .let { name -> + map[firstID] = name + map["modmenu.nameTranslation.${modID}"] = name + } + + return map + } +} diff --git a/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ModDescriptionProcessorTest.kt b/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ModDescriptionProcessorTest.kt new file mode 100644 index 00000000..15f6c608 --- /dev/null +++ b/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ModDescriptionProcessorTest.kt @@ -0,0 +1,45 @@ +package net.xolt.freecam.gradle + +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import kotlin.test.BeforeTest + +class ModDescriptionProcessorTest { + + private lateinit var processor: LangProcessor + + @BeforeTest + fun setup() { + processor = ModDescriptionProcessor() + } + + @TestFactory + fun `Basic tests`(): List { + val sample1 = mapOf( + "modid.description" to "Default description", + "modid.description.special" to "(extra special)" + ) + + return listOf( + ProcessorTest( + name = "Discard variant descriptions", + translations = sample1, + result = mapOf( + "modid.description" to "Default description", + "modmenu.descriptionTranslation.modid" to "Default description", + "modmenu.summaryTranslation.modid" to "Default description" + ) + ), + ProcessorTest( + name = "Append \"extra special\" to description", + variant = "special", + translations = sample1, + result = mapOf( + "modid.description" to "Default description (extra special)", + "modmenu.descriptionTranslation.modid" to "Default description (extra special)", + "modmenu.summaryTranslation.modid" to "Default description" + ) + ) + ).map { it.buildProcessorTest(processor) } + } +} \ No newline at end of file diff --git a/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ModNameProcessorTest.kt b/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ModNameProcessorTest.kt new file mode 100644 index 00000000..af0f1e2c --- /dev/null +++ b/buildSrc/src/test/kotlin/net/xolt/freecam/gradle/ModNameProcessorTest.kt @@ -0,0 +1,43 @@ +package net.xolt.freecam.gradle + +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import kotlin.test.BeforeTest + +class ModNameProcessorTest { + + private lateinit var processor: LangProcessor + + @BeforeTest + fun setup() { + processor = ModNameProcessor() + } + + @TestFactory + fun `Basic tests`(): List { + val sample1 = mapOf( + "modid.name" to "ModName", + "modid.name.special" to "(extra special)" + ) + + return listOf( + ProcessorTest( + name = "Discard variant names", + translations = sample1, + result = mapOf( + "modid.name" to "ModName", + "modmenu.nameTranslation.modid" to "ModName" + ) + ), + ProcessorTest( + name = "Append \"extra special\" to name", + variant = "special", + translations = sample1, + result = mapOf( + "modid.name" to "ModName (extra special)", + "modmenu.nameTranslation.modid" to "ModName (extra special)" + ) + ) + ).map { it.buildProcessorTest(processor) } + } +} \ No newline at end of file diff --git a/data/build.gradle b/data/build.gradle index 80d34a24..1a9de661 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -25,24 +25,18 @@ variants.each { variant -> kebabName += "-$variant" } - def metadataTask = tasks.register("${name}GenMetadata", Copy) { + def metadataTask = tasks.register("${name}GenMetadata") { group = "metadata" description = { def file = platform.endsWith "forge" ? "mods.toml" : "${platform}.mods.json" return "Build $variant $file file" }() - // Include files under `src/platform` - from layout.projectDirectory.dir("src").dir(platform) - into layout.buildDirectory.dir("metadata/${kebabName}") - + // Depend on langTask and the following properties + dependsOn langTask inputs.properties( - mod_id: rootProject.name, - modrinth_name: project.modrinth_name, version: project.mod_version, authors: project.authors, - description: project.description, - modrinth_description: project.modrinth_description, licence: project.licence, homepage_url: project.homepage_url, source_code_url: project.source_code_url, @@ -54,22 +48,33 @@ variants.each { variant -> neoforge_req: project.neoforge_req, ) - def overrides = new TreeMap() - overrides.mod_id = inputs.properties.mod_id.toLowerCase() - overrides.name = overrides.mod_id.capitalize() - overrides.json_authors = inputs.properties.authors.split(',').collect { "\"$it\"" }.join(", ") - - if (variant == "modrinth") { - overrides.name += ' ' + inputs.properties.modrinth_name - overrides.description = inputs.properties.description + ' ' + inputs.properties.modrinth_description - } - - filesMatching("fabric.mod.json") { - expand inputs.properties + overrides - } - - filesMatching("META-INF/mods.toml") { - expand inputs.properties + overrides + def inputDir = layout.projectDirectory.dir("src").dir(platform) + def outputDir = layout.buildDirectory.dir("metadata/${kebabName}") + + // Declare task outputs so they can be used by other tasks + outputs.dir outputDir + + // Need to do this at execution time; + // LangTask.getTranslation() can't be used at configuration time... + doLast { + def values = new TreeMap(inputs.properties) + values.mod_id = rootProject.name.toLowerCase() + values.name = langTask.get().getTranslation("freecam.name") + values.description = langTask.get().getTranslation("freecam.description") + values.json_authors = values.authors.split(',').collect { "\"$it\"" }.join(", ") + + copy { + from inputDir + into outputDir + + filesMatching("fabric.mod.json") { + expand values + } + + filesMatching("META-INF/mods.toml") { + expand values + } + } } } diff --git a/data/src/lang/en-US/mod.json b/data/src/lang/en-US/mod.json new file mode 100644 index 00000000..8b682b10 --- /dev/null +++ b/data/src/lang/en-US/mod.json @@ -0,0 +1,6 @@ +{ + "freecam.name": "Freecam", + "freecam.name.modrinth": "(Modrinth Edition)", + "freecam.description": "A highly customizable freecam mod.", + "freecam.description.modrinth": "Some features have been restricted to comply with Modrinth's Content Rules." +} diff --git a/gradle.properties b/gradle.properties index d3b2b248..386ed021 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,9 +3,6 @@ org.gradle.jvmargs=-Xmx4G mod_version=1.2.3 maven_group=net.xolt.freecam authors=hashalite,Matt Sturgeon -description=A highly customizable freecam mod. -modrinth_name=(Modrinth Edition) -modrinth_description=Some features have been restricted to comply with Modrinth's Content Rules. licence=MIT homepage_url=https://www.curseforge.com/minecraft/mc-mods/free-cam source_code_url=https://github.com/MinecraftFreecam/Freecam