Commit febe1b83 authored by Setako's avatar Setako

[Add] I18n support

parent 2f11ca46
......@@ -12,6 +12,10 @@ object ReactantPermissions : PermissionNode("reactant") {
}
object PROFILER : PermissionNode("profiler")
object I18N : PermissionNode("i18n") {
object LIST : PermissionNode(child("list"))
object GENERATE : PermissionNode(child("generate"))
}
}
}
}
package dev.reactant.reactant.example
import dev.reactant.reactant.extra.i18n.I18n
import dev.reactant.reactant.extra.i18n.I18nTable
@I18n("HelloTable")
open class HelloI18nTable : I18nTable {
open fun blamePlayer(playerName: String, itemName: Int) = "$playerName destroyed the $itemName! ;C"
}
package dev.reactant.reactant.extra.i18n
data class GlobalI18nConfig(
val languages: List<String> = listOf("en")
)
package dev.reactant.reactant.extra.i18n
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FILE)
annotation class I18n(
val name: String,
val description: String = ""
)
package dev.reactant.reactant.extra.i18n
import I18nTranslation
import dev.reactant.reactant.core.ReactantCore
import dev.reactant.reactant.core.component.Component
import dev.reactant.reactant.core.component.container.ContainerManager
import dev.reactant.reactant.core.component.lifecycle.LifeCycleHook
import dev.reactant.reactant.service.spec.config.ConfigService
import dev.reactant.reactant.service.spec.config.get
import dev.reactant.reactant.service.spec.parser.JsonParserService
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import javassist.ClassClassPath
import javassist.ClassPool
import javassist.CtNewMethod
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.HashMap
import kotlin.collections.HashSet
import kotlin.reflect.KClass
import kotlin.reflect.full.createInstance
import kotlin.reflect.full.declaredMemberFunctions
import kotlin.reflect.jvm.jvmErasure
typealias LanguageTableClass = KClass<out I18nTable>
@Component
class I18nService(
private val configService: ConfigService,
private val jsonParserService: JsonParserService,
private val containerManager: ContainerManager
) : LifeCycleHook {
private val _tableClass: HashSet<LanguageTableClass> = HashSet()
val tableClasses: Set<LanguageTableClass> get() = _tableClass
private val defaultTable = ConcurrentHashMap<LanguageTableClass, Any>()
private val cachedLanguageTables = ConcurrentHashMap<LanguageTableClass, HashMap<String, Any?>>()
override fun onEnable() {
// Load all I18nTable interfaces
@Suppress("UNCHECKED_CAST")
containerManager.containers
.flatMap { it.reflections.getTypesAnnotatedWith(I18n::class.java) }
.map { it.kotlin as KClass<out I18nTable> }
.filter {
if (it.isFinal) {
ReactantCore.logger.error("I18n Table must be a open parameterless concrete class: ${it}")
false
} else kotlin.runCatching {
defaultTable[it] = it.createInstance()
}.onFailure { e ->
ReactantCore.logger.error("I18n Table must be a open parameterless concrete class: ${it}")
}.isSuccess
}
.also { _tableClass.addAll(it) }
.forEach { cachedLanguageTables[it] = HashMap() }
}
fun getLanguageFilePath(table: LanguageTableClass, languageCode: String): String {
val languagePath = "${table.qualifiedName!!.replace('.', '/')}/$languageCode.json"
return "${ReactantCore.configDirPath}/i18n/tables/$languagePath"
}
@Suppress("UNCHECKED_CAST")
private fun <T : I18nTable> findLanguage(tableClass: KClass<T>, languageCode: String): Maybe<T> {
if (cachedLanguageTables[tableClass]!!.containsKey(languageCode))
return Maybe.fromOptional(Optional.ofNullable(cachedLanguageTables[tableClass]!![languageCode]?.let { it as T }))
.subscribeOn(Schedulers.trampoline())
return Maybe.defer {
Maybe.fromOptional(Optional.ofNullable(cachedLanguageTables[tableClass]!![languageCode]?.let { it as T }))
}.switchIfEmpty(Maybe.defer {
configService.get<I18nTranslation>(jsonParserService, getLanguageFilePath(tableClass, languageCode))
.map { convertTranslationToLanguageTable(languageCode, it.content, tableClass) }
.switchIfEmpty(Maybe.defer {
// fallback
if (languageCode.contains("_"))
findLanguage(tableClass, languageCode.split("_")[0])
else Maybe.empty<T>()
})
.doOnSuccess { cachedLanguageTables[tableClass]!![languageCode] = it }
})
}
private fun <T : I18nTable> convertTranslationToLanguageTable(languageCode: String, translation: I18nTranslation, target: KClass<T>): T {
val classPool = ClassPool.getDefault()
classPool.insertClassPath(ClassClassPath(target.java))
val tableCtClass = classPool.get(target.java.canonicalName)
val languageTableCtClass = classPool.makeClass(tableCtClass.name + "_" + languageCode, tableCtClass)
target.declaredMemberFunctions.forEach { langFun ->
if (translation.translations.containsKey(langFun.name)) {
// Replace translation's placeholder as parameter
var translationResult = "\"${translation.translations[langFun.name]!!.replace("\"", "\\\"")}\""
langFun.parameters.drop(1)
.forEach { param -> translationResult = translationResult.replace("\$${param.name}", "\"+${param.name}+\"") }
// Create method parameters
val parameters = langFun.parameters.drop(1).map { "${it.type.jvmErasure.java.canonicalName} ${it.name}" }.joinToString(",")
// Add method to the translation class
CtNewMethod.make("public String ${langFun.name}($parameters) { return ${translationResult}; }",
languageTableCtClass).let { languageTableCtClass.addMethod(it) }
}
}
@Suppress("UNCHECKED_CAST")
return languageTableCtClass.toClass(target.java.classLoader).newInstance() as T;
}
@Suppress("UNCHECKED_CAST")
fun <T : I18nTable> getLanguage(tableClass: KClass<T>, languageCodes: List<String>): Single<T> {
if (!defaultTable.containsKey(tableClass))
throw IllegalArgumentException("I18n table class not registered, ${tableClass.qualifiedName}. " +
"Make sure you table class is annotated with @I18n and it is a open parameterless concrete class")
return Observable.fromIterable(languageCodes)
.flatMapMaybe { findLanguage(tableClass, it) }
.first(defaultTable[tableClass] as T)
}
inline fun <reified T : I18nTable> getLanguage(languageCodes: List<String>) = getLanguage(T::class, languageCodes)
}
package dev.reactant.reactant.extra.i18n
interface I18nTable {
}
package dev.reactant.reactant.extra.i18n
import dev.reactant.reactant.core.ReactantCore
import dev.reactant.reactant.core.component.Component
import dev.reactant.reactant.core.dependency.injection.Inject
import dev.reactant.reactant.core.dependency.injection.ProvideSubtype
import dev.reactant.reactant.service.spec.config.Config
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.jvm.jvmErasure
@Component
internal class I18nTableInjectableProvider(
private val i18nService: I18nService,
@Inject("${ReactantCore.configDirPath}/i18n/config.json") private val languageConfig: Config<GlobalI18nConfig>
) {
@ProvideSubtype(".*")
private fun getI18nTable(kType: KType, languageCode: String): I18nTable {
@Suppress("UNCHECKED_CAST")
val tableClass = kType.jvmErasure as KClass<out I18nTable>
return i18nService.getLanguage(tableClass,
if (languageCode == "") languageConfig.content.languages
else languageCode.split(",").map { it.trim() }
).blockingGet()
}
}
data class I18nTranslation(
val translations: HashMap<String, String> = hashMapOf()
)
package dev.reactant.reactant.extra.i18n.commands
import dev.reactant.reactant.core.commands.ReactantPermissions
import dev.reactant.reactant.extra.command.ReactantCommand
import picocli.CommandLine
@CommandLine.Command(
name = "i18n",
mixinStandardHelpOptions = true,
description = ["I18n related commands"]
)
internal class I18nCommand : ReactantCommand() {
override fun run() {
requirePermission(ReactantPermissions.ADMIN.DEV.I18N)
showUsage()
}
}
package dev.reactant.reactant.extra.i18n.commands
import I18nTranslation
import dev.reactant.reactant.core.ReactantCore
import dev.reactant.reactant.core.commands.ReactantPermissions
import dev.reactant.reactant.extra.command.ReactantCommand
import dev.reactant.reactant.extra.i18n.I18nService
import dev.reactant.reactant.extra.i18n.I18nTable
import dev.reactant.reactant.service.spec.config.ConfigService
import dev.reactant.reactant.service.spec.config.getOrDefault
import dev.reactant.reactant.service.spec.parser.JsonParserService
import dev.reactant.reactant.utils.PatternMatchingUtils
import picocli.CommandLine
import java.io.File
import java.util.regex.Pattern
import kotlin.collections.set
import kotlin.reflect.KClass
import kotlin.reflect.full.declaredMemberFunctions
@CommandLine.Command(
name = "generate",
aliases = ["gen"],
mixinStandardHelpOptions = true,
description = ["Generate i18n language table"]
)
internal class I18nGenerateTableCommand(
private val i18nService: I18nService,
private val jsonParserService: JsonParserService,
private val configService: ConfigService
) : ReactantCommand() {
@CommandLine.Option(names = ["-l", "--language"], paramLabel = "LANGUAGE_CODE",
description = ["Specify the language codes you want, default is 'en'"])
var languageCodes: ArrayList<String> = arrayListOf("en")
@CommandLine.Option(names = ["-f", "--force"],
description = ["Override and regenerate existing file"])
var force: Boolean = false
@CommandLine.Option(names = ["-p", "--pattern"], paramLabel = "REG_EXP",
description = ["Filtering I18n Table class canonical name by RegExp"])
var classNamePattern: Pattern? = null
@CommandLine.Parameters(arity = "0..*", paramLabel = "CLASS_NAME",
description = ["Filtering I18n Table class canonical name, wildcard is available"])
var classNameWildcards: ArrayList<String> = arrayListOf();
override fun run() {
requirePermission(ReactantPermissions.ADMIN.DEV.I18N.GENERATE)
i18nService.tableClasses.sortedBy { it.qualifiedName }
.filter { nameMatching(it.java.canonicalName) }
.forEach { tableClass: KClass<out I18nTable> ->
ReactantCore.logger.info("Generated ${generate(tableClass)} language file for ${tableClass.qualifiedName}")
}
}
private fun generate(tableClass: KClass<out I18nTable>): Int {
val translationPairs = tableClass.declaredMemberFunctions.map { it.name to it.parameters.drop(1).map { "\$${it.name}" }.joinToString(" ") }
languageCodes.map { code -> i18nService.getLanguageFilePath(tableClass, code) }
.filter { force || !File(it).exists() }
.onEach { path ->
val translationFile = configService.getOrDefault(jsonParserService, path) { I18nTranslation() }.blockingGet()
translationPairs.forEach { (key, value) ->
translationFile.content.translations[key] = value
}
translationFile.save().blockingAwait()
}.let { return it.size }
}
private fun nameMatching(canonicalName: String): Boolean =
(classNamePattern == null || classNamePattern!!.toRegex().matches(canonicalName)) &&
(classNameWildcards.isEmpty() || classNameWildcards
.any { wildcard -> PatternMatchingUtils.matchWildcard(wildcard, canonicalName) })
}
package dev.reactant.reactant.extra.i18n.commands
import dev.reactant.reactant.core.commands.ReactantPermissions
import dev.reactant.reactant.extra.command.ReactantCommand
import dev.reactant.reactant.extra.i18n.I18nService
import dev.reactant.reactant.utils.PatternMatchingUtils
import picocli.CommandLine
import java.util.regex.Pattern
@CommandLine.Command(
name = "ls",
aliases = ["list"],
mixinStandardHelpOptions = true,
description = ["List i18n language tables"]
)
internal class I18nListTableCommand(
private val i18nService: I18nService
) : ReactantCommand() {
@CommandLine.Option(names = ["-p", "--pattern"], paramLabel = "REG_EXP",
description = ["Filtering I18n Table class canonical name by RegExp"])
var classNamePattern: Pattern? = null
@CommandLine.Parameters(arity = "0..*", paramLabel = "CLASS_NAME",
description = ["Filtering I18n Table class canonical name, wildcard is available"])
var classNameWildcards: ArrayList<String> = arrayListOf();
override fun run() {
requirePermission(ReactantPermissions.ADMIN.DEV.I18N.LIST)
i18nService.tableClasses.sortedBy { it.qualifiedName }
.filter { nameMatching(it.java.canonicalName) }
.forEach { stdout.out(it.java.canonicalName) }
}
private fun nameMatching(canonicalName: String): Boolean =
(classNamePattern == null || classNamePattern!!.toRegex().matches(canonicalName)) &&
(classNameWildcards.isEmpty() || classNameWildcards
.any { wildcard -> PatternMatchingUtils.matchWildcard(wildcard, canonicalName) })
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment