Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate lang files at build time #174

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/crowdin-upload-sources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/crowdin-upload-translations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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()
}
5 changes: 5 additions & 0 deletions buildSrc/src/main/kotlin/net/xolt/freecam/extensions/File.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package net.xolt.freecam.extensions

import java.io.File

fun File.childDirectories(): Sequence<File> = listFiles { file -> file.isDirectory }?.asSequence() ?: emptySequence()
10 changes: 10 additions & 0 deletions buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangProcessor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.xolt.freecam.gradle

internal fun interface LangProcessor {
fun process(
modID: String,
variant: String,
translations: Map<String, String>,
fallback: Map<String, String>?
): Map<String, String>
}
152 changes: 152 additions & 0 deletions buildSrc/src/main/kotlin/net/xolt/freecam/gradle/LangTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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<String>

/**
* The "source" language that translations are based on.
*
* Defaults to `"en-US"`.
*/
@get:Input
abstract val source: Property<String>

/**
* The mod ID.
*
* Used in the output file structure as well as some translation keys.
*/
@get:Input
abstract val modId: Property<String>

private val json = Json { prettyPrint = true }
private val localeRegex = "^[a-z]{2}-[A-Z]{2}$".toRegex()
private val processors = listOf(
VariantTooltipProcessor(),
ModDescriptionProcessor(),
ModNameProcessor()
)

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())
}
}

/**
* 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())
.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<String, String>, fallback: Map<String, String>?) =
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<String, String> = json.decodeFromStream(file.inputStream())

@OptIn(ExperimentalSerializationApi::class)
private fun writeJsonFile(file: File, translations: Map<String, String>) {
file.parentFile.mkdirs()
file.createNewFile()
json.encodeToStream(translations, file.outputStream())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package net.xolt.freecam.gradle

internal class ModDescriptionProcessor : LangProcessor {
override fun process(
modID: String,
variant: String,
translations: Map<String, String>,
fallback: Map<String, String>?
): Map<String, String> {
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package net.xolt.freecam.gradle

internal class ModNameProcessor : LangProcessor {
override fun process(
modID: String,
variant: String,
translations: Map<String, String>,
fallback: Map<String, String>?
): Map<String, String> {
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package net.xolt.freecam.gradle

internal class VariantTooltipProcessor : LangProcessor {
private val variantRegex = "\\.@(?<variant>[^.]+)Tooltip(?<index>\\[\\d+])?${'$'}".toRegex()

override fun process(
modID: String,
variant: String,
translations: Map<String, String>,
fallback: Map<String, String>?
): Map<String, String> {
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}")
}
}
Original file line number Diff line number Diff line change
@@ -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<DynamicTest> {
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) }
}
}
Loading