diff --git a/.github/workflows/jitpack.yml b/.github/workflows/jitpack.yml index b60131f..11bc4a3 100644 --- a/.github/workflows/jitpack.yml +++ b/.github/workflows/jitpack.yml @@ -1,8 +1,6 @@ name: Trigger Jitpack Build on: push: - branches: [ master ] - workflow_dispatch: jobs: build: diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 94f8e5b..3490f7c 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -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") diff --git a/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/Localization.kt b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/Localization.kt new file mode 100644 index 0000000..21a2d9b --- /dev/null +++ b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/Localization.kt @@ -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 . + */ +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() + + /** + * 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>(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, path: String = "") { + data.forEach { (key, value) -> + // If the value is a map, parse it recursively + if (value is Map<*, *>) { + load(locale, value as Map, "$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) + is List<*> -> Message((value as List).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") + +} \ No newline at end of file diff --git a/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/LocalizationModule.kt b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/LocalizationModule.kt new file mode 100644 index 0000000..253680b --- /dev/null +++ b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/LocalizationModule.kt @@ -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 . + */ +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 { singleton { localization } } + } +} \ No newline at end of file diff --git a/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/Message.kt b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/Message.kt new file mode 100644 index 0000000..1dc9a4c --- /dev/null +++ b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/Message.kt @@ -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 . + */ +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) { + + /** + * 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 = + 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 \ No newline at end of file diff --git a/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/Translation.kt b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/Translation.kt new file mode 100644 index 0000000..66170c4 --- /dev/null +++ b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/api/messages/Translation.kt @@ -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 . + */ +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() + + /** + * 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) } + +} \ No newline at end of file diff --git a/core/common/src/main/kotlin/dev/koding/catalyst/core/common/injection/PluginModule.kt b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/injection/PluginModule.kt index f3accef..ef99ff8 100644 --- a/core/common/src/main/kotlin/dev/koding/catalyst/core/common/injection/PluginModule.kt +++ b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/injection/PluginModule.kt @@ -17,6 +17,7 @@ */ package dev.koding.catalyst.core.common.injection +import dev.koding.catalyst.core.common.api.messages.LocalizationModule import dev.koding.catalyst.core.common.injection.bootstrap.BaseComponentBootstrap import dev.koding.catalyst.core.common.injection.component.Tags import dev.koding.catalyst.core.common.loader.PlatformLoader @@ -47,5 +48,8 @@ object PluginModule { // Components bind { singleton { BaseComponentBootstrap(instance()) } } + + // Modules + import(LocalizationModule.of(plugin)) } } diff --git a/core/common/src/main/kotlin/dev/koding/catalyst/core/common/util/Components.kt b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/util/Components.kt new file mode 100644 index 0000000..122e7dc --- /dev/null +++ b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/util/Components.kt @@ -0,0 +1,139 @@ +/* + * 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 . + */ +package dev.koding.catalyst.core.common.util + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.format.TextDecoration +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver +import java.util.regex.Pattern + +/** + * Utility for creating / manipulating Adventure components. + */ +object Components { + + /** + * The pattern used to match URLs. + */ + private val URL_PATTERN = + Pattern.compile("(http(s)?://)?(www\\.)?[a-zA-Z0-9]+\\.[a-zA-Z]+(\\.[a-zA-Z]+)*(/[a-zA-Z0-9]+)*") + + /** + * The pattern used to match legacy hex color codes. + */ + private val LEGACY_HEX_PATTERN = Regex("&#([\\da-fA-F]{6})|&#([\\da-fA-F]{3})") + + /** + * Parses a string to an Adventure component. + * + * @param text The text to parse. + * @param resolvers The placeholders to use. + * + * @param prepare Whether to prepare the text for parsing. + * @param replaceUrls Whether to replace URLs with clickable links. + */ + fun parse( + text: String, + vararg resolvers: TagResolver, + prepare: Boolean = true, + replaceUrls: Boolean = true + ): Component { + val target = if (prepare) prepareMiniMessageString(text) else text + val component = MiniMessage.miniMessage().deserialize(target, *resolvers) + + // Replace URLs + return if (replaceUrls) component.replaceText { + it.match(URL_PATTERN) + it.replacement { match, tag -> + @Suppress("HttpUrlsUsage") + val hasProtocol = match.group().startsWith("http://") || match.group().startsWith("https://") + val url = if (hasProtocol) match.group() else "https://${match.group()}" + + tag + .decoration(TextDecoration.UNDERLINED, true) + .hoverEvent(Component.text("Click to open link").color(NamedTextColor.GRAY)) + .clickEvent(ClickEvent.openUrl(url)) + } + } else component + } + + /** + * Prepares a component string for parsing to MiniMessage, supporting + * some legacy-formatting codes. + * + * @param text The text to prepare. + * @return The prepared text. + */ + @Suppress("MemberVisibilityCanBePrivate") + fun prepareMiniMessageString(text: String): String { + // Replace legacy hex color codes + var result = LEGACY_HEX_PATTERN.replace(text) { "<${it.value.substring(1)}>" } + + // Replace legacy color codes + LegacyColors.LEGACY_COLORS.forEach { (key, value) -> + result = result.replace("&$key", "<$value>") + } + + // Replace legacy formatting codes + LegacyColors.LEGACY_FORMATTING.forEach { (key, value) -> + result = result.replace("&$key", "<$value>") + } + + // We ignore the reset code + return result + } + +} + +object LegacyColors { + /** + * The legacy color codes. + */ + val LEGACY_COLORS = mapOf( + '0' to NamedTextColor.BLACK, + '1' to NamedTextColor.DARK_BLUE, + '2' to NamedTextColor.DARK_GREEN, + '3' to NamedTextColor.DARK_AQUA, + '4' to NamedTextColor.DARK_RED, + '5' to NamedTextColor.DARK_PURPLE, + '6' to NamedTextColor.GOLD, + '7' to NamedTextColor.GRAY, + '8' to NamedTextColor.DARK_GRAY, + '9' to NamedTextColor.BLUE, + 'a' to NamedTextColor.GREEN, + 'b' to NamedTextColor.AQUA, + 'c' to NamedTextColor.RED, + 'd' to NamedTextColor.LIGHT_PURPLE, + 'e' to NamedTextColor.YELLOW, + 'f' to NamedTextColor.WHITE + ) + + /** + * The legacy-formatting codes. + */ + val LEGACY_FORMATTING = mapOf( + 'k' to TextDecoration.OBFUSCATED, + 'l' to TextDecoration.BOLD, + 'm' to TextDecoration.STRIKETHROUGH, + 'n' to TextDecoration.UNDERLINED, + 'o' to TextDecoration.ITALIC, + ) +} \ No newline at end of file diff --git a/core/common/src/main/kotlin/dev/koding/catalyst/core/common/util/ext/FileExt.kt b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/util/ext/FileExt.kt new file mode 100644 index 0000000..5a098db --- /dev/null +++ b/core/common/src/main/kotlin/dev/koding/catalyst/core/common/util/ext/FileExt.kt @@ -0,0 +1,85 @@ +/* + * 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 . + */ +package dev.koding.catalyst.core.common.util.ext + +import dev.koding.catalyst.core.common.loader.PlatformLoader +import java.io.File +import java.nio.file.FileSystems +import java.nio.file.Path +import kotlin.io.path.copyTo +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.pathString + +/** + * Save resources from the plugin JAR to the data folder. + * + * @param resources The resources to save + */ +fun PlatformLoader.saveResources(vararg resources: String) { + // Load the JAR as a file system + val jarPath = File(javaClass.protectionDomain.codeSource.location.toURI()).toPath() + FileSystems.newFileSystem(jarPath).use { fs -> + resources.forEach { res -> + // Check if the file exists + val resource = fs.getPath(res) + val hostFile = dataDirectory.resolve(resource.pathString) + if (!resource.exists()) return@forEach + + // If the resource is a directory, copy it recursively + if (resource.isDirectory()) { + resource.copyToRecursivelySafe(hostFile) + return@forEach + } + + // Otherwise, copy the file + hostFile.parent.createDirectories() + resource.copyTo(hostFile) + } + } +} + +/** + * Copy a directory recursively. + * + * We need to re-implement the copyToRecursively function since we + * are copying between two different file systems, which will throw + * an exception if we try to use the built-in function. + * + * We resolve this by calling the resolve function with the relative + * file path as a string instead of a Path object. + * + * @param dest The destination directory + */ +internal fun Path.copyToRecursivelySafe(dest: Path) { + listDirectoryEntries().forEach { file -> + // Get the relative path of the file + val relativePath = relativize(file).toString() + val destFile = dest.resolve(relativePath) + + if (file.isDirectory()) { + file.copyToRecursivelySafe(destFile) + } else { + if (destFile.exists()) return@forEach + destFile.parent.createDirectories() + file.copyTo(destFile) + } + } +} \ No newline at end of file diff --git a/core/paper/build.gradle.kts b/core/paper/build.gradle.kts index ecf575c..bd3dad8 100644 --- a/core/paper/build.gradle.kts +++ b/core/paper/build.gradle.kts @@ -23,8 +23,10 @@ apply(plugin = "gg.hubblemc.paper") dependencies { // Project - shadow(project(":core-common")) shadow("com.github.InnitGG:mapping-io:dfc566d20e") + shadow(project(":core-common")) { + exclude(group = "org.yaml", module = "snakeyaml") + } // Commands shadow("cloud.commandframework:cloud-paper:1.8.3") diff --git a/settings.gradle.kts b/settings.gradle.kts index 89b8a06..240fe50 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -54,8 +54,13 @@ plugins { } gitHooks { - commitMsg { conventionalCommits() } - preCommit { tasks("lint") } + commitMsg { + conventionalCommits { + defaultTypes() + types("wip") + } + } + preCommit { tasks("lint") } createHooks(true) }