Skip to content

feat: localization / translation api #3

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

Open
wants to merge 5 commits into
base: master
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: 0 additions & 2 deletions .github/workflows/jitpack.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
name: Trigger Jitpack Build
on:
push:
branches: [ master ]
workflow_dispatch:

jobs:
build:
Expand Down
7 changes: 6 additions & 1 deletion core/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ dependencies {
shadow("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
shadow("com.charleskorn.kaml:kaml:0.53.0")

// Config
@Suppress("VulnerableLibrariesLocal", "RedundantSuppression")
shadow("org.yaml:snakeyaml:1.33")

// Math
shadow("org.joml:joml:1.10.5")

// Adventure
shadow("net.kyori:adventure-api:4.12.0")
shadow("net.kyori:adventure-api:4.13.1")
shadow("net.kyori:adventure-text-minimessage:4.13.1")

// Commands
shadow("cloud.commandframework:cloud-core:1.8.3")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Catalyst - Minecraft plugin development toolkit
* Copyright (C) 2023 Koding Development
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.koding.catalyst.core.common.api.messages

import org.yaml.snakeyaml.Yaml
import java.io.InputStream
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap

/**
* Localization provides a way to store translations for a plugin.
* It is a collection of [Translation] objects, which are collections of [Message] objects.
*
* It is recommended to use the [loadFromFile] method to load translations from a YAML file,
* but it is also possible to manually register translations. (Though we do not recommend this)
*
* @see Translation
*/
class Localization {

companion object {
/**
* The default locale to use when no other locale is specified.
*/
val DEFAULT_LOCALE: Locale = Locale.US
}

/**
* The translations for this instance.
*/
internal val translations = ConcurrentHashMap<String, Translation>()

/**
* Load the translations from a YAML file.
*
* @param locale The locale of the file
* @param stream The input stream to read from
*/
fun loadFromFile(locale: Locale, stream: InputStream) {
val data = Yaml().load<Map<String, Any>>(stream)
load(locale, data)
}

/**
* Load the translations from a map.
*
* @param data The data to parse
* @param path The path of the data, used for building message keys separated by a "."
*/
@Suppress("UNCHECKED_CAST")
private fun load(locale: Locale, data: Map<String, Any>, path: String = "") {
data.forEach { (key, value) ->
// If the value is a map, parse it recursively
if (value is Map<*, *>) {
load(locale, value as Map<String, Any>, "$path.$key")
} else {
// Build the message key
val msgKey = "$path.$key".removePrefix(".")

// Get the translation
val translation = translations.computeIfAbsent(msgKey) { Translation(msgKey) }

// Determine the message type
val message = when (value) {
is String -> Message(arrayOf(value))
is Array<*> -> Message(value as Array<String>)
is List<*> -> Message((value as List<String>).toTypedArray())
else -> throw IllegalArgumentException("Invalid message type for key $msgKey")
}
translation.register(locale, message)
}
}
}

/**
* Get a translation.
*
* @param key The key of the message
* @return The message
* @throws IllegalArgumentException If the message does not exist
*/
operator fun get(key: String): Translation =
translations[key] ?: throw IllegalArgumentException("No translation for key $key")

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Catalyst - Minecraft plugin development toolkit
* Copyright (C) 2023 Koding Development
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.koding.catalyst.core.common.api.messages

import dev.koding.catalyst.core.common.loader.PlatformLoader
import dev.koding.catalyst.core.common.util.ext.saveResources
import net.kyori.adventure.translation.Translator
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.singleton
import kotlin.io.path.exists
import kotlin.io.path.inputStream
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.nameWithoutExtension
import kotlin.math.round

object LocalizationModule {
fun of(plugin: PlatformLoader) = DI.Module("Localization-${plugin::class.java.name}") {
// Parse the messages from the lang folder
val langFolder = plugin.dataDirectory.resolve("lang")
plugin.saveResources("lang")

// Create a localization instance
val localization = Localization()

if (langFolder.exists()) {
// Load the message files from the lang folder
langFolder.listDirectoryEntries("*.yml")
.mapNotNull { it to (Translator.parseLocale(it.nameWithoutExtension) ?: return@mapNotNull null) }
.forEach { (path, locale) -> localization.loadFromFile(locale, path.inputStream()) }

// Sum the amount of total localizations and group them by locale
val localizations = localization.translations.values
.flatMap { it.formats.keys }
.groupingBy { it }
.eachCount()

val total = localization.translations.size
plugin.logger.info(
"Loaded $total translations: ${
localizations.entries.joinToString {
"${it.key.displayName} (${round(it.value / total.toDouble() * 100.0).toInt()}%)"
}
}"
)
}

// Bind the message files to the DI
bind<Localization> { singleton { localization } }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Catalyst - Minecraft plugin development toolkit
* Copyright (C) 2023 Koding Development
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.koding.catalyst.core.common.api.messages

import dev.koding.catalyst.core.common.util.Components
import net.kyori.adventure.audience.Audience
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.ComponentLike
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver

/**
* A message object that can be used to parse a message with placeholders.
* The messages are stored in a map with the locale as the key and the message as the value.
*/
class Message(private val messages: Array<String>) {

/**
* Pre-parsed messages to lookup when no placeholders are supplied.
*/
private val processed = messages.map { Components.parse(it) }

/**
* Get a message from this message object.
*
* @param index The index of the message to get
* @param placeholders The placeholders to replace in the message
* @return The message
*/
fun get(index: Int, vararg placeholders: TagResolver): Component {
val message = messages.getOrNull(index) ?: return Component.empty()

return if (placeholders.isEmpty()) Components.parse(message)
else Components.parse(message, *placeholders)
}

/**
* Get the first message from this message object.
*
* @param placeholders The placeholders to replace in the message
* @return The message
*/
fun first(vararg placeholders: TagResolver): Component = get(0, *placeholders)

/**
* Get all the components from this message object.
*
* @param placeholders The placeholders to replace in the message
* @return The components
*/
@Suppress("MemberVisibilityCanBePrivate")
fun all(vararg placeholders: TagResolver): List<Component> =
if (placeholders.isEmpty()) processed
else messages.map { Components.parse(it, *placeholders) }

/**
* Send the message to an audience.
*
* @param audience The audience to send the message to
* @param placeholders The placeholders to replace in the message
*/
@Suppress("unused")
fun send(audience: Audience, vararg placeholders: TagResolver): Unit =
all(*placeholders).forEach { audience.sendMessage(it) }

}

/**
* Create an unparsed placeholder.
*
* @param value The value of the placeholder
*/
@Suppress("unused")
infix fun String.unparsed(value: String): TagResolver = Placeholder.unparsed(this, value)

/**
* Create a parsed placeholder.
*
* @param value The value of the placeholder
*/
@Suppress("unused")
infix fun String.parsed(value: String): TagResolver = Placeholder.parsed(this, value)

/**
* Create a component placeholder.
*
* @param value The value of the placeholder
*/
infix fun String.component(value: ComponentLike): TagResolver = Placeholder.component(this, value)

// TODO: Styling placeholder
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Catalyst - Minecraft plugin development toolkit
* Copyright (C) 2023 Koding Development
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.koding.catalyst.core.common.api.messages

import net.kyori.adventure.audience.Audience
import net.kyori.adventure.identity.Identity
import net.kyori.adventure.pointer.Pointered
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap

class Translation(val key: String) {

/**
* A map of all the messages for this translation.
*/
internal val formats = ConcurrentHashMap<Locale, Message>()

/**
* The default message for this translation.
*/
val default get() = get(Localization.DEFAULT_LOCALE)

/**
* Register a message for this translation.
*
* @param locale The locale of the message
* @param message The message
*/
fun register(locale: Locale, message: Message) {
formats[locale] = message
}

/**
* Get a message for this translation.
*
* @param locale The locale to translate for
* @throws IllegalArgumentException If no message is found for the given locales
*/
operator fun get(locale: Locale): Message =
formats[locale]
?: formats[Locale(locale.language)] // try without country
?: formats[Localization.DEFAULT_LOCALE] // try local default locale
?: throw IllegalArgumentException("No message found for key $key in locale ${locale.toLanguageTag()} or default locale ${Localization.DEFAULT_LOCALE.toLanguageTag()}")

/**
* Get a message for this translation based on the current locale
* of the target.
*
* @param target The target to get the locale from
*/
@JvmName("getFromPointered")
operator fun get(target: Pointered): Message =
get(target.get(Identity.LOCALE).orElse(Localization.DEFAULT_LOCALE))

/**
* Send a message to the audience based on their locales.
*
* @param audience The audience to send the message to
*/
@Suppress("unused")
fun send(audience: Audience, vararg placeholders: TagResolver): Unit =
audience.forEachAudience { get(it).send(it, *placeholders) }

}
Loading