diff --git a/.gitignore b/.gitignore index 12b5f23..f2127b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ -.gradle -.idea -build -gradle -API_KEYS.kt -src/main/Stuff.txt -gradle.properties -gradlew -gradlew.bat -settings.gradle.kts \ No newline at end of file +/Paper/build/ +/.gradle/ +/.idea/ +/build/ +/gradle/ +/gradlew +/gradlew.bat +/common/build/ +/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/API_KEYS.kt +/src/ +/Spigot/Spigot.iml +/Spigot/build/ diff --git a/Paper/build.gradle.kts b/Paper/build.gradle.kts new file mode 100644 index 0000000..7820bdf --- /dev/null +++ b/Paper/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.github.johnrengelman.shadow") version "7.0.0" + kotlin("jvm") + java + application +} + +application { + mainClass.set("com.github.hoshikurama.ticketmanager.paper.TicketManagerPlugin") +} + +repositories { + mavenCentral() + maven { url = uri("https://papermc.io/repo/repository/maven-public/") } + maven { url = uri("https://jitpack.io") } +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.17.1-R0.1-SNAPSHOT") + implementation(kotlin("stdlib", version = "1.5.21")) + implementation("com.github.HoshiKurama:KyoriComponentDSL:1.1.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.1") + implementation("net.kyori:adventure-extra-kotlin:4.8.1") + implementation("joda-time:joda-time:2.10.10") + compileOnly("com.github.MilkBowl:VaultAPI:1.7") + implementation("com.github.shynixn.mccoroutine:mccoroutine-bukkit-api:1.5.0") + implementation("com.github.shynixn.mccoroutine:mccoroutine-bukkit-core:1.5.0") + implementation(project(":common")) +} + +tasks { + named("shadowJar") { + archiveBaseName.set("TicketManager-Paper") + + dependencies { + include(dependency("com.github.HoshiKurama:KyoriComponentDSL:1.1.0")) + include(dependency("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.2.2")) + include(project(":common")) + + relocate("com.github.hoshikurama.componentDSL", "com.github.hoshikurama.ticketmanager.componentDSL") + relocate("kotlinx.serialization.json", "com.github.hoshikurama.ticketmanager.shaded.kotlinx.serialization.json") + } + } +} \ No newline at end of file diff --git a/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/Globals.kt b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/Globals.kt new file mode 100644 index 0000000..c019995 --- /dev/null +++ b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/Globals.kt @@ -0,0 +1 @@ +package com.github.hoshikurama.ticketmanager.paper import com.github.hoshikurama.componentDSL.buildComponent import com.github.hoshikurama.componentDSL.formattedContent import com.github.hoshikurama.ticketmanager.common.ConfigState import com.github.hoshikurama.ticketmanager.common.TMLocale import com.github.shynixn.mccoroutine.asyncDispatcher import net.kyori.adventure.audience.Audience import net.kyori.adventure.text.Component import org.bukkit.Bukkit import org.bukkit.ChatColor import org.bukkit.command.CommandSender import org.bukkit.entity.Player import java.util.* import java.util.logging.Level import kotlin.coroutines.CoroutineContext fun consoleLog(level: Level, message: String) = Bukkit.getLogger().log(level, ChatColor.stripColor(message)) internal val mainPlugin: TicketManagerPlugin get() = TicketManagerPlugin.plugin internal val configState: ConfigState get() = mainPlugin.configStateI internal val asyncContext: CoroutineContext get() = mainPlugin.asyncDispatcher internal fun pushMassNotify(permission: String, localeMsg: (TMLocale) -> Component) { Bukkit.getConsoleSender().sendMessage(localeMsg(mainPlugin.configStateI.localeHandler.consoleLocale)) Bukkit.getOnlinePlayers().asSequence() .filter { it.has(permission) } .forEach { localeMsg(it.toTMLocale()).run(it::sendMessage) } } internal fun Player.has(permission: String) = mainPlugin.perms.has(this, permission) internal fun CommandSender.has(permission: String): Boolean = if (this is Player) has(permission) else true internal fun Player.toTMLocale() = configState.localeHandler.getOrDefault(locale().toString()) internal fun CommandSender.toTMLocale() = if (this is Player) toTMLocale() else configState.localeHandler.consoleLocale internal fun CommandSender.toUUIDOrNull() = if (this is Player) this.uniqueId else null fun UUID?.toName(locale: TMLocale): String { if (this == null) return locale.consoleName return this.run(Bukkit::getOfflinePlayer).name ?: "UUID" } fun postModifiedStacktrace(e: Exception) { val onlinePlayers = Bukkit.getOnlinePlayers() .asSequence() .filter { it.has("ticketmanager.notify.warning") } .map { it as Audience to it.toTMLocale() } val console = Bukkit.getConsoleSender() as Audience to configState.localeHandler.consoleLocale val playersAndConsole = onlinePlayers + sequenceOf(console) playersAndConsole.forEach { pair -> val audience = pair.first val locale = pair.second audience.sendMessage( buildComponent { // Builds header listOf( locale.stacktraceLine1, locale.stacktraceLine2.replace("%exception%", e.javaClass.simpleName), locale.stacktraceLine3.replace("%message%", e.message ?: "?"), locale.stacktraceLine4, ) .forEach { text { formattedContent(it) } } // Adds stacktrace entries e.stackTrace .filter { it.className.startsWith("com.github.hoshikurama.ticketmanager") } .map { locale.stacktraceEntry .replace("%method%", it.methodName) .replace("%file%", it.fileName ?: "?") .replace("%line%", "${it.lineNumber}") } .forEach { text { formattedContent(it) } } } ) } } \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/MetricsKotlin.kt b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/MetricsKotlinBukkit.kt similarity index 98% rename from src/main/kotlin/com/hoshikurama/github/ticketmanager/MetricsKotlin.kt rename to Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/MetricsKotlinBukkit.kt index 14025d1..c1d5caf 100644 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/MetricsKotlin.kt +++ b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/MetricsKotlinBukkit.kt @@ -1,4 +1,4 @@ -package com.hoshikurama.github.ticketmanager +package com.github.hoshikurama.ticketmanager.paper import org.bukkit.Bukkit import org.bukkit.configuration.file.YamlConfiguration @@ -809,20 +809,3 @@ class Metrics(plugin: JavaPlugin, serviceId: Int) { } } -internal class UpdateChecker(private val resourceID: Int) { - internal fun getLatestVersion(): String? { - var inputStream: InputStream? = null - var scanner: Scanner? = null - - return try { - inputStream = URL("https://api.spigotmc.org/legacy/update.php?resource=$resourceID").openStream() - scanner = Scanner(inputStream!!) - if (scanner.hasNext()) scanner.next() else null - } - catch (ignored: Exception) { null } - finally { - inputStream?.close() - scanner?.close() - } - } -} \ No newline at end of file diff --git a/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/TicketManagerPlugin.kt b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/TicketManagerPlugin.kt new file mode 100644 index 0000000..a057101 --- /dev/null +++ b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/TicketManagerPlugin.kt @@ -0,0 +1,243 @@ +package com.github.hoshikurama.ticketmanager.paper + +import com.github.hoshikurama.componentDSL.formattedContent +import com.github.hoshikurama.ticketmanager.common.* +import com.github.hoshikurama.ticketmanager.common.databases.Database +import com.github.hoshikurama.ticketmanager.common.databases.Memory +import com.github.hoshikurama.ticketmanager.common.databases.MySQL +import com.github.hoshikurama.ticketmanager.common.databases.SQLite +import com.github.hoshikurama.ticketmanager.paper.events.Commands +import com.github.hoshikurama.ticketmanager.paper.events.PlayerJoin +import com.github.hoshikurama.ticketmanager.paper.events.TabComplete +import com.github.shynixn.mccoroutine.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import net.kyori.adventure.extra.kotlin.text +import net.milkbowl.vault.permission.Permission +import org.bukkit.Bukkit +import java.io.File + +class TicketManagerPlugin : SuspendingJavaPlugin() { + internal val pluginState = PluginState() + internal lateinit var perms: Permission private set + internal lateinit var configStateI: ConfigState + + private lateinit var metrics: Metrics + + + companion object { lateinit var plugin: TicketManagerPlugin } + init { plugin = this } + + override suspend fun onDisableAsync() { + pluginState.pluginLocked.set(true) + configStateI.database.closeDatabase() + } + + override fun onEnable() { + + // Find Vault plugin + server.servicesManager.getRegistration(Permission::class.java)?.provider + ?.let { perms = it } + ?: this.pluginLoader.disablePlugin(this) + + // Launch Metrics + launch { + metrics = Metrics(plugin, metricsKey) + metrics.addCustomChart( + Metrics.SingleLineChart("tickets_made") { + runBlocking { + val ticketCount = pluginState.ticketCountMetrics.get() + pluginState.ticketCountMetrics.set(0) + ticketCount + } + } + ) + metrics.addCustomChart( + Metrics.SimplePie("database_type") { + configStateI.database.type.name + } + ) + } //todo add pie chart for database type being used + + // Launches ConfigState initialisation + launchAsync { loadPlugin() } + + // Register Event + server.pluginManager.registerSuspendingEvents(PlayerJoin(), plugin) + + // Creates task timers + Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, Runnable { + launchAsync { configStateI.cooldowns.filterMapAsync() } + + launchAsync { + if (pluginState.pluginLocked.get()) return@launchAsync + + try { + // Mass Unread Notify + if (configStateI.allowUnreadTicketUpdates) { + Bukkit.getOnlinePlayers().asFlow() + .filter { it.has("ticketmanager.notify.unreadUpdates.scheduled") } + .onEach { + launch { + val ticketIDs = configStateI.database.getIDsWithUpdatesFor(it.uniqueId).toList() + val tickets = ticketIDs.joinToString(", ") + + if (ticketIDs.isEmpty()) return@launch + + val template = if (ticketIDs.size > 1) it.toTMLocale().notifyUnreadUpdateMulti + else it.toTMLocale().notifyUnreadUpdateSingle + + val sentMessage = template.replace("%num%", tickets) + it.sendMessage(text { formattedContent(sentMessage) }) + } + } + } + + val openPriority = configStateI.database.getOpenIDPriorityPairs().map { it.first }.toList() + val openCount = openPriority.count() + val assignments = configStateI.database.getBasicTickets(openPriority).mapNotNull { it.assignedTo }.toList() + + // Open and Assigned Notify + Bukkit.getOnlinePlayers().asFlow() + .filter { it.has("ticketmanager.notify.openTickets.scheduled") } + .onEach { p -> + launch { + val groups = perms.getPlayerGroups(p).map { "::$it" } + val assignedCount = assignments + .filter { it == p.name || it in groups } + .count() + + val sentMessage = p.toTMLocale().notifyOpenAssigned + .replace("%open%", "$openCount") + .replace("%assigned%", "$assignedCount") + p.sendMessage(text { formattedContent(sentMessage) }) + } + } + } catch (e: Exception) { + e.printStackTrace() + postModifiedStacktrace(e) + } + } + }, 100, 12000) + } + + internal suspend fun loadPlugin() = withContext(plugin.asyncDispatcher) { + pluginState.pluginLocked.set(true) + + configStateI = run { + // Creates config file if not found + if (!File(plugin.dataFolder, "config.yml").exists()) { + plugin.saveDefaultConfig() + + // Notifies users config was generated after plugin state init + launch { + while (!(::configStateI.isInitialized)) + delay(100L) + pushMassNotify("ticketmanager.notify.warning") { text { formattedContent(it.warningsNoConfig) } } + } + } + + plugin.reloadConfig() + plugin.config.run { + val path = plugin.dataFolder.absolutePath + val database: () -> Database? = { + val type = getString("Database_Mode", "SQLite")!! + .let { tryOrNull { Database.Type.valueOf(it) } ?: Database.Type.SQLite } + + when (type) { + Database.Type.MySQL -> MySQL( + getString("MySQL_Host")!!, + getString("MySQL_Port")!!, + getString("MySQL_DBName")!!, + getString("MySQL_Username")!!, + getString("MySQL_Password")!!, + asyncDispatcher = (plugin.asyncDispatcher as CoroutineDispatcher), + ) + Database.Type.SQLite -> SQLite(path) + Database.Type.Memory -> Memory( + filePath = path, + backupFrequency = getLong("Memory_Backup_Frequency", 600) + ) + } + } + + val cooldown: () -> ConfigState.Cooldown? = { + ConfigState.Cooldown( + getBoolean("Use_Cooldowns", false), + getLong("Cooldown_Time", 0L) + ) + } + + val localeHandler: suspend () -> LocaleHandler? = { + LocaleHandler.buildLocalesAsync( + getString("Colour_Code", "&3")!!, + getString("Preferred_Locale", "en_ca")!!, + getString("Console_Locale", "en_ca")!!, + getBoolean("Force_Locale", false), + asyncContext + ) + } + + val allowUnreadTicketUpdates: () -> Boolean? = { + getBoolean("Allow_Unread_Ticket_Updates", true) + } + + val checkForPluginUpdate: () -> Boolean? = { + getBoolean("Allow_UpdateChecking", false) + } + + val pluginVersion: () -> String = { + mainPlugin.description.version + } + + ConfigState.createPluginState( + database, + cooldown, + localeHandler, + allowUnreadTicketUpdates, + checkForPluginUpdate, + pluginVersion, + path, + asyncContext + ) + } + } + + launch { + val updateNeeded = configStateI.database.updateNeeded() + + if (updateNeeded) { + configStateI.database.updateDatabase( + onBegin = { + pushMassNotify("ticketmanager.notify.info") { + text { formattedContent(it.informationDBUpdate) } + } + }, + onComplete = { + pushMassNotify("ticketmanager.notify.info") { + text { formattedContent(it.informationDBUpdateComplete) } + } + pluginState.pluginLocked.set(true) + }, + offlinePlayerNameToUuidOrNull = { + Bukkit.getOfflinePlayers() + .filter { it.name == name } + .map { it.uniqueId } + .firstOrNull() + }, + context = asyncContext + ) + } else pluginState.pluginLocked.set(false) + } + + + withContext(minecraftDispatcher) { + // Register events and commands + configStateI.localeHandler.getCommandBases().forEach { + getCommand(it)!!.setSuspendingExecutor(Commands()) + server.pluginManager.registerEvents(TabComplete(), this@TicketManagerPlugin) + // Remember to register any keyword in plugin.yml + } + } + } +} \ No newline at end of file diff --git a/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/events/Commands.kt b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/events/Commands.kt new file mode 100644 index 0000000..dd55176 --- /dev/null +++ b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/events/Commands.kt @@ -0,0 +1,1475 @@ +package com.github.hoshikurama.ticketmanager.paper.events + +import com.github.hoshikurama.componentDSL.buildComponent +import com.github.hoshikurama.componentDSL.formattedContent +import com.github.hoshikurama.componentDSL.onClick +import com.github.hoshikurama.componentDSL.onHover +import com.github.hoshikurama.ticketmanager.common.* +import com.github.hoshikurama.ticketmanager.common.databases.Database +import com.github.hoshikurama.ticketmanager.common.databases.Memory +import com.github.hoshikurama.ticketmanager.common.databases.MySQL +import com.github.hoshikurama.ticketmanager.common.databases.SQLite +import com.github.hoshikurama.ticketmanager.common.ticket.* +import com.github.hoshikurama.ticketmanager.paper.* +import com.github.shynixn.mccoroutine.SuspendingCommandExecutor +import com.github.shynixn.mccoroutine.asyncDispatcher +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import net.kyori.adventure.extra.kotlin.text +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.event.HoverEvent +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.format.TextDecoration +import org.bukkit.Bukkit +import org.bukkit.ChatColor +import org.bukkit.Location +import org.bukkit.command.Command +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player +import java.util.* + +class Commands : SuspendingCommandExecutor { + + override suspend fun onCommand( + sender: CommandSender, + command: Command, + label: String, + args: Array, + ): Boolean = withContext(mainPlugin.asyncDispatcher) { + + val senderLocale = sender.toTMLocale() + val argList = args.toList() + + if (argList.isEmpty()) { + sender.sendMessage(text { formattedContent(senderLocale.warningsInvalidCommand) }) + return@withContext false + } + + if (mainPlugin.pluginState.pluginLocked.get()) { + sender.sendMessage(text { formattedContent(senderLocale.warningsLocked)}) + return@withContext false + } + + // Grabs BasicTicket. Only null if ID required but doesn't exist. Filters non-valid tickets + val pseudoTicket = getBasicTicketHandler(argList, senderLocale) + if (pseudoTicket == null) { + sender.sendMessage(text { formattedContent(senderLocale.warningsInvalidID) }) + return@withContext false + } + + // Async Calculations + val hasValidPermission = async { hasValidPermission(sender, pseudoTicket, argList, senderLocale) } + val isValidCommand = async { isValidCommand(sender, pseudoTicket, argList, senderLocale) } + val notUnderCooldown = async { notUnderCooldown(sender, senderLocale, argList) } + // Shortened Commands + val executeCommand = suspend { executeCommand(sender, argList, senderLocale, pseudoTicket) } + + try { + mainPlugin.pluginState.jobCount.run { set(get() + 1) } + if (notUnderCooldown.await() && isValidCommand.await() && hasValidPermission.await()) { + executeCommand()?.let { pushNotifications(sender, it, senderLocale, pseudoTicket) } + } + } catch (e: Exception) { + e.printStackTrace() + postModifiedStacktrace(e) + sender.sendMessage(text { formattedContent(senderLocale.warningsUnexpectedError) }) + } finally { + mainPlugin.pluginState.jobCount.run { set(get() - 1) } + } + + return@withContext true + } + + private suspend fun getBasicTicketHandler( + args: List, + senderLocale: TMLocale, + ): BasicTicketHandler? { + + suspend fun buildFromID(id: Int) = BasicTicketHandler.buildHandler(configState.database, id) + + return when (args[0]) { + senderLocale.commandWordAssign, + senderLocale.commandWordSilentAssign, + senderLocale.commandWordClaim, + senderLocale.commandWordSilentClaim, + senderLocale.commandWordClose, + senderLocale.commandWordSilentClose, + senderLocale.commandWordComment, + senderLocale.commandWordSilentComment, + senderLocale.commandWordReopen, + senderLocale.commandWordSilentReopen, + senderLocale.commandWordSetPriority, + senderLocale.commandWordSilentSetPriority, + senderLocale.commandWordTeleport, + senderLocale.commandWordUnassign, + senderLocale.commandWordSilentUnassign, + senderLocale.commandWordView, + senderLocale.commandWordDeepView -> + args.getOrNull(1) + ?.toIntOrNull() + ?.let { buildFromID(it) } + else -> ConcreteBasicTicket(creatorUUID = null, location = null).run { BasicTicketHandler(this, configState.database) } // Occurs when command does not need valid handler + } + } + + private fun hasValidPermission( + sender: CommandSender, + basicTicket: BasicTicket, + args: List, + senderLocale: TMLocale + ): Boolean { + try { + if (sender !is Player) return true + + fun has(perm: String) = sender.has(perm) + fun hasSilent() = has("ticketmanager.commandArg.silence") + fun hasDuality(basePerm: String): Boolean { + val senderUUID = sender.toUUIDOrNull() + val ownsTicket = basicTicket.uuidMatches(senderUUID) + return has("$basePerm.all") || (sender.has("$basePerm.own") && ownsTicket) + } + + return senderLocale.run { + when (args[0]) { + commandWordAssign, commandWordClaim, commandWordUnassign -> + has("ticketmanager.command.assign") + commandWordSilentAssign, commandWordSilentClaim,commandWordSilentUnassign -> + has("ticketmanager.command.assign") && hasSilent() + commandWordClose -> hasDuality("ticketmanager.command.close") + commandWordSilentClose -> hasDuality("ticketmanager.command.close") && hasSilent() + commandWordCloseAll -> has("ticketmanager.command.closeAll") + commandWordSilentCloseAll -> has("ticketmanager.command.closeAll") && hasSilent() + commandWordComment -> hasDuality("ticketmanager.command.comment") + commandWordSilentComment -> hasDuality("ticketmanager.command.comment") && hasSilent() + commandWordCreate -> has("ticketmanager.command.create") + commandWordHelp -> has("ticketmanager.command.help") + commandWordReload -> has("ticketmanager.command.reload") + commandWordList -> has("ticketmanager.command.list") + commandWordListAssigned -> has("ticketmanager.command.list") + commandWordReopen -> has("ticketmanager.command.reopen") + commandWordSilentReopen -> has("ticketmanager.command.reopen") && hasSilent() + commandWordSearch -> has("ticketmanager.command.search") + commandWordSetPriority -> has("ticketmanager.command.setPriority") + commandWordSilentSetPriority -> has("ticketmanager.command.setPriority") && hasSilent() + commandWordTeleport -> has("ticketmanager.command.teleport") + commandWordView -> hasDuality("ticketmanager.command.view") + commandWordDeepView -> hasDuality("ticketmanager.command.viewdeep") + commandWordConvertDB -> has("ticketmanager.command.convertDatabase") + commandWordHistory -> + sender.has("ticketmanager.command.history.all") || + sender.has("ticketmanager.command.history.own").let { hasPerm -> + if (args.size >= 2) hasPerm && args[1] == sender.name + else hasPerm + } + else -> true + } + } + .also { if (!it) sender.sendMessage(text { formattedContent(senderLocale.warningsNoPermission) }) } + } catch (e: Exception) { + sender.sendMessage(text { formattedContent(senderLocale.warningsNoPermission) }) + return false + } + } + + private fun isValidCommand( + sender: CommandSender, + basicTicket: BasicTicket, + args: List, + senderLocale: TMLocale + ): Boolean { + fun sendMessage(formattedString: String) = sender.sendMessage(text { formattedContent(formattedString) }) + fun invalidCommand() = sendMessage(senderLocale.warningsInvalidCommand) + fun notANumber() = sendMessage(senderLocale.warningsInvalidNumber) + fun outOfBounds() = sendMessage(senderLocale.warningsPriorityOutOfBounds) + fun ticketClosed() = sendMessage(senderLocale.warningsTicketAlreadyClosed) + fun ticketOpen() = sendMessage(senderLocale.warningsTicketAlreadyOpen) + + return senderLocale.run { + when (args[0]) { + commandWordAssign, commandWordSilentAssign -> + check(::invalidCommand) { args.size >= 3 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordClaim, commandWordSilentClaim -> + check(::invalidCommand) { args.size == 2 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordClose, commandWordSilentClose -> + check(::invalidCommand) { args.size >= 2 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordComment, commandWordSilentComment -> + check(::invalidCommand) { args.size >= 3 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordCloseAll, commandWordSilentCloseAll -> + check(::invalidCommand) { args.size == 3 } + .thenCheck(::notANumber) { args[1].toIntOrNull() != null } + .thenCheck(::notANumber) { args[2].toIntOrNull() != null } + + commandWordReopen, commandWordSilentReopen -> + check(::invalidCommand) { args.size == 2 } + .thenCheck(::ticketOpen) { basicTicket.status != BasicTicket.Status.OPEN } + + commandWordSetPriority, commandWordSilentSetPriority -> + check(::invalidCommand) { args.size == 3 } + .thenCheck(::outOfBounds) { args[2].toByteOrNull() != null } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordUnassign, commandWordSilentUnassign -> + check(::invalidCommand) { args.size == 2 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordView -> check(::invalidCommand) { args.size == 2 } + + commandWordDeepView -> check(::invalidCommand) { args.size == 2 } + + commandWordTeleport -> check(::invalidCommand) { args.size == 2 } + + commandWordCreate -> check(::invalidCommand) { args.size >= 2 } + + commandWordHistory -> + check(::invalidCommand) { args.isNotEmpty() } + .thenCheck(::notANumber) { if (args.size >= 3) args[2].toIntOrNull() != null else true} + + commandWordList -> + check(::notANumber) { if (args.size == 2) args[1].toIntOrNull() != null else true } + + commandWordListAssigned -> + check(::notANumber) { if (args.size == 2) args[1].toIntOrNull() != null else true } + + commandWordSearch -> check(::invalidCommand) { args.size >= 2} + + commandWordReload -> true + commandWordVersion -> true + commandWordHelp -> true + + commandWordConvertDB -> + check(::invalidCommand) { args.size == 2 } + .thenCheck( { sendMessage(senderLocale.warningsInvalidDBType) }, + { + try { Database.Type.valueOf(args[1]); true } + catch (e: Exception) { false } + } + ) + .thenCheck( { sendMessage(senderLocale.warningsConvertToSameDBType) } ) + { configState.database.type != Database.Type.valueOf(args[1]) } + + else -> false.also { invalidCommand() } + } + } + } + + private suspend fun notUnderCooldown( + sender: CommandSender, + senderLocale: TMLocale, + args: List + ): Boolean { + val underCooldown = when (args[0]) { + senderLocale.commandWordCreate, + senderLocale.commandWordComment, + senderLocale.commandWordSilentComment -> + configState.cooldowns.checkAndSetAsync(sender.toUUIDOrNull()) + else -> false + } + + if (underCooldown) + sender.sendMessage(text { formattedContent(senderLocale.warningsUnderCooldown) }) + + return !underCooldown + } + + private suspend fun executeCommand( + sender: CommandSender, + args: List, + senderLocale: TMLocale, + ticketHandler: BasicTicketHandler + ): NotifyParams? { + return senderLocale.run { + when (args[0]) { + commandWordAssign -> assign(sender, args, false, senderLocale, ticketHandler) + commandWordAssign -> assign(sender, args, false, senderLocale, ticketHandler) + commandWordSilentAssign -> assign(sender, args, true, senderLocale, ticketHandler) + commandWordClaim -> claim(sender, args, false, senderLocale, ticketHandler) + commandWordSilentClaim -> claim(sender, args, true, senderLocale, ticketHandler) + commandWordClose -> close(sender, args, false, ticketHandler) + commandWordSilentClose -> close(sender, args, true, ticketHandler) + commandWordCloseAll -> closeAll(sender, args, false, ticketHandler) + commandWordSilentCloseAll -> closeAll(sender, args, true, ticketHandler) + commandWordComment -> comment(sender, args, false, ticketHandler) + commandWordSilentComment -> comment(sender, args, true, ticketHandler) + commandWordCreate -> create(sender, args) + commandWordHelp -> help(sender, senderLocale).let { null } + commandWordHistory -> history(sender, args, senderLocale).let { null } + commandWordList -> list(sender, args, senderLocale).let { null } + commandWordListAssigned -> listAssigned(sender, args, senderLocale).let { null } + commandWordReload -> reload(sender, senderLocale).let { null } + commandWordReopen -> reopen(sender,args, false, ticketHandler) + commandWordSilentReopen -> reopen(sender,args, true, ticketHandler) + commandWordSearch -> search(sender, args, senderLocale).let { null } + commandWordSetPriority -> setPriority(sender, args, false, ticketHandler) + commandWordSilentSetPriority -> setPriority(sender, args, true, ticketHandler) + commandWordTeleport -> teleport(sender, ticketHandler).let { null } + commandWordUnassign -> unAssign(sender, args, false, senderLocale, ticketHandler) + commandWordSilentUnassign -> unAssign(sender, args, true, senderLocale, ticketHandler) + commandWordVersion -> version(sender, senderLocale).let { null } + commandWordView -> view(sender, senderLocale, ticketHandler).let { null } + commandWordDeepView -> viewDeep(sender, senderLocale, ticketHandler).let { null } + commandWordConvertDB -> convertDatabase(args).let { null } + else -> null + } + } + } + + private fun pushNotifications( + sender: CommandSender, + params: NotifyParams, + locale: TMLocale, + basicTicket: BasicTicket + ) { + params.run { + if (sendSenderMSG) + senderLambda!!(locale) + .run(sender::sendMessage) + + if (sendCreatorMSG) + basicTicket.creatorUUID + ?.run(Bukkit::getPlayer) + ?.let { creatorLambda!!(it.toTMLocale()) } + ?.run(creator!!::sendMessage) + + if (sendMassNotifyMSG) + pushMassNotify(massNotifyPerm, massNotifyLambda!!) + } + } + + private class NotifyParams( + silent: Boolean, + basicTicket: BasicTicket, + sender: CommandSender, + creatorAlertPerm: String, + val massNotifyPerm: String, + val senderLambda: ((TMLocale) -> Component)?, + val creatorLambda: ((TMLocale) -> Component)?, + val massNotifyLambda: ((TMLocale) -> Component)?, + ) { + val creator: Player? = basicTicket.creatorUUID?.let(Bukkit::getPlayer) + val sendSenderMSG: Boolean = (!sender.has(massNotifyPerm) || silent) + && senderLambda != null + val sendCreatorMSG: Boolean = sender.nonCreatorMadeChange(basicTicket.creatorUUID) + && !silent && (creator?.isOnline ?: false) + && (creator?.has(creatorAlertPerm) ?: false) + && (creator?.has(massNotifyPerm)?.run { !this } ?: false) + && creatorLambda != null + val sendMassNotifyMSG: Boolean = !silent + && massNotifyLambda != null + } + + /*-------------------------*/ + /* Commands */ + /*-------------------------*/ + + private suspend fun allAssignVariations( + sender: CommandSender, + silent: Boolean, + senderLocale: TMLocale, + assignmentID: String, + dbAssignment: String?, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val shownAssignment = dbAssignment ?: senderLocale.miscNobody + + launch { ticketHandler.setAssignedTo(dbAssignment) } + launch { configState.database.addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.ASSIGN, sender.toUUIDOrNull(), dbAssignment) + )} + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + senderLambda = { + val content = it.notifyTicketAssignSuccess + .replace("%id%", assignmentID) + .replace("%assign%", shownAssignment) + text { formattedContent(content) } + }, + massNotifyLambda = { + val content = it.notifyTicketAssignEvent + .replace("%user%", sender.name) + .replace("%id%", assignmentID) + .replace("%assign%", shownAssignment) + text { formattedContent(content) } + }, + creatorLambda = null, + creatorAlertPerm = "ticketmanager.notify.change.assign", + massNotifyPerm = "ticketmanager.notify.massNotify.assign" + ) + } + + // /ticket assign + private suspend fun assign( + sender: CommandSender, + args: List, + silent: Boolean, + senderLocale: TMLocale, + ticketHandler: BasicTicketHandler, + ): NotifyParams { + val sqlAssignment = args.subList(2, args.size).joinToString(" ") + return allAssignVariations(sender, silent, senderLocale, args[1], sqlAssignment, ticketHandler) + } + + // /ticket claim + private suspend fun claim( + sender: CommandSender, + args: List, + silent: Boolean, + senderLocale: TMLocale, + ticketHandler: BasicTicketHandler, + ): NotifyParams { + return allAssignVariations(sender, silent, senderLocale, args[1], sender.name, ticketHandler) + } + + // /ticket close [Comment...] + private suspend fun close( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler + ): NotifyParams = withContext(asyncContext) { + val newCreatorStatusUpdate = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && configState.allowUnreadTicketUpdates + if (newCreatorStatusUpdate != ticketHandler.creatorStatusUpdate) { + launch { ticketHandler.setCreatorStatusUpdate(newCreatorStatusUpdate) } + } + + return@withContext if (args.size >= 3) + closeWithComment(sender, args, silent, ticketHandler) + else closeWithoutComment(sender, args, silent, ticketHandler) + } + + private suspend fun closeWithComment( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val message = args.subList(2, args.size) + .joinToString(" ") + .run(ChatColor::stripColor)!! + + launch { + configState.database.run { + addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.COMMENT, sender.toUUIDOrNull(), message) + ) + addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.CLOSE, sender.toUUIDOrNull()) + ) + ticketHandler.setTicketStatus(BasicTicket.Status.CLOSED) + } + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + senderLambda = { + val content = it.notifyTicketCloseWCommentSuccess + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + massNotifyLambda = { + val content = it.notifyTicketCloseWCommentEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + .replace("%message%", message) + text { formattedContent(content) } + }, + creatorLambda = { + val content = it.notifyTicketModificationEvent + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + massNotifyPerm = "ticketmanager.notify.massNotify.close", + creatorAlertPerm = "ticketmanager.notify.change.close" + ) + } + + private suspend fun closeWithoutComment( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler + ): NotifyParams = withContext(asyncContext) { + launch { + configState.database.addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.CLOSE, sender.toUUIDOrNull()) + ) + ticketHandler.setTicketStatus(BasicTicket.Status.CLOSED) + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + creatorLambda = { + val content = it.notifyTicketModificationEvent + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + massNotifyLambda = { + val content = it.notifyTicketCloseEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + senderLambda = { + val content = it.notifyTicketCloseSuccess + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + massNotifyPerm = "ticketmanager.notify.massNotify.close", + creatorAlertPerm = "ticketmanager.notify.change.close" + ) + } + + // /ticket closeall + private suspend fun closeAll( + sender: CommandSender, + args: List, + silent: Boolean, + basicTicket: BasicTicket + ): NotifyParams = withContext(asyncContext) { + val lowerBound = args[1].toInt() + val upperBound = args[2].toInt() + + launch { configState.database.massCloseTickets(lowerBound, upperBound, sender.toUUIDOrNull(), asyncContext) } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = basicTicket, + creatorLambda = null, + senderLambda = { + val content =it.notifyTicketMassCloseSuccess + .replace("%low%", args[1]) + .replace("%high%", args[2]) + text { formattedContent(content) } + }, + massNotifyLambda = { + val content = it.notifyTicketMassCloseEvent + .replace("%user%", sender.name) + .replace("%low%", args[1]) + .replace("%high%", args[2]) + text { formattedContent(content) } + }, + massNotifyPerm = "ticketmanager.notify.massNotify.massClose", + creatorAlertPerm = "ticketmanager.notify.change.massClose" + ) + } + + // /ticket comment + private suspend fun comment( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val message = args.subList(2, args.size) + .joinToString(" ") + .run(ChatColor::stripColor)!! + + val newCreatorStatusUpdate = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && configState.allowUnreadTicketUpdates + if (newCreatorStatusUpdate != ticketHandler.creatorStatusUpdate) { + launch { ticketHandler.setCreatorStatusUpdate(newCreatorStatusUpdate) } + } + + launch { + configState.database.addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.COMMENT, sender.toUUIDOrNull(), message) + ) + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + creatorLambda = { + val content = it.notifyTicketModificationEvent + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + senderLambda = { + val content = it.notifyTicketCommentSuccess + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + massNotifyLambda = { + val content = it.notifyTicketCommentEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + .replace("%message%", message) + text { formattedContent(content) } + }, + massNotifyPerm = "ticketmanager.notify.massNotify.comment", + creatorAlertPerm = "ticketmanager.notify.change.comment" + ) + } + + // /ticket convertdatabase + private suspend fun convertDatabase(args: List) { + val type = args[1].run(Database.Type::valueOf) + val config = mainPlugin.config + + try { + configState.database.migrateDatabase( + context = asyncContext, + to = type, + sqLiteBuilder = { SQLite(mainPlugin.dataFolder.absolutePath) }, + mySQLBuilder = { + MySQL( + config.getString("MySQL_Host")!!, + config.getString("MySQL_Port")!!, + config.getString("MySQL_DBName")!!, + config.getString("MySQL_Username")!!, + config.getString("MySQL_Password")!!, + asyncDispatcher = (mainPlugin.asyncDispatcher as CoroutineDispatcher) + ) + }, + memoryBuilder = { + Memory( + filePath = mainPlugin.dataFolder.absolutePath, + backupFrequency = config.getLong("Memory_Backup_Frequency", 600), + ) + }, + onBegin = { + mainPlugin.pluginState.pluginLocked.set(true) + pushMassNotify("ticketmanager.notify.info") { + text { + formattedContent( + it.informationDBConvertInit + .replace("%fromDB%", configState.database.type.name) + .replace("%toDB%", type.name) + ) + } + } + }, + onComplete = { + mainPlugin.pluginState.pluginLocked.set(false) + pushMassNotify("ticketmanager.notify.info") { + text { formattedContent(it.informationDBConvertSuccess) } + } + } + ) + } catch (e: Exception) { + mainPlugin.pluginState.pluginLocked.set(false) + throw e + } + } + + // /ticket create + private suspend fun create( + sender: CommandSender, + args: List, + ): NotifyParams = withContext(asyncContext) { + val message = args.subList(1, args.size) + .joinToString(" ") + .run(ChatColor::stripColor)!! + + val ticket = ConcreteBasicTicket(creatorUUID = sender.toUUIDOrNull(), location = sender.toTicketLocationOrNull()) + + val deferredID = async { configState.database.addNewTicket(ticket, asyncContext, message) } + mainPlugin.pluginState.ticketCountMetrics.run { set(get() + 1) } + val id = deferredID.await().toString() + + NotifyParams( + silent = false, + sender = sender, + basicTicket = ticket, + creatorLambda = null, + senderLambda = { + val content = it.notifyTicketCreationSuccess + .replace("%id%", id) + text { formattedContent(content) } + }, + massNotifyLambda = { + val content = it.notifyTicketCreationEvent + .replace("%user%", sender.name) + .replace("%id%", id) + .replace("%message%", message) + text { formattedContent(content) } + }, + creatorAlertPerm = "ticketmanager.NO NODE", + massNotifyPerm = "ticketmanager.notify.massNotify.create", + ) + } + + // /ticket help + private fun help( + sender: CommandSender, + locale: TMLocale, + ) { + val hasSilentPerm = sender.has("ticketmanager.commandArg.silence") + val cc = configState.localeHandler.mainColourCode + + val component = buildComponent { + text { formattedContent(locale.helpHeader) } + text { formattedContent(locale.helpLine1) } + + if (hasSilentPerm) { + text { formattedContent(locale.helpLine2) } + text { formattedContent(locale.helpLine3) } + } + text { formattedContent(locale.helpSep) } + + locale.run { + listOf( // Triple(silence-able, format, permissions) + Triple(true, "$commandWordAssign &f<$parameterID> <$parameterAssignment...>", listOf("ticketmanager.command.assign")), + Triple(true, "$commandWordClaim &f<$parameterID>", listOf("ticketmanager.command.claim")), + Triple(true, "$commandWordClose &f<$parameterID> &7[$parameterComment...]", listOf("ticketmanager.command.close.all", "ticketmanager.command.close.own")), + Triple(true, "$commandWordCloseAll &f<$parameterLowerID> <$parameterUpperID>", listOf("ticketmanager.command.closeAll")), + Triple(true, "$commandWordComment &f<$parameterID> <$parameterComment...>", listOf("ticketmanager.command.comment.all", "ticketmanager.command.comment.own")), + Triple(false, "$commandWordConvertDB &f<$parameterTargetDB>", listOf("ticketmanager.command.convertDatabase")), + Triple(false, "$commandWordCreate &f<$parameterComment...>", listOf("ticketmanager.command.create")), + Triple(false, commandWordHelp, listOf("ticketmanager.command.help")), + Triple(false, "$commandWordHistory &7[$parameterUser] [$parameterPage]", listOf("ticketmanager.command.history.all", "ticketmanager.command.history.own")), + Triple(false, "$commandWordList &7[$parameterPage]", listOf("ticketmanager.command.list")), + Triple(false, "$commandWordListAssigned &7[$parameterPage]", listOf("ticketmanager.command.list")), + Triple(false, commandWordReload, listOf("ticketmanager.command.reload")), + Triple(true, "$commandWordReopen &f<$parameterID>", listOf("ticketmanager.command.reopen")), + Triple(false, "$commandWordSearch &f<$parameterConstraints...>", listOf("ticketmanager.command.search")), + Triple(true, "$commandWordSetPriority &f<$parameterID> <$parameterLevel>", listOf("ticketmanager.command.setPriority")), + Triple(false, "$commandWordTeleport &f<$parameterID>", listOf("ticketmanager.command.teleport")), + Triple(true, "$commandWordUnassign &f<$parameterID>", listOf("ticketmanager.command.assign")), + Triple(false, "$commandWordView &f<$parameterID>", listOf("ticketmanager.command.view.all", "ticketmanager.command.view.own")), + Triple(false, "$commandWordDeepView &f<$parameterID>", listOf("ticketmanager.command.viewdeep.all", "ticketmanager.command.viewdeep.own")) + ) + } + .filter { it.third.any(sender::has) } + .run { this + Triple(false, locale.commandWordVersion, "NA") } + .map { + val commandString = "$cc/${locale.commandBase} ${it.second}" + if (hasSilentPerm) + if (it.first) "\n&a[✓] $commandString" + else "\n&c[✕] $commandString" + else "\n$commandString" + } + .forEach { text { formattedContent(it) } } + } + + sender.sendMessage(component) + } + + // /ticket history [User] [Page] + private suspend fun history( + sender: CommandSender, + args: List, + locale: TMLocale, + ) { + withContext(asyncContext) { + val targetName = + if (args.size >= 2) args[1].takeIf { it != locale.consoleName } else sender.name.takeIf { sender is Player } + val requestedPage = if (args.size >= 3) args[2].toInt() else 1 + + // Leaves console as null. Otherwise attempts UUID grab or [PLAYERNOTFOUND] + fun String.attemptToUUIDString(): String = + Bukkit.getOfflinePlayers().asSequence() + .firstOrNull { equals(it.name) } + ?.run { uniqueId.toString() } + ?: "[PLAYERNOTFOUND]" + + val searchedUser = targetName?.attemptToUUIDString() + + val resultSize: Int + val resultsChunked = configState.database.searchDatabase(asyncContext, locale, listOf(locale.searchCreator to searchedUser)) { true } + .toList() + .sortedByDescending(BasicTicket::id) + .also { resultSize = it.size } + .chunked(6) + + val sentComponent = buildComponent { + text { + formattedContent( + locale.historyHeader + .replace("%name%", targetName ?: locale.consoleName) + .replace("%count%", "$resultSize") + ) + } + + val actualPage = if (requestedPage >= 1 && requestedPage < resultsChunked.size) requestedPage else 1 + + if (resultsChunked.isNotEmpty()) { + resultsChunked.getOrElse(requestedPage - 1) { resultsChunked[1] }.forEach { t -> + text { + formattedContent( + locale.historyEntry + .let { "\n$it" } + .replace("%id%", "${t.id}") + .replace("%SCC%", t.status.colourCode) + .replace("%status%", t.status.toLocaledWord(locale)) + .replace("%comment%", t.actions[0].message!!) + .let { if (it.length > 80) "${it.substring(0, 81)}..." else it } + ) + onHover { showText(Component.text(locale.clickViewTicket)) } + onClick { + action = ClickEvent.Action.RUN_COMMAND + value = locale.run { "/$commandBase $commandWordView ${t.id}" } + } + } + } + + if (resultsChunked.size > 1) { + append(buildPageComponent(actualPage, resultsChunked.size, locale) { + "/${it.commandBase} ${it.commandWordHistory} ${targetName ?: it.consoleName} " + }) + } + } + } + + sender.sendMessage(sentComponent) + } + } + + // /ticket list [Page] + private suspend fun list( + sender: CommandSender, + args: List, + locale: TMLocale, + ) { + sender.sendMessage( + createGeneralList(args, locale, locale.listFormatHeader, + getIDPriorityPair = { it.getOpenIDPriorityPairs() }, + baseCommand = locale.run{ { "/$commandBase $commandWordList " } } + ) + ) + } + + // /ticket listassigned [Page] + private suspend fun listAssigned( + sender: CommandSender, + args: List, + locale: TMLocale, + ) { + val groups: List = if (sender is Player) mainPlugin.perms.getPlayerGroups(sender).toList() else listOf() + + sender.sendMessage( + createGeneralList(args, locale, locale.listFormatAssignedHeader, + getIDPriorityPair = { it.getAssignedOpenIDPriorityPairs(sender.name, groups) }, + baseCommand = locale.run { { "/$commandBase $commandWordListAssigned " } } + ) + ) + } + + // /ticket reload + private suspend fun reload( + sender: CommandSender, + locale: TMLocale, + ) { + withContext(asyncContext) { + try { + mainPlugin.pluginState.pluginLocked.set(true) + pushMassNotify("ticketmanager.notify.info") { + text { formattedContent(it.informationReloadInitiated.replace("%user%", sender.name)) } + } + + val forceQuitJob = launch { + delay(30L * 1000L) + + // Long standing task has occurred if it reaches this point + launch { + pushMassNotify("ticketmanager.notify.warning") { + text { formattedContent(it.warningsLongTaskDuringReload) } + } + mainPlugin.pluginState.jobCount.set(1) + mainPlugin.asyncDispatcher.cancelChildren() + } + } + + // Waits for other tasks to complete + while (mainPlugin.pluginState.jobCount.get() > 1) delay(1000L) + + if (!forceQuitJob.isCancelled) + forceQuitJob.cancel("Tasks closed on time") + + pushMassNotify("ticketmanager.notify.info") { text { formattedContent(it.informationReloadTasksDone) } } + configState.database.closeDatabase() + mainPlugin.loadPlugin() + + pushMassNotify("ticketmanager.notify.info") { + text { formattedContent(it.informationReloadSuccess) } + } + if (!sender.has("ticketmanager.notify.info")) { + sender.sendMessage(text { formattedContent(locale.informationReloadSuccess) }) + } + } catch (e: Exception) { + pushMassNotify("ticketmanager.notify.info") { + text { formattedContent(it.informationReloadFailure) } + } + throw e + } + } + } + + // /ticket reopen + private suspend fun reopen( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val action = FullTicket.Action(FullTicket.Action.Type.REOPEN, sender.toUUIDOrNull()) + + // Updates user status if needed + val newCreatorStatusUpdate = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && configState.allowUnreadTicketUpdates + if (newCreatorStatusUpdate != ticketHandler.creatorStatusUpdate) { + launch { ticketHandler.setCreatorStatusUpdate(newCreatorStatusUpdate) } + } + + launch { + configState.database.addAction(ticketHandler.id, action) + ticketHandler.setTicketStatus(BasicTicket.Status.OPEN) + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + creatorLambda = { + val content = it.notifyTicketModificationEvent + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + senderLambda = { + val content = it.notifyTicketReopenSuccess + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + massNotifyLambda = { + val content = it.notifyTicketReopenEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + text { formattedContent(content) } + }, + creatorAlertPerm = "ticketmanager.notify.change.reopen", + massNotifyPerm = "ticketmanager.notify.massNotify.reopen", + ) + } + + private suspend fun search( + sender: CommandSender, + args: List, + locale: TMLocale, + ) { + withContext(asyncContext) { + fun String.attemptToUUIDString(): String? = + if (equals(locale.consoleName)) null + else Bukkit.getOfflinePlayers().asSequence() + .firstOrNull { equals(it.name) } + ?.run { uniqueId.toString() } + ?: "[PLAYERNOTFOUND]" + + // Beginning of execution + sender.sendMessage(text { formattedContent(locale.searchFormatQuerying) }) + + // Input args mapped to valid search types + val arguments = args.subList(1, args.size) + .asSequence() + .map { it.split(":", limit = 2) } + .filter { it.size >= 2 } + .associate { it[0] to it[1] } + + val mainTableConstrains = arguments + .mapNotNull { (key, value) -> + when (key) { + locale.searchAssigned -> key to value + locale.searchCreator -> key to value.attemptToUUIDString() + locale.searchPriority -> value.toByteOrNull()?.run { key to this.toString() } + locale.searchStatus -> { + val constraintStatus = when (value) { + locale.statusOpen -> BasicTicket.Status.OPEN.name + locale.statusClosed -> BasicTicket.Status.CLOSED.name + else -> null + } + constraintStatus?.run { key to this } + } + else -> null + } + } + + val functionConstraints = arguments + .mapNotNull { (key, value) -> + when (key) { + + locale.searchClosedBy -> { + val searchedUser = value.attemptToUUIDString(); + { t: FullTicket -> t.actions.any{ it.type == FullTicket.Action.Type.CLOSE && it.user?.toString() == searchedUser } } + } + + locale.searchLastClosedBy -> { + val searchedUser = value.attemptToUUIDString(); + { t: FullTicket -> + t.actions.lastOrNull { e -> e.type == FullTicket.Action.Type.CLOSE } + ?.run { user?.toString() == searchedUser } + ?: false + } + } + + locale.searchWorld -> { t: FullTicket -> t.location?.world?.equals(value) ?: false } + + locale.searchTime -> { + val creationTime = relTimeToEpochSecond(value, locale); + { t: FullTicket -> t.actions[0].timestamp >= creationTime } + } + + locale.searchKeywords -> { + val words = value.split(","); + + { t: FullTicket -> + val comments = t.actions + .filter { it.type == FullTicket.Action.Type.OPEN || it.type == FullTicket.Action.Type.COMMENT } + .map { it.message!! } + words.map { w -> comments.any { it.lowercase().contains(w.lowercase()) } } + .all { it } + } + } + + else -> null + } + } + val composedSearch = + if (functionConstraints.isNotEmpty()) + { t: FullTicket -> functionConstraints.map { it(t) }.all { it } } + else { _: FullTicket -> true } + + // Results Computation + val resultSize: Int + val chunkedTickets = configState.database.searchDatabase(asyncContext, locale, mainTableConstrains, composedSearch) + .toList() + .sortedByDescending(BasicTicket::id) + .apply { resultSize = size } + .chunked(8) + + val page = arguments[locale.searchPage]?.toIntOrNull() + .let { if (it != null && it >= 1 && it < chunkedTickets.size) it else 1 } + val fixMSGLength = { t: FullTicket -> t.actions[0].message!!.run { if (length > 25) "${substring(0,21)}..." else this } } + + val sentComponent = buildComponent { + + // Initial header + text { + formattedContent( + locale.searchFormatHeader.replace("%size%", "$resultSize") + ) + } + + // Adds entries + if (chunkedTickets.isNotEmpty()) { + chunkedTickets[page-1] + .map { + val content = "\n${locale.searchFormatEntry}" + .replace("%PCC%", it.priority.colourCode) + .replace("%SCC%", it.status.colourCode) + .replace("%id%", "${it.id}") + .replace("%status%", it.status.toLocaledWord(locale)) + .replace("%creator%", it.creatorUUID.toName(locale)) + .replace("%assign%", it.assignedTo ?: "") + .replace("%world%", it.location?.world ?: "") + .replace("%time%", it.actions[0].timestamp.toLargestRelativeTime(locale)) + .replace("%comment%", fixMSGLength(it)) + it.id to content + } + .forEach { + text { + formattedContent(it.second) + onHover { showText(Component.text(locale.clickViewTicket)) } + onClick { + action = ClickEvent.Action.RUN_COMMAND + value = locale.run { "/$commandBase $commandWordView ${it.first}" } + } + } + } + } + + // Implements pages if needed + if (chunkedTickets.size > 1) { + val pageComponent = buildPageComponent(page, chunkedTickets.size, locale) { + // Removes page constraint and converts rest to key:arg + val constraints = arguments + .filter { it.key != locale.searchPage } + .map { (k, v) -> "$k:$v" } + "/${locale.commandBase} ${locale.commandWordSearch} $constraints ${locale.searchPage}:" + } + append(pageComponent) + } + } + + sender.sendMessage(sentComponent) + } + } + + // /ticket setpriority + private suspend fun setPriority( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val newPriority = byteToPriority(args[2].toByte()) + launch { + configState.database.addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.SET_PRIORITY, sender.toUUIDOrNull(), args[2]) + ) + ticketHandler.setTicketPriority(newPriority) + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + creatorLambda = null, + senderLambda = { + val content = it.notifyTicketSetPrioritySuccess + .replace("%id%", args[1]) + .replace("%priority%", ticketHandler.run { newPriority.colourCode + newPriority.toLocaledWord(it) }) + text { formattedContent(content) } + }, + massNotifyLambda = { + val content = it.notifyTicketSetPriorityEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + .replace("%priority%", ticketHandler.run { newPriority.colourCode + newPriority.toLocaledWord(it) }) + text { formattedContent(content) } + }, + creatorAlertPerm = "ticketmanager.notify.change.priority", + massNotifyPerm = "ticketmanager.notify.massNotify.priority" + ) + } + + // /ticket teleport + private fun teleport( + sender: CommandSender, + basicTicket: BasicTicket, + ) { + if (sender is Player && basicTicket.location != null) { + val loc = basicTicket.location!!.run { Location(Bukkit.getWorld(world), x.toDouble(), y.toDouble(), z.toDouble()) } + sender.teleportAsync(loc) + } + } + + // /ticket unassign + private suspend fun unAssign( + sender: CommandSender, + args: List, + silent: Boolean, + senderLocale: TMLocale, + ticketHandler: BasicTicketHandler, + ): NotifyParams { + return allAssignVariations(sender, silent, senderLocale, args[1], null, ticketHandler) + } + + // /ticket version + private fun version( + sender: CommandSender, + locale: TMLocale, + ) { + val sentComponent = buildComponent { + text { + content("===========================\n") + color(NamedTextColor.DARK_AQUA) + } + text { + content("TicketManager:") + decorate(TextDecoration.BOLD) + color(NamedTextColor.DARK_AQUA) + append(Component.text(" by HoshiKurama\n", NamedTextColor.GRAY)) + } + text { + content(" GitHub Wiki: ") + color(NamedTextColor.DARK_AQUA) + } + text { + content("HERE\n") + color(NamedTextColor.GRAY) + decorate(TextDecoration.UNDERLINED) + clickEvent(ClickEvent.openUrl(locale.wikiLink)) + onHover { showText(Component.text(locale.clickWiki)) } + } + text { + content(" V$pluginVersion\n") + color(NamedTextColor.DARK_AQUA) + } + text { + content("===========================") + color(NamedTextColor.DARK_AQUA) + } + } + + sender.sendMessage(sentComponent) + } + + // /ticket view + private suspend fun view( + sender: CommandSender, + locale: TMLocale, + ticketHandler: BasicTicketHandler, + ) { + withContext(asyncContext) { + val fullTicket = ticketHandler.toFullTicket() + val baseComponent = buildTicketInfoComponent(fullTicket, locale) + + if (!sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && ticketHandler.creatorStatusUpdate) + launch { ticketHandler.setCreatorStatusUpdate(false) } + + val entries = fullTicket.actions.asSequence() + .filter { it.type == FullTicket.Action.Type.COMMENT || it.type == FullTicket.Action.Type.OPEN } + .map { + "\n${locale.viewFormatComment}" + .replace("%user%", it.user.toName(locale)) + .replace("%comment%", it.message!!) + } + .map { text { formattedContent(it) } } + .reduce(TextComponent::append) + + sender.sendMessage(baseComponent.append(entries)) + } + } + + // /ticket viewdeep + private suspend fun viewDeep( + sender: CommandSender, + locale: TMLocale, + ticketHandler: BasicTicketHandler, + ) { + withContext(asyncContext) { + val fullTicket = ticketHandler.toFullTicket() + val baseComponent = buildTicketInfoComponent(fullTicket, locale) + + if (!sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && ticketHandler.creatorStatusUpdate) + launch { ticketHandler.setCreatorStatusUpdate(false) } + + fun formatDeepAction(action: FullTicket.Action): String { + val result = when (action.type) { + FullTicket.Action.Type.OPEN, FullTicket.Action.Type.COMMENT -> + "\n${locale.viewFormatDeepComment}" + .replace("%comment%", action.message!!) + + FullTicket.Action.Type.SET_PRIORITY -> + "\n${locale.viewFormatDeepSetPriority}" + .replace("%priority%", + byteToPriority(action.message!!.toByte()).run { colourCode + toLocaledWord(locale) } + ) + + FullTicket.Action.Type.ASSIGN -> + "\n${locale.viewFormatDeepAssigned}" + .replace("%assign%", action.message ?: "") + + FullTicket.Action.Type.REOPEN -> "\n${locale.viewFormatDeepReopen}" + FullTicket.Action.Type.CLOSE -> "\n${locale.viewFormatDeepClose}" + FullTicket.Action.Type.MASS_CLOSE -> "\n${locale.viewFormatDeepMassClose}" + } + return result + .replace("%user%", action.user.toName(locale)) + .replace("%time%", action.timestamp.toLargestRelativeTime(locale)) + } + + val entries = fullTicket.actions.asSequence() + .map(::formatDeepAction) + .map { text { formattedContent(it) } } + .reduce(TextComponent::append) + + sender.sendMessage(baseComponent.append(entries)) + } + } + + private fun buildPageComponent( + curPage: Int, + pageCount: Int, + locale: TMLocale, + baseCommand: (TMLocale) -> String, + ): Component { + + fun Component.addForward(): Component { + return color(NamedTextColor.WHITE) + .clickEvent(ClickEvent.runCommand(baseCommand(locale) + "${curPage + 1}")) + .hoverEvent(HoverEvent.hoverEvent(HoverEvent.Action.SHOW_TEXT, Component.text(locale.clickNextPage))) + } + + fun Component.addBack(): Component { + return color(NamedTextColor.WHITE) + .clickEvent(ClickEvent.runCommand(baseCommand(locale) + "${curPage - 1}")) + .hoverEvent(HoverEvent.hoverEvent(HoverEvent.Action.SHOW_TEXT, Component.text(locale.clickBackPage))) + } + + var back: Component = Component.text("[${locale.pageBack}]") + var next: Component = Component.text("[${locale.pageNext}]") + val separator = text { + content("...............") + color(NamedTextColor.DARK_GRAY) + } + val cc = configState.localeHandler.mainColourCode + val ofSection = text { formattedContent("$cc($curPage${locale.pageOf}$pageCount)") } + + when (curPage) { + 1 -> { + back = back.color(NamedTextColor.DARK_GRAY) + next = next.addForward() + } + pageCount -> { + back = back.addBack() + next = next.color(NamedTextColor.DARK_GRAY) + } + else -> { + back = back.addBack() + next = next.addForward() + } + } + + return Component.text("\n") + .append(back) + .append(separator) + .append(ofSection) + .append(separator) + .append(next) + } + + private fun createListEntry( + ticket: FullTicket, + locale: TMLocale + ): Component { + val creator = ticket.creatorUUID.toName(locale) + val fixedAssign = ticket.assignedTo ?: "" + + // Shortens comment preview to fit on one line + val fixedComment = ticket.run { + if (12 + id.toString().length + creator.length + fixedAssign.length + actions[0].message!!.length > 58) + actions[0].message!!.substring( + 0, + 43 - id.toString().length - fixedAssign.length - creator.length + ) + "..." + else actions[0].message!! + } + + return text { + formattedContent( + "\n${locale.listFormatEntry}" + .replace("%priorityCC%", ticket.priority.colourCode) + .replace("%ID%", "${ticket.id}") + .replace("%creator%", creator) + .replace("%assign%", fixedAssign) + .replace("%comment%", fixedComment) + ) + onHover { showText(Component.text(locale.clickViewTicket)) } + onClick { + action = ClickEvent.Action.RUN_COMMAND + value = locale.run { "/$commandBase $commandWordView ${ticket.id}" } + } + } + } + + private suspend fun createGeneralList( + args: List, + locale: TMLocale, + headerFormat: String, + getIDPriorityPair: suspend (Database) -> Flow>, + baseCommand: (TMLocale) -> String + ): Component { + val chunkedIDs = getIDPriorityPair(configState.database) + .toList() + .sortedWith(compareByDescending> { it.second }.thenByDescending { it.first } ) + .map { it.first } + .chunked(8) + val page = if (args.size == 2 && args[1].toInt() in 1..chunkedIDs.size) args[1].toInt() else 1 + + val fullTickets = chunkedIDs.getOrNull(page - 1) + ?.run { configState.database.getFullTickets(this, asyncContext) } + ?.toList() + ?: emptyList() + + return buildComponent { + text { formattedContent(headerFormat) } + + if (fullTickets.isNotEmpty()) { + fullTickets.forEach { append(createListEntry(it, locale)) } + + if (chunkedIDs.size > 1) { + append(buildPageComponent(page, chunkedIDs.size, locale, baseCommand)) + } + } + } + } + + private fun buildTicketInfoComponent( + ticket: FullTicket, + locale: TMLocale, + ) = buildComponent { + text { + formattedContent( + "\n${locale.viewFormatHeader}" + .replace("%num%", "${ticket.id}") + ) + } + text { formattedContent("\n${locale.viewFormatSep1}") } + text { + formattedContent( + "\n${locale.viewFormatInfo1}" + .replace("%creator%", ticket.creatorUUID.toName(locale)) + .replace("%assignment%", ticket.assignedTo ?: "") + ) + } + text { + formattedContent( + "\n${locale.viewFormatInfo2}" + .replace("%priority%", ticket.priority.run { colourCode + toLocaledWord(locale) }) + .replace("%status%", ticket.status.run { colourCode + toLocaledWord(locale) }) + ) + } + text { + formattedContent( + "\n${locale.viewFormatInfo3}" + .replace("%location%", ticket.location?.toString() ?: "") + ) + + if (ticket.location != null) { + onHover { showText(Component.text(locale.clickTeleport)) } + onClick { + action = ClickEvent.Action.RUN_COMMAND + value = locale.run { "/$commandBase $commandWordTeleport ${ticket.id}" } + } + } + } + text { formattedContent("\n${locale.viewFormatSep2}") } + } + +} + +/*-------------------------*/ +/* Other Functions */ +/*-------------------------*/ + +private inline fun check(error: () -> Unit, predicate: () -> Boolean): Boolean { + return if (predicate()) true else error().run { false } +} + +private inline fun Boolean.thenCheck(error: () -> Unit, predicate: () -> Boolean): Boolean { + return if (!this) false + else if (predicate()) true + else error().run { false } +} + +private fun CommandSender.nonCreatorMadeChange(creatorUUID: UUID?): Boolean { + if (creatorUUID == null) return false + return this.toUUIDOrNull()?.notEquals(creatorUUID) ?: true +} + +private fun CommandSender.toTicketLocationOrNull() = if (this is Player) + location.run { BasicTicket.TicketLocation(world.name, blockX, blockY, blockZ) } + else null \ No newline at end of file diff --git a/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/events/PlayerJoin.kt b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/events/PlayerJoin.kt new file mode 100644 index 0000000..794c34d --- /dev/null +++ b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/events/PlayerJoin.kt @@ -0,0 +1,72 @@ +package com.github.hoshikurama.ticketmanager.paper.events + +import com.github.hoshikurama.componentDSL.formattedContent +import com.github.hoshikurama.ticketmanager.paper.configState +import com.github.hoshikurama.ticketmanager.paper.has +import com.github.hoshikurama.ticketmanager.paper.mainPlugin +import com.github.hoshikurama.ticketmanager.paper.toTMLocale +import com.github.shynixn.mccoroutine.asyncDispatcher +import com.github.shynixn.mccoroutine.minecraftDispatcher +import kotlinx.coroutines.flow.count +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.kyori.adventure.extra.kotlin.text +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent + +class PlayerJoin : Listener { + + @EventHandler + suspend fun onPlayerJoin(event: PlayerJoinEvent) = withContext(mainPlugin.minecraftDispatcher) { + if (mainPlugin.pluginState.pluginLocked.get()) return@withContext + val player = event.player + + withContext(mainPlugin.asyncDispatcher) { + + //Plugin Update Checking + launch { + val pluginUpdateStatus = configState.pluginUpdateAvailable.await() + if (player.has("ticketmanager.notify.pluginUpdate") && pluginUpdateStatus != null) { + val sentMSG = player.toTMLocale().notifyPluginUpdate + .replace("%current%", pluginUpdateStatus.first) + .replace("%latest%", pluginUpdateStatus.second) + player.sendMessage(text { formattedContent(sentMSG) }) + } + } + + // Unread Updates + launch { + if (player.has("ticketmanager.notify.unreadUpdates.onJoin")) { + configState.database.getIDsWithUpdatesFor(player.uniqueId) + .toList() + .run { if (size == 0) null else this } + ?.run { + val template = if (size == 1) player.toTMLocale().notifyUnreadUpdateSingle + else player.toTMLocale().notifyUnreadUpdateMulti + val tickets = this.joinToString(", ") + + + val sentMSG = template.replace("%num%", tickets) + player.sendMessage(text { formattedContent(sentMSG) }) + } + } + } + + // View Open-Count and Assigned-Count Tickets + launch { + if (player.has("ticketmanager.notify.openTickets.onJoin")) { + val open = configState.database.getOpenIDPriorityPairs() + val assigned = configState.database.getAssignedOpenIDPriorityPairs(player.name, mainPlugin.perms.getPlayerGroups(player).toList()) + + val sentMSG = player.toTMLocale().notifyOpenAssigned + .replace("%open%", "${open.count()}") + .replace("%assigned%", "${assigned.count()}") + + player.sendMessage(text { formattedContent(sentMSG) }) + } + } + } + } +} \ No newline at end of file diff --git a/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/events/TabComplete.kt b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/events/TabComplete.kt new file mode 100644 index 0000000..7ff2463 --- /dev/null +++ b/Paper/src/main/kotlin/com/github/hoshikurama/ticketmanager/paper/events/TabComplete.kt @@ -0,0 +1,317 @@ +package com.github.hoshikurama.ticketmanager.paper.events + +import com.destroystokyo.paper.event.server.AsyncTabCompleteEvent +import com.github.hoshikurama.ticketmanager.common.TMLocale +import com.github.hoshikurama.ticketmanager.common.databases.Database +import com.github.hoshikurama.ticketmanager.paper.has +import com.github.hoshikurama.ticketmanager.paper.mainPlugin +import com.github.hoshikurama.ticketmanager.paper.toTMLocale +import org.bukkit.Bukkit +import org.bukkit.World +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener + +class TabComplete: Listener { + @EventHandler + fun onTabCompleteAsync(event: AsyncTabCompleteEvent) { + if (event.buffer.startsWith("/ticket ")) { + val args = event.buffer + .replace(" +".toRegex(), " ") + .split(" ") + .run { subList(1, this.size) } + + event.completions = tabCompleteFunction(event.sender, args).toMutableList() + } + } + + private fun tabCompleteFunction( + sender: CommandSender, + args: List + ): List { + val blankList = listOf("") + + if (!sender.has("ticketmanager.commandArg.autotab") && sender is Player) return blankList + val locale = sender.toTMLocale() + val perms = LazyPermissions(locale, sender) + + return locale.run { + if (args.size <= 1) return@run perms.getPermissiveCommands() + .filter { it.startsWith(args[0]) } + + when (args[0]) { + commandWordAssign, commandWordSilentAssign -> when { // /ticket assign + !perms.hasAssignVariation -> listOf("") + args.size == 2 -> listOf("<$parameterID>") + .filter { it.startsWith(args[1]) } + args.size == 3 -> { + val groups = mainPlugin.perms.groups.map { "::$it" } + (listOf("<$parameterAssignment...>") + offlinePlayerNames() + groups + listOf(locale.consoleName)) + .filter { it.startsWith(args[2]) } + } + else -> listOf("") + } + + commandWordClaim, commandWordSilentClaim, commandWordUnassign, commandWordSilentUnassign -> when { // /ticket claim + !perms.hasAssignVariation -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordClose, commandWordSilentClose -> when { // /ticket close [Comment...] + !perms.hasClose -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> (listOf("[$parameterComment...]") + onlineSeenPlayers(sender)) + .filter { it.startsWith(args[args.lastIndex]) } + } + + commandWordCloseAll, commandWordSilentCloseAll -> when { // /ticket closeall + !perms.hasMassClose -> listOf("") + args.size == 2 -> listOf("<$parameterLowerID>").filter { it.startsWith(args[1]) } + args.size == 3 -> listOf("<$parameterUpperID>").filter { it.startsWith(args[2]) } + else -> listOf("") + } + + commandWordComment, commandWordSilentComment -> when { // /ticket comment + !perms.hasComment -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> (listOf("<$parameterComment...>") + onlineSeenPlayers(sender)) + .filter { it.startsWith(args[args.lastIndex]) } + } + + commandWordCreate -> when { // /ticket create + !perms.hasCreate -> listOf("") + else -> (listOf("<$parameterComment...>") + onlineSeenPlayers(sender)) + .filter { it.startsWith(args[args.lastIndex]) } + } + + commandWordHelp -> listOf("") + + commandWordHistory -> when { // /ticket history [User] [Page] + !perms.hasHistory -> listOf("") + args.size == 2 -> (listOf("[$parameterUser]", locale.consoleName) + offlinePlayerNames()) + .filter { it.startsWith(args[1]) } + args.size == 3 -> listOf("[$parameterPage]").filter { it.startsWith(args[2]) } + else -> listOf("") + } + + commandWordList, commandWordListAssigned -> when { // /ticket list(assigned) [Page] + !perms.hasListVariation -> listOf("") + args.size == 2 -> listOf("[$parameterPage]").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordReopen, commandWordSilentReopen -> when { // /ticket reopen + !perms.hasReopen -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordSetPriority, commandWordSilentSetPriority -> when { // /ticket setpriority + !perms.hasPriority -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + args.size == 3 -> listOf("<$parameterLevel>", "1", "2", "3", "4", "5").filter { + it.startsWith( + args[2] + ) + } + else -> listOf("") + } + + commandWordTeleport -> when { // /ticket teleport + !perms.hasTeleport -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordVersion -> listOf("") + + commandWordView -> when { // /ticket view + !perms.hasView -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordDeepView -> when { + !perms.hasDeepView -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordReload -> listOf("") + + commandWordSearch -> { //ticket search keywords:separated,by,commas status:OPEN/CLOSED time:5w creator:creator priority:value assignedto:player world:world + if (!perms.hasSearch) return@run listOf("") + + val curArgument = args[args.lastIndex] + val splitArgs = curArgument.split(":", limit = 2) + + if (splitArgs.size < 2) + return@run locale.run { + listOf( + "$searchAssigned:", + "$searchCreator:", + "$searchKeywords:", + "$searchPriority:", + "$searchStatus:", + "$searchWorld:", + "$searchClosedBy:", + "$searchLastClosedBy:", + "$searchTime:", + ) + } + .filter { it.startsWith(curArgument) } + + // String now has form "constraint:" + return@run when (splitArgs[0]) { + searchAssigned -> { + val groups = mainPlugin.perms.groups.map { "::$it" } + (offlinePlayerNames() + groups) + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + searchCreator, searchLastClosedBy, searchClosedBy -> { + (offlinePlayerNames() + listOf(locale.consoleName)) + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + searchPriority -> { + listOf("1", "2", "3", "4", "5") + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + locale.searchStatus -> { + listOf(locale.statusOpen, locale.statusClosed) + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + searchWorld -> { + Bukkit.getWorlds() + .map(World::getName) + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + searchTime -> { + locale.run { + listOf( + searchTimeSecond, + searchTimeMinute, + searchTimeHour, + searchTimeDay, + searchTimeWeek, + searchTimeYear + ) + } + .filter { curArgument.last().digitToIntOrNull() != null } + .map { "${splitArgs[0]}:${splitArgs[1]}$it" } + } + + searchKeywords -> listOf(curArgument) + + else -> listOf("") + } + } + + commandWordConvertDB -> when { // /ticket convertDatabase + !perms.hasConvertDB -> listOf("") + args.size == 2 -> + Database.Type.values() + .map(Database.Type::name) + .filter { it.startsWith(args[1]) } + else -> listOf("") + } + + else -> listOf("") + } + } + } + + private fun onlineSeenPlayers(sender: CommandSender): List { + return if (sender is Player) + Bukkit.getOnlinePlayers() + .filter(sender::canSee) + .map { it.name } + else Bukkit.getOnlinePlayers() + .map { it.name } + } + + private fun offlinePlayerNames() = Bukkit.getOfflinePlayers().mapNotNull { it.name }.toList() + + + class LazyPermissions(private val locale: TMLocale, private val sender: CommandSender) { + val hasAssignVariation by lazy { sender.has("ticketmanager.command.assign") } + val hasCreate by lazy { sender.has("ticketmanager.command.create") } + val hasListVariation by lazy { sender.has("ticketmanager.command.list") } + val hasReopen by lazy { sender.has("ticketmanager.command.reopen") } + val hasSearch by lazy { sender.has("ticketmanager.command.search") } + val hasPriority by lazy { sender.has("ticketmanager.command.setPriority") } + val hasTeleport by lazy { sender.has("ticketmanager.command.teleport") } + val hasMassClose by lazy { sender.has("ticketmanager.command.closeAll") } + val hasConvertDB by lazy { sender.has("ticketmanager.command.convertDatabase") } + private val hasHelp by lazy { sender.has("ticketmanager.command.help") } + private val hasReload by lazy { sender.has("ticketmanager.command.reload") } + private val hasSilent by lazy { sender.has("ticketmanager.commandArg.silence") } + val hasClose by lazy { + sender.has("ticketmanager.command.close.all") + || sender.has("ticketmanager.command.close.own") + } + val hasComment by lazy { + sender.has("ticketmanager.command.comment.all") + || sender.has("ticketmanager.command.comment.own") + } + val hasView by lazy { + sender.has("ticketmanager.command.view.all") + || sender.has("ticketmanager.command.view.own") + } + val hasDeepView by lazy { + sender.has("ticketmanager.command.viewdeep.all") + || sender.has("ticketmanager.command.viewdeep.own") + } + val hasHistory by lazy { + sender.has("ticketmanager.command.history.all") + || sender.has("ticketmanager.command.history.own") + } + + + fun getPermissiveCommands(): List { + return mapOf( + locale.commandWordAssign to hasAssignVariation, + locale.commandWordSilentAssign to (hasAssignVariation && hasSilent), + locale.commandWordClaim to hasAssignVariation, + locale.commandWordSilentClaim to (hasAssignVariation && hasSilent), + locale.commandWordClose to hasClose, + locale.commandWordSilentClose to (hasClose && hasSilent), + locale.commandWordCloseAll to hasMassClose, + locale.commandWordSilentCloseAll to (hasMassClose && hasSilent), + locale.commandWordComment to hasComment, + locale.commandWordSilentComment to (hasComment && hasSilent), + locale.commandWordConvertDB to hasConvertDB, + locale.commandWordCreate to hasCreate, + locale.commandWordHelp to hasHelp, + locale.commandWordHistory to hasHistory, + locale.commandWordList to hasListVariation, + locale.commandWordListAssigned to hasListVariation, + locale.commandWordReload to hasReload, + locale.commandWordReopen to hasReopen, + locale.commandWordSilentReopen to (hasReopen && hasSilent), + locale.commandWordSearch to hasSearch, + locale.commandWordSetPriority to hasPriority, + locale.commandWordSilentSetPriority to (hasPriority && hasSilent), + locale.commandWordTeleport to hasTeleport, + locale.commandWordUnassign to hasAssignVariation, + locale.commandWordSilentUnassign to (hasAssignVariation && hasSilent), + locale.commandWordVersion to true, + locale.commandWordView to hasView, + locale.commandWordDeepView to hasDeepView + ) + .filter { it.value } + .map { it.key } + } + } +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/Paper/src/main/resources/config.yml similarity index 57% rename from src/main/resources/config.yml rename to Paper/src/main/resources/config.yml index 4b35e08..5e21e94 100644 --- a/src/main/resources/config.yml +++ b/Paper/src/main/resources/config.yml @@ -10,9 +10,9 @@ # be found on the GitHub wiki page. # https://github.com/HoshiKurama/TicketManager # -# ##################### +# ########################################## # Locale: -# ##################### +# ########################################## # Force Locale: Forces specific language to be used for commands and responses. # Values: true, false Force_Locale: false @@ -31,9 +31,9 @@ Preferred_Locale: 'en_CA' # 'en_US' = United States English Console_Locale: 'en_CA' # -# ##################### -# Cooldown: -# ##################### +# ########################################## +# Cooldown: +# ########################################## # Use Cool-down: Determine if cool-downs should be applied to ticket commands # that create or modify a ticket. Cool-downs apply to ALL users without the # override permission. @@ -45,19 +45,46 @@ Use_Cooldowns: false # cool-downs are enabled. Cooldown_Time: 0 # -# ##################### -# Database Settings: -# ##################### -# Database mode: Type of database used. SQLite is default and can run locally -# Do NOT run MySQL unless you have a valid MySQL database! Internally defaults -# to SQLite if invalid value is used. -# Values: 'MySQL','SQLite' +# ########################################## +# Database Settings: +# ########################################## +# +# Database mode: Type of database used. Internally defaults +# to SQLite if invalid value is used. Information only needs +# to be filled out for the desired database type. +# +# SQLite: +# Stores information in the SQLite file in the TicketManager folder. +# Pros: + No setup. +# + No external database needed. +# + Faster than MySQL for search and history command. +# Cons: - Stores data in TicketManager folder. +# - Easier to overwhelm than Memory or MySQL. +# +# MySQL: +# Stores information in a database that may or may not be on the server. +# Pros: + Can handle more traffic than SQLite. +# + Can be separate from server storage. +# Cons: - Must have MySQL database. +# - Search and history commands slower. +# +# Memory: +# Memory refers to TicketManager's custom solution for running the entire +# database in RAM. Data is backed up throughout normal server operations or +# on server shutdowns. It is then loaded up on startup if selected. DO NOT +# USE THIS DATABASE TYPE UNLESS YOU UNDERSTAND THE RISKS OF RUNNING A +# DATABASE IN RAM!!! +# Pros: + Incredibly responsive +# + Incredibly fast queries +# Cons: - Any power loss or server crash will result in all data beyond +# the last backup being lost. +# +# Values: 'MySQL','SQLite','Memory' Database_Mode: 'SQLite' # # ###################### # MySQL Database # ###################### -# This section only applies when using MySQL. MySQL_Port: '' MySQL_Host: '' MySQL_DBName: '' @@ -65,8 +92,14 @@ MySQL_Username: '' MySQL_Password: '' # # ###################### -# Other +# Memory as Database # ###################### +# Time in seconds between database backups being made +Memory_Backup_Frequency: 600 +# +# ########################################### +# Other +# ########################################### # Colour Code: Colour code used to add simple colour customization. # Must be in the form "&". eg: &3, &6, &b, etc # DO NOT USE ANY COLOUR CODES OTHER THAN COLOUR! diff --git a/Paper/src/main/resources/plugin.yml b/Paper/src/main/resources/plugin.yml new file mode 100644 index 0000000..da3ce62 --- /dev/null +++ b/Paper/src/main/resources/plugin.yml @@ -0,0 +1,246 @@ +name: TicketManager +version: 5.0.0 +main: com.github.hoshikurama.ticketmanager.paper.TicketManagerPlugin +api-version: 1.17 +authors: [HoshiKurama] +depend: [Vault] +libraries: + - org.jetbrains.kotlin:kotlin-stdlib:1.5.21 + - mysql:mysql-connector-java:8.0.25 + - org.xerial:sqlite-jdbc:3.34.0 + - com.github.jasync-sql:jasync-mysql:1.2.2 + - com.github.seratch:kotliquery:1.3.1 + - org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.1 + - net.kyori:adventure-extra-kotlin:4.8.1 + - net.kyori:adventure-text-serializer-legacy:4.8.1 + - joda-time:joda-time:2.10.10 + - com.github.shynixn.mccoroutine:mccoroutine-bukkit-api:1.5.0 + - com.github.shynixn.mccoroutine:mccoroutine-bukkit-core:1.5.0 + - org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.2.2 +commands: + ticket: + description: Base for all TicketManager commands +permissions: + ticketmanager.command.assign: + description: Assign a ticket + default: false + ticketmanager.command.close.all: + description: Close any ticket + default: false + ticketmanager.command.close.own: + description: Close only own tickets + default: false + ticketmanager.command.closeAll: + description: Mass close tickets + default: false + ticketmanager.command.comment.all: + description: Comment on any ticket + default: false + ticketmanager.command.comment.own: + description: Comment on only own tickets + default: false + ticketmanager.command.convertDatabase: + description: Convert database to another supported type + default: false + ticketmanager.command.create: + description: Create a ticket + default: false + ticketmanager.command.help: + description: See list of ticket commands to which user has permissions for + default: false + ticketmanager.command.history.all: + description: View all users' histories of tickets + default: false + ticketmanager.command.history.own: + description: View own history of tickets + default: false + ticketmanager.command.list: + description: List all open tickets + default: false + ticketmanager.command.reload: + description: Restarts plugin + default: false + ticketmanager.command.reopen: + description: Reopen any ticket + default: false + ticketmanager.command.search: + description: Search database for certain tickets + default: false + ticketmanager.command.setPriority: + description: Set priority of any ticket + default: false + ticketmanager.command.teleport: + description: Teleport to any ticket + default: false + ticketmanager.command.view.all: + description: View any ticket + default: false + ticketmanager.command.view.own: + description: View own ticket + default: false + ticketmanager.command.viewdeep.all: + description: View any detailed ticket + default: false + ticketmanager.command.viewdeep.own: + description: View own detailed ticket information + default: false + ticketmanager.commandArg.cooldown.override: + description: Overrides cooldown if enabled + default: false + ticketmanager.commandArg.silence: + description: Ability to perform silent variations + default: false + ticketmanager.commandArg.autotab: + description: Allows autotabbing (must have permission for command too) + default: false + ticketmanager.notify.openTickets.onJoin: + description: See open and assigned ticket count on login + default: false + ticketmanager.notify.openTickets.scheduled: + description: See open and assigned ticket count at scheduled intervals + default: false + ticketmanager.notify.unreadUpdates.onJoin: + description: See own tickets with updates on login + default: false + ticketmanager.notify.unreadUpdates.scheduled: + description: See own tickets with updates at scheduled intervals + default: false + ticketmanager.notify.info: + description: See general TicketManager information + default: false + ticketmanager.notify.pluginUpdate: + description: Get notified of plugin update on login + default: false + ticketmanager.notify.warning: + description: See modified stacktrace on errors + default: false + ticketmanager.notify.change.comment: + description: Creator sees when user comments on their ticket + default: false + ticketmanager.notify.change.close: + description: Creator sees when user closes their ticket + default: false + ticketmanager.notify.change.reopen: + description: Creator sees when user reopens their ticket + default: false + ticketmanager.notify.massNotify.assign: + description: See ticket assignment events + default: false + ticketmanager.notify.massNotify.close: + description: See any ticket close events + default: false + ticketmanager.notify.massNotify.comment: + description: See any ticket comment events + default: false + ticketmanager.notify.massNotify.create: + description: See ticket creation events + default: false + ticketmanager.notify.massNotify.massClose: + description: See ticket mass close events + default: false + ticketmanager.notify.massNotify.priority: + description: See ticket priority change events + default: false + ticketmanager.notify.massNotify.reopen: + description: See ticket reopen events + default: false + ticketmanager.command.*: + description: Wildcard for all commands and commandargs + children: + ticketmanager.command.assign: true + ticketmanager.command.close.all: true + ticketmanager.command.close.own: true + ticketmanager.command.closeAll: true + ticketmanager.command.comment.all: true + ticketmanager.command.comment.own: true + ticketmanager.command.convertDatabase: true + ticketmanager.command.create: true + ticketmanager.command.help: true + ticketmanager.command.history.all: true + ticketmanager.command.history.own: true + ticketmanager.command.list: true + ticketmanager.command.reload: true + ticketmanager.command.reopen: true + ticketmanager.command.search: true + ticketmanager.command.setPriority: true + ticketmanager.command.teleport: true + ticketmanager.command.view.all: true + ticketmanager.command.view.own: true + ticketmanager.command.viewdeep.all: true + ticketmanager.command.viewdeep.own: true + default: false + ticketmanager.notify.change.*: + description: Wildcard for all status changes on own ticket + children: + ticketmanager.notify.change.comment: true + ticketmanager.notify.change.close: true + ticketmanager.notify.change.reopen: true + default: false + ticketmanager.massNotify.*: + description: Wildcard for all mass notifications (except errors and plugin info) + children: + ticketmanager.notify.massNotify.assign: true + ticketmanager.notify.massNotify.close: true + ticketmanager.notify.massNotify.comment: true + ticketmanager.notify.massNotify.create: true + ticketmanager.notify.massNotify.massClose: true + ticketmanager.notify.massNotify.priority: true + ticketmanager.notify.massNotify.reopen: true + default: false + ticketmanager.notify.*: + description: Wildcard for all notification permissions + children: + ticketmanager.notify.openTickets.onJoin: true + ticketmanager.notify.openTickets.scheduled: true + ticketmanager.notify.unreadUpdates.onJoin: true + ticketmanager.notify.unreadUpdates.scheduled: true + ticketmanager.notify.warning: true + ticketmanager.notify.info: true + ticketmanager.massNotify.*: true + ticketmanager.notify.change.*: true + ticketmanager.notify.pluginUpdate: true + default: false + ticketmanager.*: + description: Wildcard for all TicketManager permissions + children: + ticketmanager.notify.*: true + ticketmanager.command.*: true + ticketmanager.commandArg.cooldown.override: true + ticketmanager.commandArg.silence: true + ticketmanager.commandArg.autotab: true + default: op + ticketmanager.basic: + description: Preset for normal users + children: + ticketmanager.command.close.own: true + ticketmanager.command.comment.own: true + ticketmanager.command.create: true + ticketmanager.command.help: true + ticketmanager.command.history.own: true + ticketmanager.command.view.own: true + ticketmanager.commandArg.autotab: true + ticketmanager.notify.unreadUpdates.onJoin: true + ticketmanager.notify.unreadUpdates.scheduled: true + ticketmanager.notify.change.*: true + default: false + ticketmanager.manage: + description: Preset for staff (includes ticketmanager.basic) + children: + ticketmanager.basic: true + ticketmanager.command.assign: true + ticketmanager.command.close.all: true + ticketmanager.command.comment.all: true + ticketmanager.command.list: true + ticketmanager.command.reopen: true + ticketmanager.command.search: true + ticketmanager.command.setPriority: true + ticketmanager.command.view.all: true + ticketmanager.command.viewdeep.all: true + ticketmanager.command.viewdeep.own: true + ticketmanager.command.history.all: true + ticketmanager.commandArg.cooldown.override: true + ticketmanager.notify.openTickets.onJoin: true + ticketmanager.notify.openTickets.scheduled: true + ticketmanager.notify.info: true + ticketmanager.massNotify.*: true + default: false \ No newline at end of file diff --git a/Spigot/build.gradle.kts b/Spigot/build.gradle.kts new file mode 100644 index 0000000..ca24c22 --- /dev/null +++ b/Spigot/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.github.johnrengelman.shadow") version "7.0.0" + kotlin("jvm") + java + application +} + +application { + mainClass.set("com.github.hoshikurama.ticketmanager.spigot.TicketManagerPlugin") +} + +repositories { + mavenCentral() + maven(url = "https://oss.sonatype.org/content/repositories/snapshots/") { + name = "sonatype-oss-snapshots" + } + maven { url = uri("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") } + maven { url = uri("https://jitpack.io") } +} + +dependencies { + compileOnly("org.spigotmc:spigot-api:1.17-R0.1-SNAPSHOT") + implementation(kotlin("stdlib", version = "1.5.21")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.1") + implementation("joda-time:joda-time:2.10.10") + compileOnly("com.github.MilkBowl:VaultAPI:1.7") + implementation("com.github.shynixn.mccoroutine:mccoroutine-bukkit-api:1.5.0") + implementation("com.github.shynixn.mccoroutine:mccoroutine-bukkit-core:1.5.0") + implementation(project(":common")) +} + +tasks { + named("shadowJar") { + archiveBaseName.set("TicketManager-Spigot") + + dependencies { + include(dependency("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.2.2")) + include(project(":common")) + + relocate("kotlinx.serialization.json", "com.github.hoshikurama.ticketmanager.shaded.kotlinx.serialization.json") + } + } +} \ No newline at end of file diff --git a/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/Globals.kt b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/Globals.kt new file mode 100644 index 0000000..58d3918 --- /dev/null +++ b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/Globals.kt @@ -0,0 +1 @@ +package com.github.hoshikurama.ticketmanager.spigot import com.github.hoshikurama.ticketmanager.common.ConfigState import com.github.hoshikurama.ticketmanager.common.TMLocale import com.github.shynixn.mccoroutine.asyncDispatcher import net.md_5.bungee.api.chat.TextComponent import org.bukkit.Bukkit import org.bukkit.ChatColor import org.bukkit.command.CommandSender import org.bukkit.entity.Player import java.util.* import java.util.logging.Level import kotlin.coroutines.CoroutineContext fun consoleLog(level: Level, message: String) = Bukkit.getLogger().log(level, ChatColor.stripColor(message)) internal val mainPlugin: TicketManagerPlugin get() = TicketManagerPlugin.plugin internal val configState: ConfigState get() = mainPlugin.configStateI internal val asyncContext: CoroutineContext get() = mainPlugin.asyncDispatcher internal fun addColour(s: String) = ChatColor.translateAlternateColorCodes('&', s) internal fun pushMassNotify(permission: String, localeMsg: (TMLocale) -> String) { Bukkit.getConsoleSender().sendMessage(localeMsg(mainPlugin.configStateI.localeHandler.consoleLocale)) Bukkit.getOnlinePlayers() .asSequence() .filter { it.has(permission) } .forEach { val message = it.toTMLocale() .run(localeMsg) .run(::addColour) it.sendMessage(message) } } internal fun Player.has(permission: String) = mainPlugin.perms.has(this, permission) internal fun CommandSender.has(permission: String): Boolean = if (this is Player) has(permission) else true internal fun Player.toTMLocale() = configState.localeHandler.getOrDefault(locale) internal fun CommandSender.toTMLocale() = if (this is Player) toTMLocale() else configState.localeHandler.consoleLocale internal fun CommandSender.toUUIDOrNull() = if (this is Player) this.uniqueId else null fun UUID?.toName(locale: TMLocale): String { if (this == null) return locale.consoleName return this.run(Bukkit::getOfflinePlayer).name ?: "UUID" } fun postModifiedStacktrace(e: Exception) { val players = Bukkit.getOnlinePlayers() .asSequence() .filter { it.has("ticketmanager.notify.warning") } val console = Bukkit.getConsoleSender() val buildMessage: (TMLocale) -> TextComponent = { locale -> val sentComponent = TextComponent("") // Creates header listOf( locale.stacktraceLine1, locale.stacktraceLine2.replace("%exception%", e.javaClass.simpleName), locale.stacktraceLine3.replace("%message%", e.message ?: "?"), locale.stacktraceLine4 ) .map(::addColour) .map(::TextComponent) .forEach { sentComponent.addExtra(it) } // Adds entries e.stackTrace .filter { it.className.startsWith("com.hoshikurama.github.ticketmanager") } .map { locale.stacktraceEntry .replace("%method%", it.methodName) .replace("%file%", it.fileName ?: "?") .replace("%line%", "${it.lineNumber}") } .map(::addColour) .map(::TextComponent) .forEach { sentComponent.addExtra(it) } sentComponent } console.run { buildMessage(this.toTMLocale()) .let{ spigot().sendMessage(it) } } players.forEach { p -> buildMessage(p.toTMLocale()) .let { p.spigot().sendMessage(it) } } } \ No newline at end of file diff --git a/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/MetricsKotlinBukkit.kt b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/MetricsKotlinBukkit.kt new file mode 100644 index 0000000..1917457 --- /dev/null +++ b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/MetricsKotlinBukkit.kt @@ -0,0 +1,811 @@ +package com.github.hoshikurama.ticketmanager.spigot + +import org.bukkit.Bukkit +import org.bukkit.configuration.file.YamlConfiguration +import org.bukkit.entity.Player +import org.bukkit.plugin.Plugin +import org.bukkit.plugin.java.JavaPlugin +import java.io.* +import java.net.URL +import java.nio.charset.StandardCharsets +import java.util.* +import java.util.concurrent.* +import java.util.function.* +import java.util.logging.Level +import java.util.stream.Collectors +import java.util.zip.GZIPOutputStream +import javax.net.ssl.HttpsURLConnection + +/* Note: This is NOT my code. Converted bStats Java to Kotlin using IntelliJ */ +class Metrics(plugin: JavaPlugin, serviceId: Int) { + private val plugin: Plugin + private val metricsBase: MetricsBase + + /** + * Adds a custom chart. + * + * @param chart The chart to add. + */ + fun addCustomChart(chart: CustomChart) { + metricsBase.addCustomChart(chart) + } + + private fun appendPlatformData(builder: JsonObjectBuilder) { + builder.appendField("playerAmount", playerAmount) + builder.appendField("onlineMode", if (Bukkit.getOnlineMode()) 1 else 0) + builder.appendField("bukkitVersion", Bukkit.getVersion()) + builder.appendField("bukkitName", Bukkit.getName()) + builder.appendField("javaVersion", System.getProperty("java.version")) + builder.appendField("osName", System.getProperty("os.name")) + builder.appendField("osArch", System.getProperty("os.arch")) + builder.appendField("osVersion", System.getProperty("os.version")) + builder.appendField("coreCount", Runtime.getRuntime().availableProcessors()) + } + + private fun appendServiceData(builder: JsonObjectBuilder) { + builder.appendField("pluginVersion", plugin.description.version) + }// Just use the new method if the reflection failed + + // Around MC 1.8 the return type was changed from an array to a collection, + // This fixes java.lang.NoSuchMethodError: + // org.bukkit.Bukkit.getOnlinePlayers()Ljava/util/Collection; + private val playerAmount: Int + get() { + return try { + // Around MC 1.8 the return type was changed from an array to a collection, + // This fixes java.lang.NoSuchMethodError: + // org.bukkit.Bukkit.getOnlinePlayers()Ljava/util/Collection; + val onlinePlayersMethod = Class.forName("org.bukkit.Server").getMethod("getOnlinePlayers") + if (onlinePlayersMethod.returnType == MutableCollection::class.java) (onlinePlayersMethod.invoke( + Bukkit.getServer() + ) as Collection<*>).size else (onlinePlayersMethod.invoke(Bukkit.getServer()) as Array).size + } catch (e: Exception) { + // Just use the new method if the reflection failed + Bukkit.getOnlinePlayers().size + } + } + + class MetricsBase( + private val platform: String, + private val serverUuid: String, + private val serviceId: Int, + private val enabled: Boolean, + private val appendPlatformDataConsumer: Consumer, + private val appendServiceDataConsumer: Consumer, + private val submitTaskConsumer: Consumer?, + private val checkServiceEnabledSupplier: Supplier, + private val errorLogger: BiConsumer, + private val infoLogger: Consumer, + private val logErrors: Boolean, + private val logSentData: Boolean, + private val logResponseStatusText: Boolean + ) { + private val customCharts: MutableSet = HashSet() + fun addCustomChart(chart: CustomChart) { + customCharts.add(chart) + } + + private fun startSubmitting() { + val submitTask = Runnable { + if (!enabled || !checkServiceEnabledSupplier.get()) { + // Submitting data or service is disabled + scheduler.shutdown() + return@Runnable + } + if (submitTaskConsumer != null) { + submitTaskConsumer.accept(Runnable { submitData() }) + } else { + submitData() + } + } + // Many servers tend to restart at a fixed time at xx:00 which causes an uneven distribution + // of requests on the + // bStats backend. To circumvent this problem, we introduce some randomness into the initial + // and second delay. + // WARNING: You must not modify and part of this Metrics class, including the submit delay or + // frequency! + // WARNING: Modifying this code will get your plugin banned on bStats. Just don't do it! + val initialDelay = (1000 * 60 * (3 + Math.random() * 3)).toLong() + val secondDelay = (1000 * 60 * (Math.random() * 30)).toLong() + scheduler.schedule(submitTask, initialDelay, TimeUnit.MILLISECONDS) + scheduler.scheduleAtFixedRate( + submitTask, initialDelay + secondDelay, (1000 * 60 * 30).toLong(), TimeUnit.MILLISECONDS + ) + } + + private fun submitData() { + val baseJsonBuilder = JsonObjectBuilder() + appendPlatformDataConsumer.accept(baseJsonBuilder) + val serviceJsonBuilder = JsonObjectBuilder() + appendServiceDataConsumer.accept(serviceJsonBuilder) + val chartData: Array = customCharts + .mapNotNull { it.getRequestJsonObject(errorLogger, logErrors) } + .toTypedArray() + serviceJsonBuilder.appendField("id", serviceId) + serviceJsonBuilder.appendField("customCharts", chartData) + baseJsonBuilder.appendField("service", serviceJsonBuilder.build()) + baseJsonBuilder.appendField("serverUUID", serverUuid) + baseJsonBuilder.appendField("metricsVersion", METRICS_VERSION) + val data = baseJsonBuilder.build() + scheduler.execute { + try { + // Send the data + sendData(data) + } catch (e: Exception) { + // Something went wrong! :( + if (logErrors) { + errorLogger.accept("Could not submit bStats metrics data", e) + } + } + } + } + + @Throws(Exception::class) + private fun sendData(data: JsonObjectBuilder.JsonObject) { + if (logSentData) { + infoLogger.accept("Sent bStats metrics data: $data") + } + val url = String.format(REPORT_URL, platform) + val connection = URL(url).openConnection() as HttpsURLConnection + // Compress the data to save bandwidth + val compressedData = compress(data.toString()) + connection.requestMethod = "POST" + connection.addRequestProperty("Accept", "application/json") + connection.addRequestProperty("Connection", "close") + connection.addRequestProperty("Content-Encoding", "gzip") + connection.addRequestProperty("Content-Length", compressedData!!.size.toString()) + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("User-Agent", "Metrics-Service/1") + connection.doOutput = true + DataOutputStream(connection.outputStream).use { outputStream -> outputStream.write(compressedData) } + val builder = StringBuilder() + BufferedReader(InputStreamReader(connection.inputStream)).use { bufferedReader -> + var line: String? + while ((bufferedReader.readLine().also { line = it }) != null) { + builder.append(line) + } + } + if (logResponseStatusText) { + infoLogger.accept("Sent data to bStats and received response: $builder") + } + } + + /** Checks that the class was properly relocated. */ + private fun checkRelocation() { + // You can use the property to disable the check in your test environment + if (System.getProperty("bstats.relocatecheck") == null + || System.getProperty("bstats.relocatecheck") != "false" + ) { + // Maven's Relocate is clever and changes strings, too. So we have to use this little + // "trick" ... :D + val defaultPackage = String( + byteArrayOf( + 'o'.code.toByte(), + 'r'.code.toByte(), + 'g'.code.toByte(), + '.'.code.toByte(), + 'b'.code.toByte(), + 's'.code.toByte(), + 't'.code.toByte(), + 'a'.code.toByte(), + 't'.code.toByte(), + 's'.code.toByte() + ) + ) + val examplePackage = String( + byteArrayOf( + 'y'.code.toByte(), + 'o'.code.toByte(), + 'u'.code.toByte(), + 'r'.code.toByte(), + '.'.code.toByte(), + 'p'.code.toByte(), + 'a'.code.toByte(), + 'c'.code.toByte(), + 'k'.code.toByte(), + 'a'.code.toByte(), + 'g'.code.toByte(), + 'e'.code.toByte() + ) + ) + // We want to make sure no one just copy & pastes the example and uses the wrong package + // names + if (MetricsBase::class.java.getPackage().name.startsWith(defaultPackage) + || MetricsBase::class.java.getPackage().name.startsWith(examplePackage) + ) { + throw IllegalStateException("bStats Metrics class has not been relocated correctly!") + } + } + } + + companion object { + /** The version of the Metrics class. */ + val METRICS_VERSION = "2.2.1" + private val scheduler = Executors.newScheduledThreadPool(1 + ) { task: Runnable? -> Thread(task, "bStats-Metrics") } + private val REPORT_URL = "https://bStats.org/api/v2/data/%s" + + /** + * Gzips the given string. + * + * @param str The string to gzip. + * @return The gzipped string. + */ + @Throws(IOException::class) + private fun compress(str: String?): ByteArray? { + if (str == null) { + return null + } + val outputStream = ByteArrayOutputStream() + GZIPOutputStream(outputStream).use { gzip -> gzip.write(str.toByteArray(StandardCharsets.UTF_8)) } + return outputStream.toByteArray() + } + } + + /** + * Creates a new MetricsBase class instance. + * + * @param platform The platform of the service. + * @param serviceId The id of the service. + * @param serverUuid The server uuid. + * @param enabled Whether or not data sending is enabled. + * @param appendPlatformDataConsumer A consumer that receives a `JsonObjectBuilder` and + * appends all platform-specific data. + * @param appendServiceDataConsumer A consumer that receives a `JsonObjectBuilder` and + * appends all service-specific data. + * @param submitTaskConsumer A consumer that takes a runnable with the submit task. This can be + * used to delegate the data collection to a another thread to prevent errors caused by + * concurrency. Can be `null`. + * @param checkServiceEnabledSupplier A supplier to check if the service is still enabled. + * @param errorLogger A consumer that accepts log message and an error. + * @param infoLogger A consumer that accepts info log messages. + * @param logErrors Whether or not errors should be logged. + * @param logSentData Whether or not the sent data should be logged. + * @param logResponseStatusText Whether or not the response status text should be logged. + */ + init { + checkRelocation() + if (enabled) { + startSubmitting() + } + } + } + + @Suppress("unused") + class AdvancedBarChart + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */( + chartId: String?, // Null = skip the chart// Skip this invalid + private val callable: Callable> + ) : + CustomChart(chartId) { + // Null = skip the chart + @get:Throws(Exception::class) + override val chartData: JsonObjectBuilder.JsonObject? + get() { + val valuesBuilder = JsonObjectBuilder() + val map = callable.call() + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null + } + var allSkipped = true + for (entry: Map.Entry in map.entries) { + if (entry.value.size == 0) { + // Skip this invalid + continue + } + allSkipped = false + valuesBuilder.appendField(entry.key, entry.value) + } + return if (allSkipped) { + // Null = skip the chart + null + } else JsonObjectBuilder().appendField("values", valuesBuilder.build()).build() + } + + } + + @Suppress("unused") + class SimpleBarChart + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */(chartId: String?, private val callable: Callable>) : + CustomChart(chartId) { + // Null = skip the chart + @get:Throws(Exception::class) + override val chartData: JsonObjectBuilder.JsonObject? + get() { + val valuesBuilder = JsonObjectBuilder() + val map = callable.call() + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null + } + for (entry: Map.Entry in map.entries) { + valuesBuilder.appendField(entry.key, intArrayOf(entry.value)) + } + return JsonObjectBuilder().appendField("values", valuesBuilder.build()).build() + } + + } + + @Suppress("unused") + class MultiLineChart + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */( + chartId: String?, // Null = skip the chart// Skip this invalid + private val callable: Callable> + ) : + CustomChart(chartId) { + // Null = skip the chart + @get:Throws(Exception::class) + override val chartData: JsonObjectBuilder.JsonObject? + get() { + val valuesBuilder = JsonObjectBuilder() + val map = callable.call() + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null + } + var allSkipped = true + for (entry: Map.Entry in map.entries) { + if (entry.value == 0) { + // Skip this invalid + continue + } + allSkipped = false + valuesBuilder.appendField(entry.key, entry.value) + } + return if (allSkipped) { + // Null = skip the chart + null + } else JsonObjectBuilder().appendField("values", valuesBuilder.build()).build() + } + + } + + @Suppress("unused") + class AdvancedPie + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */( + chartId: String?, // Null = skip the chart// Skip this invalid + private val callable: Callable> + ) : + CustomChart(chartId) { + // Null = skip the chart + @get:Throws(Exception::class) + override val chartData: JsonObjectBuilder.JsonObject? + get() { + val valuesBuilder = JsonObjectBuilder() + val map = callable.call() + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null + } + var allSkipped = true + for (entry: Map.Entry in map.entries) { + if (entry.value == 0) { + // Skip this invalid + continue + } + allSkipped = false + valuesBuilder.appendField(entry.key, entry.value) + } + return if (allSkipped) { + // Null = skip the chart + null + } else JsonObjectBuilder().appendField("values", valuesBuilder.build()).build() + } + + } + + abstract class CustomChart protected constructor(chartId: String?) { + private val chartId: String + fun getRequestJsonObject( + errorLogger: BiConsumer, logErrors: Boolean + ): JsonObjectBuilder.JsonObject? { + val builder = JsonObjectBuilder() + builder.appendField("chartId", chartId) + try { + val data = chartData + ?: // If the data is null we don't send the chart. + return null + builder.appendField("data", data) + } catch (t: Throwable) { + if (logErrors) { + errorLogger.accept("Failed to get data for custom chart with id $chartId", t) + } + return null + } + return builder.build() + } + + @get:Throws(Exception::class) + protected abstract val chartData: JsonObjectBuilder.JsonObject? + + init { + if (chartId == null) { + throw IllegalArgumentException("chartId must not be null") + } + this.chartId = chartId + } + } + + class SingleLineChart + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */(chartId: String?, private val callable: Callable) : CustomChart(chartId) { + // Null = skip the chart + @get:Throws(Exception::class) + override val chartData: JsonObjectBuilder.JsonObject? + get() { + val value = callable.call() + return if (value == 0) { + // Null = skip the chart + null + } else JsonObjectBuilder().appendField("value", value).build() + } + + } + + @Suppress("unused") + class SimplePie + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */(chartId: String?, private val callable: Callable) : CustomChart(chartId) { + // Null = skip the chart + @get:Throws(Exception::class) + override val chartData: JsonObjectBuilder.JsonObject? + get() { + val value = callable.call() + return if (value == null || value.isEmpty()) { + // Null = skip the chart + null + } else JsonObjectBuilder().appendField("value", value).build() + } + + } + + @Suppress("unused") + class DrilldownPie + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */( + chartId: String?, // Null = skip the chart + private val callable: Callable>> + ) : + CustomChart(chartId) { + // Null = skip the chart + @get:Throws(Exception::class) + override val chartData: JsonObjectBuilder.JsonObject? + get() { + val valuesBuilder = JsonObjectBuilder() + val map = callable.call() + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null + } + var reallyAllSkipped = true + for (entryValues: Map.Entry> in map.entries) { + val valueBuilder = JsonObjectBuilder() + var allSkipped = true + for (valueEntry: Map.Entry in map[entryValues.key]!!.entries) { + valueBuilder.appendField(valueEntry.key, valueEntry.value) + allSkipped = false + } + if (!allSkipped) { + reallyAllSkipped = false + valuesBuilder.appendField(entryValues.key, valueBuilder.build()) + } + } + return if (reallyAllSkipped) { + // Null = skip the chart + null + } else JsonObjectBuilder().appendField("values", valuesBuilder.build()).build() + } + + } + + /** + * An extremely simple JSON builder. + * + * + * While this class is neither feature-rich nor the most performant one, it's sufficient enough + * for its use-case. + */ + class JsonObjectBuilder() { + private var builder: StringBuilder? = StringBuilder() + private var hasAtLeastOneField = false + + /** + * Appends a null field to the JSON. + * + * @param key The key of the field. + * @return A reference to this object. + */ + fun appendNull(key: String?): JsonObjectBuilder { + appendFieldUnescaped(key, "null") + return this + } + + /** + * Appends a string field to the JSON. + * + * @param key The key of the field. + * @param value The value of the field. + * @return A reference to this object. + */ + fun appendField(key: String?, value: String?): JsonObjectBuilder { + if (value == null) { + throw IllegalArgumentException("JSON value must not be null") + } + appendFieldUnescaped(key, "\"" + escape(value) + "\"") + return this + } + + /** + * Appends an integer field to the JSON. + * + * @param key The key of the field. + * @param value The value of the field. + * @return A reference to this object. + */ + fun appendField(key: String?, value: Int): JsonObjectBuilder { + appendFieldUnescaped(key, value.toString()) + return this + } + + /** + * Appends an object to the JSON. + * + * @param key The key of the field. + * @param object The object. + * @return A reference to this object. + */ + fun appendField(key: String?, `object`: JsonObject?): JsonObjectBuilder { + if (`object` == null) { + throw IllegalArgumentException("JSON object must not be null") + } + appendFieldUnescaped(key, `object`.toString()) + return this + } + + /** + * Appends a string array to the JSON. + * + * @param key The key of the field. + * @param values The string array. + * @return A reference to this object. + */ + fun appendField(key: String?, values: Array?): JsonObjectBuilder { + if (values == null) { + throw IllegalArgumentException("JSON values must not be null") + } + val escapedValues = Arrays.stream(values) + .map { value: String -> + "\"" + escape( + value + ) + "\"" + } + .collect(Collectors.joining(",")) + appendFieldUnescaped(key, "[$escapedValues]") + return this + } + + /** + * Appends an integer array to the JSON. + * + * @param key The key of the field. + * @param values The integer array. + * @return A reference to this object. + */ + fun appendField(key: String?, values: IntArray?): JsonObjectBuilder { + if (values == null) { + throw IllegalArgumentException("JSON values must not be null") + } + val escapedValues = + Arrays.stream(values).mapToObj { i: Int -> i.toString() }.collect(Collectors.joining(",")) + appendFieldUnescaped(key, "[$escapedValues]") + return this + } + + /** + * Appends an object array to the JSON. + * + * @param key The key of the field. + * @param values The integer array. + * @return A reference to this object. + */ + fun appendField(key: String?, values: Array?): JsonObjectBuilder { + if (values == null) { + throw IllegalArgumentException("JSON values must not be null") + } + val escapedValues = Arrays.stream(values).map { obj: JsonObject? -> obj.toString() } + .collect(Collectors.joining(",")) + appendFieldUnescaped(key, "[$escapedValues]") + return this + } + + /** + * Appends a field to the object. + * + * @param key The key of the field. + * @param escapedValue The escaped value of the field. + */ + private fun appendFieldUnescaped(key: String?, escapedValue: String) { + if (builder == null) { + throw IllegalStateException("JSON has already been built") + } + if (key == null) { + throw IllegalArgumentException("JSON key must not be null") + } + if (hasAtLeastOneField) { + builder!!.append(",") + } + builder!!.append("\"").append(escape(key)).append("\":").append(escapedValue) + hasAtLeastOneField = true + } + + /** + * Builds the JSON string and invalidates this builder. + * + * @return The built JSON string. + */ + fun build(): JsonObject { + if (builder == null) { + throw IllegalStateException("JSON has already been built") + } + val `object` = JsonObject(builder!!.append("}").toString()) + builder = null + return `object` + } + + /** + * A super simple representation of a JSON object. + * + * + * This class only exists to make methods of the [JsonObjectBuilder] type-safe and not + * allow a raw string inputs for methods like [JsonObjectBuilder.appendField]. + */ + class JsonObject(private val value: String) { + override fun toString(): String { + return value + } + } + + companion object { + /** + * Escapes the given string like stated in https://www.ietf.org/rfc/rfc4627.txt. + * + * + * This method escapes only the necessary characters '"', '\'. and '\u0000' - '\u001F'. + * Compact escapes are not used (e.g., '\n' is escaped as "\u000a" and not as "\n"). + * + * @param value The value to escape. + * @return The escaped value. + */ + private fun escape(value: String): String { + val builder = StringBuilder() + for (i in 0 until value.length) { + val c = value[i] + if (c == '"') { + builder.append("\\\"") + } else if (c == '\\') { + builder.append("\\\\") + } else if (c <= '\u000F') { + builder.append("\\u000").append(Integer.toHexString(c.code)) + } else if (c <= '\u001F') { + builder.append("\\u00").append(Integer.toHexString(c.code)) + } else { + builder.append(c) + } + } + return builder.toString() + } + } + + init { + builder!!.append("{") + } + } + + /** + * Creates a new Metrics instance. + * + * @param plugin Your plugin instance. + * @param serviceId The id of the service. It can be found at [What is my plugin id?](https://bstats.org/what-is-my-plugin-id) + */ + init { + this.plugin = plugin + // Get the config file + val bStatsFolder = File(plugin.dataFolder.parentFile, "bStats") + val configFile = File(bStatsFolder, "config.yml") + val config = YamlConfiguration.loadConfiguration(configFile) + if (!config.isSet("serverUuid")) { + config.addDefault("enabled", true) + config.addDefault("serverUuid", UUID.randomUUID().toString()) + config.addDefault("logFailedRequests", false) + config.addDefault("logSentData", false) + config.addDefault("logResponseStatusText", false) + // Inform the server owners about bStats + config + .options() + .header( + "bStats (https://bStats.org) collects some basic information for plugin authors, like how\n" + + "many people use their plugin and their total player count. It's recommended to keep bStats\n" + + "enabled, but if you're not comfortable with this, you can turn this setting off. There is no\n" + + "performance penalty associated with having metrics enabled, and data sent to bStats is fully\n" + + "anonymous." + ) + .copyDefaults(true) + try { + config.save(configFile) + } catch (ignored: IOException) { + } + } + // Load the data + val enabled = config.getBoolean("enabled", true) + val serverUUID = (config.getString("serverUuid"))!! + val logErrors = config.getBoolean("logFailedRequests", false) + val logSentData = config.getBoolean("logSentData", false) + val logResponseStatusText = config.getBoolean("logResponseStatusText", false) + metricsBase = MetricsBase( + "bukkit", + serverUUID, + serviceId, + enabled, + { builder: JsonObjectBuilder -> + appendPlatformData( + builder + ) + }, + { builder: JsonObjectBuilder -> + appendServiceData( + builder + ) + }, + { submitDataTask: Runnable? -> + Bukkit.getScheduler().runTask( + plugin, + (submitDataTask)!! + ) + }, + { plugin.isEnabled() }, + { message: String?, error: Throwable? -> + this.plugin.getLogger().log(Level.WARNING, message, error) + }, + { message: String? -> + this.plugin.getLogger().log(Level.INFO, message) + }, + logErrors, + logSentData, + logResponseStatusText + ) + } +} + diff --git a/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/TicketManagerPlugin.kt b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/TicketManagerPlugin.kt new file mode 100644 index 0000000..2e3449f --- /dev/null +++ b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/TicketManagerPlugin.kt @@ -0,0 +1,240 @@ +package com.github.hoshikurama.ticketmanager.spigot + +import com.github.hoshikurama.ticketmanager.common.* +import com.github.hoshikurama.ticketmanager.common.databases.Database +import com.github.hoshikurama.ticketmanager.common.databases.Memory +import com.github.hoshikurama.ticketmanager.common.databases.MySQL +import com.github.hoshikurama.ticketmanager.common.databases.SQLite +import com.github.hoshikurama.ticketmanager.spigot.events.Commands +import com.github.hoshikurama.ticketmanager.spigot.events.PlayerJoin +import com.github.hoshikurama.ticketmanager.spigot.events.TabComplete +import com.github.shynixn.mccoroutine.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import net.md_5.bungee.api.chat.TextComponent +import net.milkbowl.vault.permission.Permission +import org.bukkit.Bukkit +import java.io.File + +class TicketManagerPlugin : SuspendingJavaPlugin() { + internal val pluginState = PluginState() + internal lateinit var perms: Permission private set + internal lateinit var configStateI: ConfigState + + private lateinit var metrics: Metrics + + + companion object { lateinit var plugin: TicketManagerPlugin } + init { plugin = this } + + override suspend fun onDisableAsync() { + pluginState.pluginLocked.set(true) + configStateI.database.closeDatabase() + } + + override fun onEnable() { + + // Find Vault plugin + server.servicesManager.getRegistration(Permission::class.java)?.provider + ?.let { perms = it } + ?: this.pluginLoader.disablePlugin(this) + + // Launch Metrics + launch { + metrics = Metrics(plugin, metricsKey) + metrics.addCustomChart( + Metrics.SingleLineChart("tickets_made") { + runBlocking { + val ticketCount = pluginState.ticketCountMetrics.get() + pluginState.ticketCountMetrics.set(0) + ticketCount + } + } + ) + metrics.addCustomChart( + Metrics.SimplePie("database_type") { + configStateI.database.type.name + } + ) + } + + // Launches ConfigState initialisation + launchAsync { loadPlugin() } + + // Register Event + server.pluginManager.registerSuspendingEvents(PlayerJoin(), plugin) + + // Creates task timers + Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, Runnable { + launchAsync { configStateI.cooldowns.filterMapAsync() } + + launchAsync { + if (pluginState.pluginLocked.get()) return@launchAsync + + try { + // Mass Unread Notify + if (configStateI.allowUnreadTicketUpdates) { + Bukkit.getOnlinePlayers().asFlow() + .filter { it.has("ticketmanager.notify.unreadUpdates.scheduled") } + .onEach { + launch { + val ticketIDs = configStateI.database.getIDsWithUpdatesFor(it.uniqueId).toList() + val tickets = ticketIDs.joinToString(", ") + + if (ticketIDs.isEmpty()) return@launch + + val template = if (ticketIDs.size > 1) it.toTMLocale().notifyUnreadUpdateMulti + else it.toTMLocale().notifyUnreadUpdateSingle + + val sentMessage = template.replace("%num%", tickets) + it.sendMessage(sentMessage.run(::addColour)) + } + } + } + + val openPriority = configStateI.database.getOpenIDPriorityPairs().map { it.first }.toList() + val openCount = openPriority.count() + val assignments = configStateI.database.getBasicTickets(openPriority).mapNotNull { it.assignedTo }.toList() + + // Open and Assigned Notify + Bukkit.getOnlinePlayers().asFlow() + .filter { it.has("ticketmanager.notify.openTickets.scheduled") } + .onEach { p -> + launch { + val groups = perms.getPlayerGroups(p).map { "::$it" } + val assignedCount = assignments + .filter { it == p.name || it in groups } + .count() + + val sentMessage = p.toTMLocale().notifyOpenAssigned + .replace("%open%", "$openCount") + .replace("%assigned%", "$assignedCount") + .run(::addColour) + .run(::TextComponent) + p.spigot().sendMessage(sentMessage) + } + } + } catch (e: Exception) { + e.printStackTrace() + postModifiedStacktrace(e) + } + } + }, 100, 12000) + } + + internal suspend fun loadPlugin() = withContext(plugin.asyncDispatcher) { + pluginState.pluginLocked.set(true) + + configStateI = run { + // Creates config file if not found + if (!File(plugin.dataFolder, "config.yml").exists()) { + plugin.saveDefaultConfig() + + // Notifies users config was generated after plugin state init + launch { + while (!(::configStateI.isInitialized)) + delay(100L) + pushMassNotify("ticketmanager.notify.warning") { it.warningsNoConfig } + } + } + + plugin.reloadConfig() + plugin.config.run { + val path = plugin.dataFolder.absolutePath + val database: () -> Database? = { + val type = getString("Database_Mode", "SQLite")!! + .let { tryOrNull { Database.Type.valueOf(it) } ?: Database.Type.SQLite } + + when (type) { + Database.Type.MySQL -> MySQL( + getString("MySQL_Host")!!, + getString("MySQL_Port")!!, + getString("MySQL_DBName")!!, + getString("MySQL_Username")!!, + getString("MySQL_Password")!!, + asyncDispatcher = (plugin.asyncDispatcher as CoroutineDispatcher), + ) + Database.Type.SQLite -> SQLite(path) + Database.Type.Memory -> Memory( + filePath = path, + backupFrequency = getLong("Memory_Backup_Frequency", 600) + ) + } + } + + val cooldown: () -> ConfigState.Cooldown? = { + ConfigState.Cooldown( + getBoolean("Use_Cooldowns", false), + getLong("Cooldown_Time", 0L) + ) + } + + val localeHandler: suspend () -> LocaleHandler? = { + LocaleHandler.buildLocalesAsync( + getString("Colour_Code", "&3")!!, + getString("Preferred_Locale", "en_ca")!!, + getString("Console_Locale", "en_ca")!!, + getBoolean("Force_Locale", false), + asyncContext + ) + } + + val allowUnreadTicketUpdates: () -> Boolean? = { + getBoolean("Allow_Unread_Ticket_Updates", true) + } + + val checkForPluginUpdate: () -> Boolean? = { + getBoolean("Allow_UpdateChecking", false) + } + + val pluginVersion: () -> String = { + mainPlugin.description.version + } + + ConfigState.createPluginState( + database, + cooldown, + localeHandler, + allowUnreadTicketUpdates, + checkForPluginUpdate, + pluginVersion, + path, + asyncContext + ) + } + } + + launch { + val updateNeeded = configStateI.database.updateNeeded() + + if (updateNeeded) { + configStateI.database.updateDatabase( + onBegin = { + pushMassNotify("ticketmanager.notify.info") { it.informationDBUpdate } + }, + onComplete = { + pushMassNotify("ticketmanager.notify.info") { it.informationDBUpdateComplete } + pluginState.pluginLocked.set(true) + }, + offlinePlayerNameToUuidOrNull = { + Bukkit.getOfflinePlayers() + .filter { it.name == name } + .map { it.uniqueId } + .firstOrNull() + }, + context = asyncContext + ) + } else pluginState.pluginLocked.set(false) + } + + + withContext(minecraftDispatcher) { + // Register events and commands + configStateI.localeHandler.getCommandBases().forEach { + getCommand(it)!!.setSuspendingExecutor(Commands()) + getCommand(it)!!.tabCompleter = TabComplete() + // Remember to register any keyword in plugin.yml + } + } + } +} \ No newline at end of file diff --git a/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/events/Commands.kt b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/events/Commands.kt new file mode 100644 index 0000000..4198369 --- /dev/null +++ b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/events/Commands.kt @@ -0,0 +1,1447 @@ +package com.github.hoshikurama.ticketmanager.spigot.events + +import com.github.hoshikurama.ticketmanager.common.* +import com.github.hoshikurama.ticketmanager.common.databases.Database +import com.github.hoshikurama.ticketmanager.common.databases.Memory +import com.github.hoshikurama.ticketmanager.common.databases.MySQL +import com.github.hoshikurama.ticketmanager.common.databases.SQLite +import com.github.hoshikurama.ticketmanager.common.ticket.* +import com.github.hoshikurama.ticketmanager.spigot.* +import com.github.shynixn.mccoroutine.SuspendingCommandExecutor +import com.github.shynixn.mccoroutine.asyncDispatcher +import com.github.shynixn.mccoroutine.minecraftDispatcher +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import net.md_5.bungee.api.ChatColor +import net.md_5.bungee.api.chat.ClickEvent +import net.md_5.bungee.api.chat.ComponentBuilder +import net.md_5.bungee.api.chat.HoverEvent +import net.md_5.bungee.api.chat.TextComponent +import net.md_5.bungee.api.chat.hover.content.Text +import org.bukkit.Bukkit +import org.bukkit.Location +import org.bukkit.command.Command +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player +import java.util.* + +class Commands : SuspendingCommandExecutor { + + override suspend fun onCommand( + sender: CommandSender, + command: Command, + label: String, + args: Array, + ): Boolean = withContext(mainPlugin.asyncDispatcher) { + + val senderLocale = sender.toTMLocale() + val argList = args.toList() + + if (argList.isEmpty()) { + senderLocale.warningsInvalidCommand + .run(::addColour) + .run(sender::sendMessage) + return@withContext false + } + + if (mainPlugin.pluginState.pluginLocked.get()) { + senderLocale.warningsLocked + .run(::addColour) + .run(sender::sendMessage) + return@withContext false + } + + // Grabs BasicTicket. Only null if ID required but doesn't exist. Filters non-valid tickets + val pseudoTicket = getBasicTicketHandler(argList, senderLocale) + if (pseudoTicket == null) { + senderLocale.warningsInvalidID + .run(::addColour) + .run(sender::sendMessage) + return@withContext false + } + + // Async Calculations + val hasValidPermission = async { hasValidPermission(sender, pseudoTicket, argList, senderLocale) } + val isValidCommand = async { isValidCommand(sender, pseudoTicket, argList, senderLocale) } + val notUnderCooldown = async { notUnderCooldown(sender, senderLocale, argList) } + // Shortened Commands + val executeCommand = suspend { executeCommand(sender, argList, senderLocale, pseudoTicket) } + + try { + mainPlugin.pluginState.jobCount.run { set(get() + 1) } + if (notUnderCooldown.await() && isValidCommand.await() && hasValidPermission.await()) { + executeCommand()?.let { pushNotifications(sender, it, senderLocale, pseudoTicket) } + } + } catch (e: Exception) { + e.printStackTrace() + postModifiedStacktrace(e) + + senderLocale.warningsUnexpectedError + .run(::addColour) + .run(sender::sendMessage) + } finally { + mainPlugin.pluginState.jobCount.run { set(get() - 1) } + } + + return@withContext true + } + + private suspend fun getBasicTicketHandler( + args: List, + senderLocale: TMLocale, + ): BasicTicketHandler? { + + suspend fun buildFromID(id: Int) = BasicTicketHandler.buildHandler(configState.database, id) + + return when (args[0]) { + senderLocale.commandWordAssign, + senderLocale.commandWordSilentAssign, + senderLocale.commandWordClaim, + senderLocale.commandWordSilentClaim, + senderLocale.commandWordClose, + senderLocale.commandWordSilentClose, + senderLocale.commandWordComment, + senderLocale.commandWordSilentComment, + senderLocale.commandWordReopen, + senderLocale.commandWordSilentReopen, + senderLocale.commandWordSetPriority, + senderLocale.commandWordSilentSetPriority, + senderLocale.commandWordTeleport, + senderLocale.commandWordUnassign, + senderLocale.commandWordSilentUnassign, + senderLocale.commandWordView, + senderLocale.commandWordDeepView -> + args.getOrNull(1) + ?.toIntOrNull() + ?.let { buildFromID(it) } + else -> ConcreteBasicTicket(creatorUUID = null, location = null).run { BasicTicketHandler(this, configState.database) } // Occurs when command does not need valid handler + } + } + + private fun hasValidPermission( + sender: CommandSender, + basicTicket: BasicTicket, + args: List, + senderLocale: TMLocale + ): Boolean { + try { + if (sender !is Player) return true + + fun has(perm: String) = sender.has(perm) + fun hasSilent() = has("ticketmanager.commandArg.silence") + fun hasDuality(basePerm: String): Boolean { + val senderUUID = sender.toUUIDOrNull() + val ownsTicket = basicTicket.uuidMatches(senderUUID) + return has("$basePerm.all") || (sender.has("$basePerm.own") && ownsTicket) + } + + return senderLocale.run { + when (args[0]) { + commandWordAssign, commandWordClaim, commandWordUnassign -> + has("ticketmanager.command.assign") + commandWordSilentAssign, commandWordSilentClaim,commandWordSilentUnassign -> + has("ticketmanager.command.assign") && hasSilent() + commandWordClose -> hasDuality("ticketmanager.command.close") + commandWordSilentClose -> hasDuality("ticketmanager.command.close") && hasSilent() + commandWordCloseAll -> has("ticketmanager.command.closeAll") + commandWordSilentCloseAll -> has("ticketmanager.command.closeAll") && hasSilent() + commandWordComment -> hasDuality("ticketmanager.command.comment") + commandWordSilentComment -> hasDuality("ticketmanager.command.comment") && hasSilent() + commandWordCreate -> has("ticketmanager.command.create") + commandWordHelp -> has("ticketmanager.command.help") + commandWordReload -> has("ticketmanager.command.reload") + commandWordList -> has("ticketmanager.command.list") + commandWordListAssigned -> has("ticketmanager.command.list") + commandWordReopen -> has("ticketmanager.command.reopen") + commandWordSilentReopen -> has("ticketmanager.command.reopen") && hasSilent() + commandWordSearch -> has("ticketmanager.command.search") + commandWordSetPriority -> has("ticketmanager.command.setPriority") + commandWordSilentSetPriority -> has("ticketmanager.command.setPriority") && hasSilent() + commandWordTeleport -> has("ticketmanager.command.teleport") + commandWordView -> hasDuality("ticketmanager.command.view") + commandWordDeepView -> hasDuality("ticketmanager.command.viewdeep") + commandWordConvertDB -> has("ticketmanager.command.convertDatabase") + commandWordHistory -> + sender.has("ticketmanager.command.history.all") || + sender.has("ticketmanager.command.history.own").let { hasPerm -> + if (args.size >= 2) hasPerm && args[1] == sender.name + else hasPerm + } + else -> true + } + } + .also { if (!it) + senderLocale.warningsNoPermission + .run(::addColour) + .run(sender::sendMessage) + } + } catch (e: Exception) { + senderLocale.warningsNoPermission + .run(::addColour) + .run(sender::sendMessage) + return false + } + } + + private fun isValidCommand( + sender: CommandSender, + basicTicket: BasicTicket, + args: List, + senderLocale: TMLocale + ): Boolean { + fun sendMessage(formattedString: String) = formattedString.run(::addColour).run(sender::sendMessage) + fun invalidCommand() = sendMessage(senderLocale.warningsInvalidCommand) + fun notANumber() = sendMessage(senderLocale.warningsInvalidNumber) + fun outOfBounds() = sendMessage(senderLocale.warningsPriorityOutOfBounds) + fun ticketClosed() = sendMessage(senderLocale.warningsTicketAlreadyClosed) + fun ticketOpen() = sendMessage(senderLocale.warningsTicketAlreadyOpen) + + return senderLocale.run { + when (args[0]) { + commandWordAssign, commandWordSilentAssign -> + check(::invalidCommand) { args.size >= 3 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordClaim, commandWordSilentClaim -> + check(::invalidCommand) { args.size == 2 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordClose, commandWordSilentClose -> + check(::invalidCommand) { args.size >= 2 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordComment, commandWordSilentComment -> + check(::invalidCommand) { args.size >= 3 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordCloseAll, commandWordSilentCloseAll -> + check(::invalidCommand) { args.size == 3 } + .thenCheck(::notANumber) { args[1].toIntOrNull() != null } + .thenCheck(::notANumber) { args[2].toIntOrNull() != null } + + commandWordReopen, commandWordSilentReopen -> + check(::invalidCommand) { args.size == 2 } + .thenCheck(::ticketOpen) { basicTicket.status != BasicTicket.Status.OPEN } + + commandWordSetPriority, commandWordSilentSetPriority -> + check(::invalidCommand) { args.size == 3 } + .thenCheck(::outOfBounds) { args[2].toByteOrNull() != null } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordUnassign, commandWordSilentUnassign -> + check(::invalidCommand) { args.size == 2 } + .thenCheck(::ticketClosed) { basicTicket.status != BasicTicket.Status.CLOSED } + + commandWordView -> check(::invalidCommand) { args.size == 2 } + + commandWordDeepView -> check(::invalidCommand) { args.size == 2 } + + commandWordTeleport -> check(::invalidCommand) { args.size == 2 } + + commandWordCreate -> check(::invalidCommand) { args.size >= 2 } + + commandWordHistory -> + check(::invalidCommand) { args.isNotEmpty() } + .thenCheck(::notANumber) { if (args.size >= 3) args[2].toIntOrNull() != null else true} + + commandWordList -> + check(::notANumber) { if (args.size == 2) args[1].toIntOrNull() != null else true } + + commandWordListAssigned -> + check(::notANumber) { if (args.size == 2) args[1].toIntOrNull() != null else true } + + commandWordSearch -> check(::invalidCommand) { args.size >= 2} + + commandWordReload -> true + commandWordVersion -> true + commandWordHelp -> true + + commandWordConvertDB -> + check(::invalidCommand) { args.size == 2 } + .thenCheck( { sendMessage(senderLocale.warningsInvalidDBType) }, + { + try { Database.Type.valueOf(args[1]); true } + catch (e: Exception) { false } + } + ) + .thenCheck( { sendMessage(senderLocale.warningsConvertToSameDBType) } ) + { configState.database.type != Database.Type.valueOf(args[1]) } + + else -> false.also { invalidCommand() } + } + } + } + + private suspend fun notUnderCooldown( + sender: CommandSender, + senderLocale: TMLocale, + args: List + ): Boolean { + val underCooldown = when (args[0]) { + senderLocale.commandWordCreate, + senderLocale.commandWordComment, + senderLocale.commandWordSilentComment -> + configState.cooldowns.checkAndSetAsync(sender.toUUIDOrNull()) + else -> false + } + + if (underCooldown) + senderLocale.warningsUnderCooldown + .run(::addColour) + .run(sender::sendMessage) + return !underCooldown + } + + private suspend fun executeCommand( + sender: CommandSender, + args: List, + senderLocale: TMLocale, + ticketHandler: BasicTicketHandler + ): NotifyParams? { + return senderLocale.run { + when (args[0]) { + commandWordAssign -> assign(sender, args, false, senderLocale, ticketHandler) + commandWordAssign -> assign(sender, args, false, senderLocale, ticketHandler) + commandWordSilentAssign -> assign(sender, args, true, senderLocale, ticketHandler) + commandWordClaim -> claim(sender, args, false, senderLocale, ticketHandler) + commandWordSilentClaim -> claim(sender, args, true, senderLocale, ticketHandler) + commandWordClose -> close(sender, args, false, ticketHandler) + commandWordSilentClose -> close(sender, args, true, ticketHandler) + commandWordCloseAll -> closeAll(sender, args, false, ticketHandler) + commandWordSilentCloseAll -> closeAll(sender, args, true, ticketHandler) + commandWordComment -> comment(sender, args, false, ticketHandler) + commandWordSilentComment -> comment(sender, args, true, ticketHandler) + commandWordCreate -> create(sender, args) + commandWordHelp -> help(sender, senderLocale).let { null } + commandWordHistory -> history(sender, args, senderLocale).let { null } + commandWordList -> list(sender, args, senderLocale).let { null } + commandWordListAssigned -> listAssigned(sender, args, senderLocale).let { null } + commandWordReload -> reload(sender, senderLocale).let { null } + commandWordReopen -> reopen(sender,args, false, ticketHandler) + commandWordSilentReopen -> reopen(sender,args, true, ticketHandler) + commandWordSearch -> search(sender, args, senderLocale).let { null } + commandWordSetPriority -> setPriority(sender, args, false, ticketHandler) + commandWordSilentSetPriority -> setPriority(sender, args, true, ticketHandler) + commandWordTeleport -> teleport(sender, ticketHandler).let { null } + commandWordUnassign -> unAssign(sender, args, false, senderLocale, ticketHandler) + commandWordSilentUnassign -> unAssign(sender, args, true, senderLocale, ticketHandler) + commandWordVersion -> version(sender, senderLocale).let { null } + commandWordView -> view(sender, senderLocale, ticketHandler).let { null } + commandWordDeepView -> viewDeep(sender, senderLocale, ticketHandler).let { null } + commandWordConvertDB -> convertDatabase(args).let { null } + else -> null + } + } + } + + private fun pushNotifications( + sender: CommandSender, + params: NotifyParams, + locale: TMLocale, + basicTicket: BasicTicket + ) { + params.run { + if (sendSenderMSG) + senderLambda!!(locale) + .run(::addColour) + .run(sender::sendMessage) + + if (sendCreatorMSG) + basicTicket.creatorUUID + ?.run(Bukkit::getPlayer) + ?.let { creatorLambda!!(it.toTMLocale()) } + ?.let(::addColour) + ?.run(creator!!::sendMessage) + + if (sendMassNotifyMSG) + pushMassNotify(massNotifyPerm, massNotifyLambda!!) + } + } + + private class NotifyParams( + silent: Boolean, + basicTicket: BasicTicket, + sender: CommandSender, + creatorAlertPerm: String, + val massNotifyPerm: String, + val senderLambda: ((TMLocale) -> String)?, + val creatorLambda: ((TMLocale) -> String)?, + val massNotifyLambda: ((TMLocale) -> String)?, + ) { + val creator: Player? = basicTicket.creatorUUID?.let(Bukkit::getPlayer) + val sendSenderMSG: Boolean = (!sender.has(massNotifyPerm) || silent) + && senderLambda != null + val sendCreatorMSG: Boolean = sender.nonCreatorMadeChange(basicTicket.creatorUUID) + && !silent && (creator?.isOnline ?: false) + && (creator?.has(creatorAlertPerm) ?: false) + && (creator?.has(massNotifyPerm)?.run { !this } ?: false) + && creatorLambda != null + val sendMassNotifyMSG: Boolean = !silent + && massNotifyLambda != null + } + + /*-------------------------*/ + /* Commands */ + /*-------------------------*/ + + private suspend fun allAssignVariations( + sender: CommandSender, + silent: Boolean, + senderLocale: TMLocale, + assignmentID: String, + dbAssignment: String?, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val shownAssignment = dbAssignment ?: senderLocale.miscNobody + + launch { ticketHandler.setAssignedTo(dbAssignment) } + launch { configState.database.addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.ASSIGN, sender.toUUIDOrNull(), dbAssignment) + )} + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + senderLambda = { + it.notifyTicketAssignSuccess + .replace("%id%", assignmentID) + .replace("%assign%", shownAssignment) + }, + massNotifyLambda = { + it.notifyTicketAssignEvent + .replace("%user%", sender.name) + .replace("%id%", assignmentID) + .replace("%assign%", shownAssignment) + }, + creatorLambda = null, + creatorAlertPerm = "ticketmanager.notify.change.assign", + massNotifyPerm = "ticketmanager.notify.massNotify.assign" + ) + } + + // /ticket assign + private suspend fun assign( + sender: CommandSender, + args: List, + silent: Boolean, + senderLocale: TMLocale, + ticketHandler: BasicTicketHandler, + ): NotifyParams { + val sqlAssignment = args.subList(2, args.size).joinToString(" ") + return allAssignVariations(sender, silent, senderLocale, args[1], sqlAssignment, ticketHandler) + } + + // /ticket claim + private suspend fun claim( + sender: CommandSender, + args: List, + silent: Boolean, + senderLocale: TMLocale, + ticketHandler: BasicTicketHandler, + ): NotifyParams { + return allAssignVariations(sender, silent, senderLocale, args[1], sender.name, ticketHandler) + } + + // /ticket close [Comment...] + private suspend fun close( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler + ): NotifyParams = withContext(asyncContext) { + val newCreatorStatusUpdate = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && configState.allowUnreadTicketUpdates + if (newCreatorStatusUpdate != ticketHandler.creatorStatusUpdate) { + launch { ticketHandler.setCreatorStatusUpdate(newCreatorStatusUpdate) } + } + + return@withContext if (args.size >= 3) + closeWithComment(sender, args, silent, ticketHandler) + else closeWithoutComment(sender, args, silent, ticketHandler) + } + + private suspend fun closeWithComment( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val message = args.subList(2, args.size) + .joinToString(" ") + .run(ChatColor::stripColor)!! + + launch { + configState.database.run { + addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.COMMENT, sender.toUUIDOrNull(), message) + ) + addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.CLOSE, sender.toUUIDOrNull()) + ) + ticketHandler.setTicketStatus(BasicTicket.Status.CLOSED) + } + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + senderLambda = { + it.notifyTicketCloseWCommentSuccess + .replace("%id%", args[1]) + }, + massNotifyLambda = { + it.notifyTicketCloseWCommentEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + .replace("%message%", message) + }, + creatorLambda = { + it.notifyTicketModificationEvent + .replace("%id%", args[1]) + }, + massNotifyPerm = "ticketmanager.notify.massNotify.close", + creatorAlertPerm = "ticketmanager.notify.change.close" + ) + } + + private suspend fun closeWithoutComment( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler + ): NotifyParams = withContext(asyncContext) { + launch { + configState.database.addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.CLOSE, sender.toUUIDOrNull()) + ) + ticketHandler.setTicketStatus(BasicTicket.Status.CLOSED) + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + creatorLambda = { + it.notifyTicketModificationEvent + .replace("%id%", args[1]) + }, + massNotifyLambda = { + it.notifyTicketCloseEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + }, + senderLambda = { + it.notifyTicketCloseSuccess + .replace("%id%", args[1]) + }, + massNotifyPerm = "ticketmanager.notify.massNotify.close", + creatorAlertPerm = "ticketmanager.notify.change.close" + ) + } + + // /ticket closeall + private suspend fun closeAll( + sender: CommandSender, + args: List, + silent: Boolean, + basicTicket: BasicTicket + ): NotifyParams = withContext(asyncContext) { + val lowerBound = args[1].toInt() + val upperBound = args[2].toInt() + + launch { configState.database.massCloseTickets(lowerBound, upperBound, sender.toUUIDOrNull(), asyncContext) } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = basicTicket, + creatorLambda = null, + senderLambda = { + it.notifyTicketMassCloseSuccess + .replace("%low%", args[1]) + .replace("%high%", args[2]) + }, + massNotifyLambda = { + it.notifyTicketMassCloseEvent + .replace("%user%", sender.name) + .replace("%low%", args[1]) + .replace("%high%", args[2]) + }, + massNotifyPerm = "ticketmanager.notify.massNotify.massClose", + creatorAlertPerm = "ticketmanager.notify.change.massClose" + ) + } + + // /ticket comment + private suspend fun comment( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val message = args.subList(2, args.size) + .joinToString(" ") + .run(ChatColor::stripColor)!! + + val newCreatorStatusUpdate = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && configState.allowUnreadTicketUpdates + if (newCreatorStatusUpdate != ticketHandler.creatorStatusUpdate) { + launch { ticketHandler.setCreatorStatusUpdate(newCreatorStatusUpdate) } + } + + launch { + configState.database.addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.COMMENT, sender.toUUIDOrNull(), message) + ) + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + creatorLambda = { + it.notifyTicketModificationEvent + .replace("%id%", args[1]) + }, + senderLambda = { + it.notifyTicketCommentSuccess + .replace("%id%", args[1]) + }, + massNotifyLambda = { + it.notifyTicketCommentEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + .replace("%message%", message) + }, + massNotifyPerm = "ticketmanager.notify.massNotify.comment", + creatorAlertPerm = "ticketmanager.notify.change.comment" + ) + } + + // /ticket convertdatabase + private suspend fun convertDatabase(args: List) { + val type = args[1].run(Database.Type::valueOf) + val config = mainPlugin.config + + try { + configState.database.migrateDatabase( + context = asyncContext, + to = type, + sqLiteBuilder = { SQLite(mainPlugin.dataFolder.absolutePath) }, + mySQLBuilder = { + MySQL( + config.getString("MySQL_Host")!!, + config.getString("MySQL_Port")!!, + config.getString("MySQL_DBName")!!, + config.getString("MySQL_Username")!!, + config.getString("MySQL_Password")!!, + asyncDispatcher = (mainPlugin.asyncDispatcher as CoroutineDispatcher) + ) + }, + memoryBuilder = { + Memory( + filePath = mainPlugin.dataFolder.absolutePath, + backupFrequency = config.getLong("Memory_Backup_Frequency", 600), + ) + }, + onBegin = { + mainPlugin.pluginState.pluginLocked.set(true) + pushMassNotify("ticketmanager.notify.info") { + it.informationDBConvertInit + .replace("%fromDB%", configState.database.type.name) + .replace("%toDB%", type.name) + } + }, + onComplete = { + mainPlugin.pluginState.pluginLocked.set(false) + pushMassNotify("ticketmanager.notify.info") { it.informationDBConvertSuccess } + } + ) + } catch (e: Exception) { + mainPlugin.pluginState.pluginLocked.set(false) + throw e + } + } + + // /ticket create + private suspend fun create( + sender: CommandSender, + args: List, + ): NotifyParams = withContext(asyncContext) { + val message = args.subList(1, args.size) + .joinToString(" ") + .run(ChatColor::stripColor)!! + + val ticket = ConcreteBasicTicket(creatorUUID = sender.toUUIDOrNull(), location = sender.toTicketLocationOrNull()) + + val deferredID = async { configState.database.addNewTicket(ticket, asyncContext, message) } + mainPlugin.pluginState.ticketCountMetrics.run { set(get() + 1) } + val id = deferredID.await().toString() + + NotifyParams( + silent = false, + sender = sender, + basicTicket = ticket, + creatorLambda = null, + senderLambda = { + it.notifyTicketCreationSuccess + .replace("%id%", id) + }, + massNotifyLambda = { + it.notifyTicketCreationEvent + .replace("%user%", sender.name) + .replace("%id%", id) + .replace("%message%", message) + }, + creatorAlertPerm = "ticketmanager.NO NODE", + massNotifyPerm = "ticketmanager.notify.massNotify.create", + ) + } + + // /ticket help + private fun help( + sender: CommandSender, + locale: TMLocale, + ) { + val hasSilentPerm = sender.has("ticketmanager.commandArg.silence") + val cc = configState.localeHandler.mainColourCode + + val component = ComponentBuilder("") + + val header = mutableListOf( + locale.helpHeader, + locale.helpLine1, + ) + if (hasSilentPerm) { + header.add(locale.helpLine2) + header.add(locale.helpLine3) + } + header.add(locale.helpSep) + header.map(::addColour).forEach(component::append) + + locale.run { + listOf( // Triple(silence-able, format, permissions) + Triple(true, "$commandWordAssign &f<$parameterID> <$parameterAssignment...>", listOf("ticketmanager.command.assign")), + Triple(true, "$commandWordClaim &f<$parameterID>", listOf("ticketmanager.command.claim")), + Triple(true, "$commandWordClose &f<$parameterID> &7[$parameterComment...]", listOf("ticketmanager.command.close.all", "ticketmanager.command.close.own")), + Triple(true, "$commandWordCloseAll &f<$parameterLowerID> <$parameterUpperID>", listOf("ticketmanager.command.closeAll")), + Triple(true, "$commandWordComment &f<$parameterID> <$parameterComment...>", listOf("ticketmanager.command.comment.all", "ticketmanager.command.comment.own")), + Triple(false, "$commandWordConvertDB &f<$parameterTargetDB>", listOf("ticketmanager.command.convertDatabase")), + Triple(false, "$commandWordCreate &f<$parameterComment...>", listOf("ticketmanager.command.create")), + Triple(false, commandWordHelp, listOf("ticketmanager.command.help")), + Triple(false, "$commandWordHistory &7[$parameterUser] [$parameterPage]", listOf("ticketmanager.command.history.all", "ticketmanager.command.history.own")), + Triple(false, "$commandWordList &7[$parameterPage]", listOf("ticketmanager.command.list")), + Triple(false, "$commandWordListAssigned &7[$parameterPage]", listOf("ticketmanager.command.list")), + Triple(false, commandWordReload, listOf("ticketmanager.command.reload")), + Triple(true, "$commandWordReopen &f<$parameterID>", listOf("ticketmanager.command.reopen")), + Triple(false, "$commandWordSearch &f<$parameterConstraints...>", listOf("ticketmanager.command.search")), + Triple(true, "$commandWordSetPriority &f<$parameterID> <$parameterLevel>", listOf("ticketmanager.command.setPriority")), + Triple(false, "$commandWordTeleport &f<$parameterID>", listOf("ticketmanager.command.teleport")), + Triple(true, "$commandWordUnassign &f<$parameterID>", listOf("ticketmanager.command.assign")), + Triple(false, "$commandWordView &f<$parameterID>", listOf("ticketmanager.command.view.all", "ticketmanager.command.view.own")), + Triple(false, "$commandWordDeepView &f<$parameterID>", listOf("ticketmanager.command.viewdeep.all", "ticketmanager.command.viewdeep.own")) + ) + .filter { it.third.any(sender::has) } + .run { this + Triple(false, locale.commandWordVersion, "NA") } + .map { + val commandString = "$cc/${locale.commandBase} ${it.second}" + if (hasSilentPerm) + if (it.first) "\n&a[✓] $commandString" + else "\n&c[✕] $commandString" + else "\n$commandString" + } + .map(::addColour) + .forEach(component::append) + + sender.spigot().sendMessage(*component.create()) + } + } + + // /ticket history [User] [Page] + private suspend fun history( + sender: CommandSender, + args: List, + locale: TMLocale, + ) { + withContext(asyncContext) { + val targetName = + if (args.size >= 2) args[1].takeIf { it != locale.consoleName } else sender.name.takeIf { sender is Player } + val requestedPage = if (args.size >= 3) args[2].toInt() else 1 + + // Leaves console as null. Otherwise attempts UUID grab or [PLAYERNOTFOUND] + fun String.attemptToUUIDString(): String = + Bukkit.getOfflinePlayers().asSequence() + .firstOrNull { equals(it.name) } + ?.run { uniqueId.toString() } + ?: "[PLAYERNOTFOUND]" + + val searchedUser = targetName?.attemptToUUIDString() + + val resultSize: Int + val resultsChunked = configState.database.searchDatabase(asyncContext, locale, listOf(locale.searchCreator to searchedUser)) { true } + .toList() + .sortedByDescending(BasicTicket::id) + .also { resultSize = it.size } + .chunked(6) + + val componentBuilder = ComponentBuilder() + + locale.historyHeader + .replace("%name%", targetName ?: locale.consoleName) + .replace("%count%", "$resultSize") + .run(::addColour) + .run(componentBuilder::append) + + val actualPage = if (requestedPage >= 1 && requestedPage < resultsChunked.size) requestedPage else 1 + if (resultsChunked.isNotEmpty()) { + resultsChunked.getOrElse(requestedPage - 1) { resultsChunked[1] }.forEach { t -> + locale.historyEntry + .let { "\n$it" } + .replace("%id%", "${t.id}") + .replace("%SCC%", t.status.colourCode) + .replace("%status%", t.status.toLocaledWord(locale)) + .replace("%comment%", t.actions[0].message!!) + .let { if (it.length > 80) "${it.substring(0, 81)}..." else it } + .run(::addColour) + .run(::TextComponent) + .run { convertToClickToView(t.id, locale) } + .run(componentBuilder::append) + } + + if (resultsChunked.size > 1) { + buildPageComponent(actualPage, resultsChunked.size, locale) { + "/${it.commandBase} ${it.commandWordHistory} ${targetName ?: it.consoleName} " + }.run(componentBuilder::append) + } + } + + sender.spigot().sendMessage(*componentBuilder.create()) + } + } + + // /ticket list [Page] + private suspend fun list( + sender: CommandSender, + args: List, + locale: TMLocale, + ) { + sender.spigot().sendMessage( + createGeneralList(args, locale, locale.listFormatHeader, + getIDPriorityPair = { it.getOpenIDPriorityPairs() }, + baseCommand = locale.run{ { "/$commandBase $commandWordList " } } + ) + ) + } + + // /ticket listassigned [Page] + private suspend fun listAssigned( + sender: CommandSender, + args: List, + locale: TMLocale, + ) { + val groups: List = if (sender is Player) mainPlugin.perms.getPlayerGroups(sender).toList() else listOf() + + sender.spigot().sendMessage( + createGeneralList(args, locale, locale.listFormatAssignedHeader, + getIDPriorityPair = { it.getAssignedOpenIDPriorityPairs(sender.name, groups) }, + baseCommand = locale.run { { "/$commandBase $commandWordListAssigned " } } + ) + ) + } + + // /ticket reload + private suspend fun reload( + sender: CommandSender, + locale: TMLocale, + ) { + withContext(asyncContext) { + try { + mainPlugin.pluginState.pluginLocked.set(true) + pushMassNotify("ticketmanager.notify.info") { + it.informationReloadInitiated + .replace("%user%", sender.name) + } + + val forceQuitJob = launch { + delay(30L * 1000L) + + // Long standing task has occurred if it reaches this point + launch { + pushMassNotify("ticketmanager.notify.warning") { + it.warningsLongTaskDuringReload + } + mainPlugin.pluginState.jobCount.set(1) + mainPlugin.asyncDispatcher.cancelChildren() + } + } + + // Waits for other tasks to complete + while (mainPlugin.pluginState.jobCount.get() > 1) delay(1000L) + + if (!forceQuitJob.isCancelled) + forceQuitJob.cancel("Tasks closed on time") + + pushMassNotify("ticketmanager.notify.info") { it.informationReloadTasksDone } + configState.database.closeDatabase() + mainPlugin.loadPlugin() + + pushMassNotify("ticketmanager.notify.info") { it.informationReloadSuccess } + if (!sender.has("ticketmanager.notify.info")) { + locale.informationReloadSuccess + .run(::addColour) + .run(sender::sendMessage) + } + } catch (e: Exception) { + pushMassNotify("ticketmanager.notify.info") { it.informationReloadFailure } + throw e + } + } + } + + // /ticket reopen + private suspend fun reopen( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val action = FullTicket.Action(FullTicket.Action.Type.REOPEN, sender.toUUIDOrNull()) + + // Updates user status if needed + val newCreatorStatusUpdate = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && configState.allowUnreadTicketUpdates + if (newCreatorStatusUpdate != ticketHandler.creatorStatusUpdate) { + launch { ticketHandler.setCreatorStatusUpdate(newCreatorStatusUpdate) } + } + + launch { + configState.database.addAction(ticketHandler.id, action) + ticketHandler.setTicketStatus(BasicTicket.Status.OPEN) + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + creatorLambda = { + it.notifyTicketModificationEvent + .replace("%id%", args[1]) + }, + senderLambda = { + it.notifyTicketReopenSuccess + .replace("%id%", args[1]) + }, + massNotifyLambda = { + it.notifyTicketReopenEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + }, + creatorAlertPerm = "ticketmanager.notify.change.reopen", + massNotifyPerm = "ticketmanager.notify.massNotify.reopen", + ) + } + + private suspend fun search( + sender: CommandSender, + args: List, + locale: TMLocale, + ) { + withContext(asyncContext) { + fun String.attemptToUUIDString(): String? = + if (equals(locale.consoleName)) null + else Bukkit.getOfflinePlayers().asSequence() + .firstOrNull { equals(it.name) } + ?.run { uniqueId.toString() } + ?: "[PLAYERNOTFOUND]" + + // Beginning of execution + locale.searchFormatQuerying + .run(::addColour) + .run(sender::sendMessage) + + // Input args mapped to valid search types + val arguments = args.subList(1, args.size) + .asSequence() + .map { it.split(":", limit = 2) } + .filter { it.size >= 2 } + .associate { it[0] to it[1] } + + val mainTableConstrains = arguments + .mapNotNull { (key, value) -> + when (key) { + locale.searchAssigned -> key to value + locale.searchCreator -> key to value.attemptToUUIDString() + locale.searchPriority -> value.toByteOrNull()?.run { key to this.toString() } + locale.searchStatus -> { + val constraintStatus = when (value) { + locale.statusOpen -> BasicTicket.Status.OPEN.name + locale.statusClosed -> BasicTicket.Status.CLOSED.name + else -> null + } + constraintStatus?.run { key to this } + } + else -> null + } + } + + val functionConstraints = arguments + .mapNotNull { (key, value) -> + when (key) { + + locale.searchClosedBy -> { + val searchedUser = value.attemptToUUIDString(); + { t: FullTicket -> t.actions.any { it.type == FullTicket.Action.Type.CLOSE && it.user?.toString() == searchedUser } } + } + + locale.searchLastClosedBy -> { + val searchedUser = value.attemptToUUIDString(); + { t: FullTicket -> + t.actions.lastOrNull { e -> e.type == FullTicket.Action.Type.CLOSE } + ?.run { user?.toString() == searchedUser } + ?: false + } + } + + locale.searchWorld -> { t: FullTicket -> t.location?.world?.equals(value) ?: false } + + locale.searchTime -> { + val creationTime = relTimeToEpochSecond(value, locale); + { t: FullTicket -> t.actions[0].timestamp >= creationTime } + } + + locale.searchKeywords -> { + val words = value.split(","); + + { t: FullTicket -> + val comments = t.actions + .filter { it.type == FullTicket.Action.Type.OPEN || it.type == FullTicket.Action.Type.COMMENT } + .map { it.message!! } + words.map { w -> comments.any { it.lowercase().contains(w.lowercase()) } } + .all { it } + } + } + + else -> null + } + } + val composedSearch = + if (functionConstraints.isNotEmpty()) + { t: FullTicket -> functionConstraints.map { it(t) }.all { it } } + else { _: FullTicket -> true } + + // Results Computation + val resultSize: Int + val chunkedTickets = + configState.database.searchDatabase(asyncContext, locale, mainTableConstrains, composedSearch) + .toList() + .sortedByDescending(BasicTicket::id) + .apply { resultSize = size } + .chunked(8) + + val page = arguments[locale.searchPage]?.toIntOrNull() + .let { if (it != null && it >= 1 && it < chunkedTickets.size) it else 1 } + val fixMSGLength = + { t: FullTicket -> t.actions[0].message!!.run { if (length > 25) "${substring(0, 21)}..." else this } } + + val component = TextComponent("") + + // Adds header + locale.searchFormatHeader + .replace("%size%", "$resultSize") + .run(::addColour) + .run(component::addExtra) + + // Adds entries + if (chunkedTickets.isNotEmpty()) { + chunkedTickets[page - 1] + .map { + "\n${locale.searchFormatEntry}" + .replace("%PCC%", it.priority.colourCode) + .replace("%SCC%", it.status.colourCode) + .replace("%id%", "${it.id}") + .replace("%status%", it.status.toLocaledWord(locale)) + .replace("%creator%", it.creatorUUID.toName(locale)) + .replace("%assign%", it.assignedTo ?: "") + .replace("%world%", it.location?.world ?: "") + .replace("%time%", it.actions[0].timestamp.toLargestRelativeTime(locale)) + .replace("%comment%", fixMSGLength(it)) + .run(::addColour) + .run(::TextComponent) + .run { convertToClickToView(it.id, locale) } + } + .forEach(component::addExtra) + + // Implements pages if needed + if (chunkedTickets.size > 1) { + buildPageComponent(page, chunkedTickets.size, locale) { + // Removes page constraint and converts rest to key:arg + val constraints = arguments + .filter { it.key != locale.searchPage } + .map { (k, v) -> "$k:$v" } + "/${locale.commandBase} ${locale.commandWordSearch} $constraints ${locale.searchPage}:" + }.run(component::addExtra) + } + } + sender.spigot().sendMessage(component) + } + } + + // /ticket setpriority + private suspend fun setPriority( + sender: CommandSender, + args: List, + silent: Boolean, + ticketHandler: BasicTicketHandler, + ): NotifyParams = withContext(asyncContext) { + val newPriority = byteToPriority(args[2].toByte()) + launch { + configState.database.addAction( + ticketID = ticketHandler.id, + action = FullTicket.Action(FullTicket.Action.Type.SET_PRIORITY, sender.toUUIDOrNull(), args[2]) + ) + ticketHandler.setTicketPriority(newPriority) + } + + NotifyParams( + silent = silent, + sender = sender, + basicTicket = ticketHandler, + creatorLambda = null, + senderLambda = { + it.notifyTicketSetPrioritySuccess + .replace("%id%", args[1]) + .replace("%priority%", ticketHandler.run { newPriority.colourCode + newPriority.toLocaledWord(it) }) + }, + massNotifyLambda = { + it.notifyTicketSetPriorityEvent + .replace("%user%", sender.name) + .replace("%id%", args[1]) + .replace("%priority%", ticketHandler.run { newPriority.colourCode + newPriority.toLocaledWord(it) }) + }, + creatorAlertPerm = "ticketmanager.notify.change.priority", + massNotifyPerm = "ticketmanager.notify.massNotify.priority" + ) + } + + // /ticket teleport + private suspend fun teleport( + sender: CommandSender, + basicTicket: BasicTicket, + ) { + if (sender is Player && basicTicket.location != null) { + val loc = basicTicket.location!!.run { Location(Bukkit.getWorld(world), x.toDouble(), y.toDouble(), z.toDouble()) } + withContext(mainPlugin.minecraftDispatcher) { + sender.teleport(loc) + } + } + } + + // /ticket unassign + private suspend fun unAssign( + sender: CommandSender, + args: List, + silent: Boolean, + senderLocale: TMLocale, + ticketHandler: BasicTicketHandler, + ): NotifyParams { + return allAssignVariations(sender, silent, senderLocale, args[1], null, ticketHandler) + } + + // /ticket version + private fun version( + sender: CommandSender, + locale: TMLocale, + ) { + val sentComponent = TextComponent("") + val components = listOf( + "&3===========================&r\n", + "&3&lTicketManager:&r&7 by HoshiKurama&r\n", + " &3GitHub Wiki: ", + "&7&nHERE&r\n", + " &3V$pluginVersion\n", + "&3===========================&r" + ) + .map(::addColour) + .map(::TextComponent) + + components[3].clickEvent = ClickEvent(ClickEvent.Action.OPEN_URL, locale.wikiLink) + components[3].hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickWiki)) + + components.forEach(sentComponent::addExtra) + + sender.spigot().sendMessage(sentComponent) + } + + // /ticket view + private suspend fun view( + sender: CommandSender, + locale: TMLocale, + ticketHandler: BasicTicketHandler, + ) { + + + withContext(asyncContext) { + val fullTicket = ticketHandler.toFullTicket() + val baseComponent = buildTicketInfoComponent(fullTicket, locale) + + if (!sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && ticketHandler.creatorStatusUpdate) + launch { ticketHandler.setCreatorStatusUpdate(false) } + + // Entries + fullTicket.actions.asSequence() + .filter { it.type == FullTicket.Action.Type.COMMENT || it.type == FullTicket.Action.Type.OPEN } + .map { + "\n${locale.viewFormatComment}" + .replace("%user%", it.user.toName(locale)) + .replace("%comment%", it.message!!) + } + .map(::addColour) + .map(::TextComponent) + .forEach(baseComponent::addExtra) + + sender.spigot().sendMessage(baseComponent) + } + } + + // /ticket viewdeep + private suspend fun viewDeep( + sender: CommandSender, + locale: TMLocale, + ticketHandler: BasicTicketHandler, + ) { + withContext(asyncContext) { + val fullTicket = ticketHandler.toFullTicket() + val baseComponent = buildTicketInfoComponent(fullTicket, locale) + + if (!sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && ticketHandler.creatorStatusUpdate) + launch { ticketHandler.setCreatorStatusUpdate(false) } + + fun formatDeepAction(action: FullTicket.Action): String { + val result = when (action.type) { + FullTicket.Action.Type.OPEN, FullTicket.Action.Type.COMMENT -> + "\n${locale.viewFormatDeepComment}" + .replace("%comment%", action.message!!) + + FullTicket.Action.Type.SET_PRIORITY -> + "\n${locale.viewFormatDeepSetPriority}" + .replace("%priority%", + byteToPriority(action.message!!.toByte()).run { colourCode + toLocaledWord(locale) } + ) + + FullTicket.Action.Type.ASSIGN -> + "\n${locale.viewFormatDeepAssigned}" + .replace("%assign%", action.message ?: "") + + FullTicket.Action.Type.REOPEN -> "\n${locale.viewFormatDeepReopen}" + FullTicket.Action.Type.CLOSE -> "\n${locale.viewFormatDeepClose}" + FullTicket.Action.Type.MASS_CLOSE -> "\n${locale.viewFormatDeepMassClose}" + } + return result + .replace("%user%", action.user.toName(locale)) + .replace("%time%", action.timestamp.toLargestRelativeTime(locale)) + } + + fullTicket.actions.asSequence() + .map(::formatDeepAction) + .map(::addColour) + .map(::TextComponent) + .forEach(baseComponent::addExtra) + + sender.spigot().sendMessage(baseComponent) + } + } + + private fun buildPageComponent( + curPage: Int, + pageCount: Int, + locale: TMLocale, + baseCommand: (TMLocale) -> String, + ): TextComponent { + fun TextComponent.addForward() { + this.color = ChatColor.WHITE + this.clickEvent = ClickEvent(ClickEvent.Action.RUN_COMMAND, baseCommand(locale) + "${curPage + 1}") + this.hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickNextPage)) + } + + fun TextComponent.addBack() { + this.color = ChatColor.WHITE + this.clickEvent = ClickEvent(ClickEvent.Action.RUN_COMMAND, baseCommand(locale) + "${curPage - 1}") + this.hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickBackPage)) + } + + val back = TextComponent("[${locale.pageBack}]".run(::addColour)) + val next = TextComponent("[${locale.pageNext}]".run(::addColour)) + + val separator = TextComponent("...............") + separator.color = ChatColor.DARK_GRAY + + val cc = configState.localeHandler.mainColourCode + val ofSection = "$cc($curPage${locale.pageOf}$pageCount)" + .run(::addColour) + .run(::TextComponent) + + when (curPage) { + 1 -> { + back.color = ChatColor.DARK_GRAY + next.addForward() + } + pageCount -> { + back.addBack() + next.color = ChatColor.DARK_GRAY + } + else -> { + back.addBack() + next.addForward() + } + } + + val textComponent = TextComponent("\n") + textComponent.apply { + addExtra(back) + addExtra(separator) + addExtra(ofSection) + addExtra(separator) + addExtra(next) + } + + return textComponent + } + + private fun createListEntry( + ticket: FullTicket, + locale: TMLocale + ): TextComponent { + val creator = ticket.creatorUUID.toName(locale) + val fixedAssign = ticket.assignedTo ?: "" + + // Shortens comment preview to fit on one line + val fixedComment = ticket.run { + if (12 + id.toString().length + creator.length + fixedAssign.length + actions[0].message!!.length > 58) + actions[0].message!!.substring( + 0, + 43 - id.toString().length - fixedAssign.length - creator.length + ) + "..." + else actions[0].message!! + } + + return "\n${locale.listFormatEntry}" + .replace("%priorityCC%", ticket.priority.colourCode) + .replace("%ID%", "${ticket.id}") + .replace("%creator%", creator) + .replace("%assign%", fixedAssign) + .replace("%comment%", fixedComment) + .run(::addColour) + .run(::TextComponent) + .run { convertToClickToView(ticket.id, locale) } + } + + private suspend fun createGeneralList( + args: List, + locale: TMLocale, + headerFormat: String, + getIDPriorityPair: suspend (Database) -> Flow>, + baseCommand: (TMLocale) -> String + ): TextComponent { + val chunkedIDs = getIDPriorityPair(configState.database) + .toList() + .sortedWith(compareByDescending> { it.second }.thenByDescending { it.first } ) + .map { it.first } + .chunked(8) + + val page = if (args.size == 2 && args[1].toInt() in 1..chunkedIDs.size) args[1].toInt() else 1 + + val fullTickets = chunkedIDs.getOrNull(page - 1) + ?.run { configState.database.getFullTickets(this, asyncContext) } + ?.toList() + ?: emptyList() + + val builder = TextComponent("") + headerFormat + .run(::addColour) + .run(builder::addExtra) + + if (fullTickets.isNotEmpty()) { + + fullTickets.map { createListEntry(it, locale) } + .forEach(builder::addExtra) + + if (chunkedIDs.size > 1) + buildPageComponent(page, chunkedIDs.size, locale, baseCommand) + .run(builder::addExtra) + } + + return builder + } + + private fun buildTicketInfoComponent( + ticket: FullTicket, + locale: TMLocale, + ): TextComponent { + val textComponent = TextComponent("") + + val info = listOf( + "\n${locale.viewFormatHeader}" + .replace("%num%", "${ticket.id}"), + "\n${locale.viewFormatSep1}", + "\n${locale.viewFormatInfo1}" + .replace("%creator%", ticket.creatorUUID.toName(locale)) + .replace("%assignment%", ticket.assignedTo ?: ""), + "\n${locale.viewFormatInfo2}" + .replace("%priority%", ticket.priority.run { colourCode + toLocaledWord(locale) }) + .replace("%status%", ticket.status.run { colourCode + toLocaledWord(locale) }), + "\n${locale.viewFormatInfo3}" + .replace("%location%", ticket.location?.toString() ?: ""), + ) + .map(::addColour) + .map(::TextComponent) + + if (ticket.location != null) { + info[4].hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickTeleport)) + info[4].clickEvent = ClickEvent(ClickEvent.Action.RUN_COMMAND, + locale.run { "/$commandBase $commandWordTeleport ${ticket.id}" }) + } + + info.forEach(textComponent::addExtra) + return textComponent + } +} + +/*-------------------------*/ +/* Other Functions */ +/*-------------------------*/ + +private inline fun check(error: () -> Unit, predicate: () -> Boolean): Boolean { + return if (predicate()) true else error().run { false } +} + +private inline fun Boolean.thenCheck(error: () -> Unit, predicate: () -> Boolean): Boolean { + return if (!this) false + else if (predicate()) true + else error().run { false } +} + +private fun CommandSender.nonCreatorMadeChange(creatorUUID: UUID?): Boolean { + if (creatorUUID == null) return false + return this.toUUIDOrNull()?.notEquals(creatorUUID) ?: true +} + +private fun CommandSender.toTicketLocationOrNull() = if (this is Player) + location.run { BasicTicket.TicketLocation(world!!.name, blockX, blockY, blockZ) } + else null + +private fun TextComponent.convertToClickToView(id: Int, locale: TMLocale): TextComponent { + hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickViewTicket)) + clickEvent = ClickEvent(ClickEvent.Action.RUN_COMMAND, locale.run { "/$commandBase $commandWordView $id" } ) + + val contentStop = TextComponent("") + contentStop.hoverEvent = null + contentStop.clickEvent = null + + this.addExtra(contentStop) + return this +} \ No newline at end of file diff --git a/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/events/PlayerJoin.kt b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/events/PlayerJoin.kt new file mode 100644 index 0000000..0ffa538 --- /dev/null +++ b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/events/PlayerJoin.kt @@ -0,0 +1,68 @@ +package com.github.hoshikurama.ticketmanager.spigot.events + +import com.github.hoshikurama.ticketmanager.spigot.* +import com.github.shynixn.mccoroutine.asyncDispatcher +import com.github.shynixn.mccoroutine.minecraftDispatcher +import kotlinx.coroutines.flow.count +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent + +class PlayerJoin : Listener { + + @EventHandler + suspend fun onPlayerJoin(event: PlayerJoinEvent) = withContext(mainPlugin.minecraftDispatcher) { + if (mainPlugin.pluginState.pluginLocked.get()) return@withContext + val player = event.player + + withContext(mainPlugin.asyncDispatcher) { + + //Plugin Update Checking + launch { + val pluginUpdateStatus = configState.pluginUpdateAvailable.await() + if (player.has("ticketmanager.notify.pluginUpdate") && pluginUpdateStatus != null) { + player.toTMLocale().notifyPluginUpdate + .replace("%current%", pluginUpdateStatus.first) + .replace("%latest%", pluginUpdateStatus.second) + .run(::addColour) + .run(player::sendMessage) + } + } + + // Unread Updates + launch { + if (player.has("ticketmanager.notify.unreadUpdates.onJoin")) { + configState.database.getIDsWithUpdatesFor(player.uniqueId) + .toList() + .run { if (size == 0) null else this } + ?.run { + val template = if (size == 1) player.toTMLocale().notifyUnreadUpdateSingle + else player.toTMLocale().notifyUnreadUpdateMulti + val tickets = this.joinToString(", ") + + template.replace("%num%", tickets) + .run(::addColour) + .run(player::sendMessage) + } + } + } + + // View Open-Count and Assigned-Count Tickets + launch { + if (player.has("ticketmanager.notify.openTickets.onJoin")) { + val open = configState.database.getOpenIDPriorityPairs() + val assigned = configState.database.getAssignedOpenIDPriorityPairs(player.name, mainPlugin.perms.getPlayerGroups(player).toList()) + + player.toTMLocale().notifyOpenAssigned + .replace("%open%", "${open.count()}") + .replace("%assigned%", "${assigned.count()}") + .run(::addColour) + .run(player::sendMessage) + } + } + } + } +} \ No newline at end of file diff --git a/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/events/TabComplete.kt b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/events/TabComplete.kt new file mode 100644 index 0000000..6c4ee36 --- /dev/null +++ b/Spigot/src/main/kotlin/com/github/hoshikurama/ticketmanager/spigot/events/TabComplete.kt @@ -0,0 +1,314 @@ +package com.github.hoshikurama.ticketmanager.spigot.events + +import com.github.hoshikurama.ticketmanager.common.TMLocale +import com.github.hoshikurama.ticketmanager.common.databases.Database +import com.github.hoshikurama.ticketmanager.spigot.has +import com.github.hoshikurama.ticketmanager.spigot.mainPlugin +import com.github.hoshikurama.ticketmanager.spigot.toTMLocale +import org.bukkit.Bukkit +import org.bukkit.World +import org.bukkit.command.Command +import org.bukkit.command.CommandSender +import org.bukkit.command.TabCompleter +import org.bukkit.entity.Player + +class TabComplete: TabCompleter { + + override fun onTabComplete( + sender: CommandSender, + command: Command, + alias: String, + args: Array + ): MutableList { + return tabCompleteFunction(sender, args.toList()).toMutableList() + } + + private fun tabCompleteFunction( + sender: CommandSender, + args: List + ): List { + val blankList = listOf("") + + if (!sender.has("ticketmanager.commandArg.autotab") && sender is Player) return blankList + val locale = sender.toTMLocale() + val perms = LazyPermissions(locale, sender) + + return locale.run { + if (args.size <= 1) return@run perms.getPermissiveCommands() + .filter { it.startsWith(args[0]) } + + when (args[0]) { + commandWordAssign, commandWordSilentAssign -> when { // /ticket assign + !perms.hasAssignVariation -> listOf("") + args.size == 2 -> listOf("<$parameterID>") + .filter { it.startsWith(args[1]) } + args.size == 3 -> { + val groups = mainPlugin.perms.groups.map { "::$it" } + (listOf("<$parameterAssignment...>") + offlinePlayerNames() + groups + listOf(locale.consoleName)) + .filter { it.startsWith(args[2]) } + } + else -> listOf("") + } + + commandWordClaim, commandWordSilentClaim, commandWordUnassign, commandWordSilentUnassign -> when { // /ticket claim + !perms.hasAssignVariation -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordClose, commandWordSilentClose -> when { // /ticket close [Comment...] + !perms.hasClose -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> (listOf("[$parameterComment...]") + onlineSeenPlayers(sender)) + .filter { it.startsWith(args[args.lastIndex]) } + } + + commandWordCloseAll, commandWordSilentCloseAll -> when { // /ticket closeall + !perms.hasMassClose -> listOf("") + args.size == 2 -> listOf("<$parameterLowerID>").filter { it.startsWith(args[1]) } + args.size == 3 -> listOf("<$parameterUpperID>").filter { it.startsWith(args[2]) } + else -> listOf("") + } + + commandWordComment, commandWordSilentComment -> when { // /ticket comment + !perms.hasComment -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> (listOf("<$parameterComment...>") + onlineSeenPlayers(sender)) + .filter { it.startsWith(args[args.lastIndex]) } + } + + commandWordCreate -> when { // /ticket create + !perms.hasCreate -> listOf("") + else -> (listOf("<$parameterComment...>") + onlineSeenPlayers(sender)) + .filter { it.startsWith(args[args.lastIndex]) } + } + + commandWordHelp -> listOf("") + + commandWordHistory -> when { // /ticket history [User] [Page] + !perms.hasHistory -> listOf("") + args.size == 2 -> (listOf("[$parameterUser]", locale.consoleName) + offlinePlayerNames()) + .filter { it.startsWith(args[1]) } + args.size == 3 -> listOf("[$parameterPage]").filter { it.startsWith(args[2]) } + else -> listOf("") + } + + commandWordList, commandWordListAssigned -> when { // /ticket list(assigned) [Page] + !perms.hasListVariation -> listOf("") + args.size == 2 -> listOf("[$parameterPage]").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordReopen, commandWordSilentReopen -> when { // /ticket reopen + !perms.hasReopen -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordSetPriority, commandWordSilentSetPriority -> when { // /ticket setpriority + !perms.hasPriority -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + args.size == 3 -> listOf("<$parameterLevel>", "1", "2", "3", "4", "5").filter { + it.startsWith( + args[2] + ) + } + else -> listOf("") + } + + commandWordTeleport -> when { // /ticket teleport + !perms.hasTeleport -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordVersion -> listOf("") + + commandWordView -> when { // /ticket view + !perms.hasView -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordDeepView -> when { + !perms.hasDeepView -> listOf("") + args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } + else -> listOf("") + } + + commandWordReload -> listOf("") + + commandWordSearch -> { //ticket search keywords:separated,by,commas status:OPEN/CLOSED time:5w creator:creator priority:value assignedto:player world:world + if (!perms.hasSearch) return@run listOf("") + + val curArgument = args[args.lastIndex] + val splitArgs = curArgument.split(":", limit = 2) + + if (splitArgs.size < 2) + return@run locale.run { + listOf( + "$searchAssigned:", + "$searchCreator:", + "$searchKeywords:", + "$searchPriority:", + "$searchStatus:", + "$searchWorld:", + "$searchClosedBy:", + "$searchLastClosedBy:", + "$searchTime:", + ) + } + .filter { it.startsWith(curArgument) } + + // String now has form "constraint:" + return@run when (splitArgs[0]) { + searchAssigned -> { + val groups = mainPlugin.perms.groups.map { "::$it" } + (offlinePlayerNames() + groups) + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + searchCreator, searchLastClosedBy, searchClosedBy -> { + (offlinePlayerNames() + listOf(locale.consoleName)) + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + searchPriority -> { + listOf("1", "2", "3", "4", "5") + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + locale.searchStatus -> { + listOf(locale.statusOpen, locale.statusClosed) + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + searchWorld -> { + Bukkit.getWorlds() + .map(World::getName) + .filter { it.startsWith(splitArgs[1]) } + .map { "${splitArgs[0]}:$it" } + } + + searchTime -> { + locale.run { + listOf( + searchTimeSecond, + searchTimeMinute, + searchTimeHour, + searchTimeDay, + searchTimeWeek, + searchTimeYear + ) + } + .filter { curArgument.last().digitToIntOrNull() != null } + .map { "${splitArgs[0]}:${splitArgs[1]}$it" } + } + + searchKeywords -> listOf(curArgument) + + else -> listOf("") + } + } + + commandWordConvertDB -> when { // /ticket convertDatabase + !perms.hasConvertDB -> listOf("") + args.size == 2 -> + Database.Type.values() + .map(Database.Type::name) + .filter { it.startsWith(args[1]) } + else -> listOf("") + } + + else -> listOf("") + } + } + } + + private fun onlineSeenPlayers(sender: CommandSender): List { + return if (sender is Player) + Bukkit.getOnlinePlayers() + .filter(sender::canSee) + .map { it.name } + else Bukkit.getOnlinePlayers() + .map { it.name } + } + + private fun offlinePlayerNames() = Bukkit.getOfflinePlayers().mapNotNull { it.name }.toList() + + + class LazyPermissions(private val locale: TMLocale, private val sender: CommandSender) { + val hasAssignVariation by lazy { sender.has("ticketmanager.command.assign") } + val hasCreate by lazy { sender.has("ticketmanager.command.create") } + val hasListVariation by lazy { sender.has("ticketmanager.command.list") } + val hasReopen by lazy { sender.has("ticketmanager.command.reopen") } + val hasSearch by lazy { sender.has("ticketmanager.command.search") } + val hasPriority by lazy { sender.has("ticketmanager.command.setPriority") } + val hasTeleport by lazy { sender.has("ticketmanager.command.teleport") } + val hasMassClose by lazy { sender.has("ticketmanager.command.closeAll") } + val hasConvertDB by lazy { sender.has("ticketmanager.command.convertDatabase") } + private val hasHelp by lazy { sender.has("ticketmanager.command.help") } + private val hasReload by lazy { sender.has("ticketmanager.command.reload") } + private val hasSilent by lazy { sender.has("ticketmanager.commandArg.silence") } + val hasClose by lazy { + sender.has("ticketmanager.command.close.all") + || sender.has("ticketmanager.command.close.own") + } + val hasComment by lazy { + sender.has("ticketmanager.command.comment.all") + || sender.has("ticketmanager.command.comment.own") + } + val hasView by lazy { + sender.has("ticketmanager.command.view.all") + || sender.has("ticketmanager.command.view.own") + } + val hasDeepView by lazy { + sender.has("ticketmanager.command.viewdeep.all") + || sender.has("ticketmanager.command.viewdeep.own") + } + val hasHistory by lazy { + sender.has("ticketmanager.command.history.all") + || sender.has("ticketmanager.command.history.own") + } + + + fun getPermissiveCommands(): List { + return mapOf( + locale.commandWordAssign to hasAssignVariation, + locale.commandWordSilentAssign to (hasAssignVariation && hasSilent), + locale.commandWordClaim to hasAssignVariation, + locale.commandWordSilentClaim to (hasAssignVariation && hasSilent), + locale.commandWordClose to hasClose, + locale.commandWordSilentClose to (hasClose && hasSilent), + locale.commandWordCloseAll to hasMassClose, + locale.commandWordSilentCloseAll to (hasMassClose && hasSilent), + locale.commandWordComment to hasComment, + locale.commandWordSilentComment to (hasComment && hasSilent), + locale.commandWordConvertDB to hasConvertDB, + locale.commandWordCreate to hasCreate, + locale.commandWordHelp to hasHelp, + locale.commandWordHistory to hasHistory, + locale.commandWordList to hasListVariation, + locale.commandWordListAssigned to hasListVariation, + locale.commandWordReload to hasReload, + locale.commandWordReopen to hasReopen, + locale.commandWordSilentReopen to (hasReopen && hasSilent), + locale.commandWordSearch to hasSearch, + locale.commandWordSetPriority to hasPriority, + locale.commandWordSilentSetPriority to (hasPriority && hasSilent), + locale.commandWordTeleport to hasTeleport, + locale.commandWordUnassign to hasAssignVariation, + locale.commandWordSilentUnassign to (hasAssignVariation && hasSilent), + locale.commandWordVersion to true, + locale.commandWordView to hasView, + locale.commandWordDeepView to hasDeepView + ) + .filter { it.value } + .map { it.key } + } + } +} \ No newline at end of file diff --git a/Spigot/src/main/resources/config.yml b/Spigot/src/main/resources/config.yml new file mode 100644 index 0000000..5e21e94 --- /dev/null +++ b/Spigot/src/main/resources/config.yml @@ -0,0 +1,119 @@ +############################################## +############################################## +# TicketManager Config File +############################################## +############################################## +# +# Plugin Proudly Made by: HoshiKurama +# +# All permissions and commands can +# be found on the GitHub wiki page. +# https://github.com/HoshiKurama/TicketManager +# +# ########################################## +# Locale: +# ########################################## +# Force Locale: Forces specific language to be used for commands and responses. +# Values: true, false +Force_Locale: false +# +# Preferred Locale: Locale to use when player locale is not found. Using a non-supported +# locale will cause TicketManager to internally default to Canadian English. +# Values: +# 'en_CA' = Canadian English +# 'en_US' = United States English +Preferred_Locale: 'en_CA' +# +# Console Locale: Locale Console will use. Using a non-supported locale will cause +# TicketManager to internally default to Preferred_Locale. +# Values: +# 'en_CA' = Canadian English +# 'en_US' = United States English +Console_Locale: 'en_CA' +# +# ########################################## +# Cooldown: +# ########################################## +# Use Cool-down: Determine if cool-downs should be applied to ticket commands +# that create or modify a ticket. Cool-downs apply to ALL users without the +# override permission. +# Values: true, false +Use_Cooldowns: false +# +# Ticket Modification Cool-down: Time in seconds before a user is able to create or +# modify a ticket. This applies to ALL users without the override permission if +# cool-downs are enabled. +Cooldown_Time: 0 +# +# ########################################## +# Database Settings: +# ########################################## +# +# Database mode: Type of database used. Internally defaults +# to SQLite if invalid value is used. Information only needs +# to be filled out for the desired database type. +# +# SQLite: +# Stores information in the SQLite file in the TicketManager folder. +# Pros: + No setup. +# + No external database needed. +# + Faster than MySQL for search and history command. +# Cons: - Stores data in TicketManager folder. +# - Easier to overwhelm than Memory or MySQL. +# +# MySQL: +# Stores information in a database that may or may not be on the server. +# Pros: + Can handle more traffic than SQLite. +# + Can be separate from server storage. +# Cons: - Must have MySQL database. +# - Search and history commands slower. +# +# Memory: +# Memory refers to TicketManager's custom solution for running the entire +# database in RAM. Data is backed up throughout normal server operations or +# on server shutdowns. It is then loaded up on startup if selected. DO NOT +# USE THIS DATABASE TYPE UNLESS YOU UNDERSTAND THE RISKS OF RUNNING A +# DATABASE IN RAM!!! +# Pros: + Incredibly responsive +# + Incredibly fast queries +# Cons: - Any power loss or server crash will result in all data beyond +# the last backup being lost. +# +# Values: 'MySQL','SQLite','Memory' +Database_Mode: 'SQLite' +# +# ###################### +# MySQL Database +# ###################### +MySQL_Port: '' +MySQL_Host: '' +MySQL_DBName: '' +MySQL_Username: '' +MySQL_Password: '' +# +# ###################### +# Memory as Database +# ###################### +# Time in seconds between database backups being made +Memory_Backup_Frequency: 600 +# +# ########################################### +# Other +# ########################################### +# Colour Code: Colour code used to add simple colour customization. +# Must be in the form "&". eg: &3, &6, &b, etc +# DO NOT USE ANY COLOUR CODES OTHER THAN COLOUR! +Colour_Code: '&3' +# +# Allow Unread Ticket Updates: When users other than the creator +# make ticket changes, the creator can optionally be alerted at +# regular intervals or on login which tickets have a status change. +# This is NOT the same alert one immediately receives on ticket +# status change. Setting value to 'false' prevents TicketManager +# from flagging a ticket as unread. +# Values: true, false +Allow_Unread_Ticket_Updates: true +# +# Allow Update Checking: Server can check that the latest version of TicketManager is installed during startup or reloads. +# Values: true, false +Allow_UpdateChecking: true \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/Spigot/src/main/resources/plugin.yml similarity index 95% rename from src/main/resources/plugin.yml rename to Spigot/src/main/resources/plugin.yml index 89bf9ec..ebc42bc 100644 --- a/src/main/resources/plugin.yml +++ b/Spigot/src/main/resources/plugin.yml @@ -1,16 +1,20 @@ name: TicketManager -version: 4.1.0 -main: com.hoshikurama.github.ticketmanager.TicketManagerPlugin +version: 5.0.0 +main: com.github.hoshikurama.ticketmanager.spigot.TicketManagerPlugin api-version: 1.17 authors: [HoshiKurama] depend: [Vault] libraries: - - com.zaxxer:HikariCP:4.0.3 + - org.jetbrains.kotlin:kotlin-stdlib:1.5.21 - mysql:mysql-connector-java:8.0.25 - org.xerial:sqlite-jdbc:3.34.0 - - org.jetbrains.kotlin:kotlin-stdlib:1.5.10 + - com.github.jasync-sql:jasync-mysql:1.2.2 - com.github.seratch:kotliquery:1.3.1 + - org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.1 - joda-time:joda-time:2.10.10 + - com.github.shynixn.mccoroutine:mccoroutine-bukkit-api:1.5.0 + - com.github.shynixn.mccoroutine:mccoroutine-bukkit-core:1.5.0 + - org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.2.2 commands: ticket: description: Base for all TicketManager commands diff --git a/build.gradle.kts b/build.gradle.kts index a86a2e4..aa8ae7c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,32 +1,28 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") version "1.5.10" - java + kotlin("jvm") version "1.5.21" + java } -group = "com.hoshikurama.github" -version = "4.1.0" - repositories { - mavenCentral() - maven { url = uri("https://papermc.io/repo/repository/maven-public/") } - maven { url = uri("https://oss.sonatype.org/content/groups/public/") } - maven { url = uri("https://jitpack.io") } - maven { url = uri("https://oss.sonatype.org/content/repositories/central") } + mavenCentral() } dependencies { - compileOnly("io.papermc.paper:paper-api:1.17-R0.1-SNAPSHOT") - compileOnly("com.github.MilkBowl:VaultAPI:1.7") - implementation("joda-time:joda-time:2.10.10") - implementation(kotlin("stdlib", version = "1.5.10")) - implementation("com.github.seratch:kotliquery:1.3.1") - implementation("com.zaxxer:HikariCP:4.0.3") - implementation("mysql:mysql-connector-java:8.0.25") - implementation("org.xerial:sqlite-jdbc:3.34.0") + implementation(kotlin("stdlib", version = "1.5.21")) } -tasks.withType { - kotlinOptions.jvmTarget = "16" +subprojects { + group = "com.hoshikurama.github" + version = "5.0.0" + + tasks.withType { + kotlinOptions.jvmTarget = "16" + } + + tasks.withType().configureEach { + kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" + kotlinOptions.freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental" + } } \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 0000000..3634181 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("plugin.serialization") version "1.5.20" + kotlin("jvm") + java +} + +repositories { + mavenCentral() + maven { url = uri("https://jitpack.io") } +} + +dependencies { + implementation(kotlin("stdlib", version = "1.5.21")) + implementation("mysql:mysql-connector-java:8.0.25") + implementation("org.xerial:sqlite-jdbc:3.34.0") + implementation("com.github.jasync-sql:jasync-mysql:1.2.2") + implementation("com.github.seratch:kotliquery:1.3.1") + implementation("com.github.HoshiKurama:KyoriComponentDSL:1.1.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.1") + implementation("net.kyori:adventure-api:4.8.1") + implementation("net.kyori:adventure-extra-kotlin:4.8.1") + implementation("net.kyori:adventure-text-serializer-legacy:4.8.1") + implementation("org.yaml:snakeyaml:1.29") + implementation("joda-time:joda-time:2.10.10") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2") +} diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ConfigState.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ConfigState.kt new file mode 100644 index 0000000..b11c074 --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ConfigState.kt @@ -0,0 +1,125 @@ +package com.github.hoshikurama.ticketmanager.common + +import com.github.hoshikurama.ticketmanager.common.databases.Database +import com.github.hoshikurama.ticketmanager.common.databases.SQLite +import kotlinx.coroutines.* +import java.time.Instant +import java.util.* +import kotlin.coroutines.CoroutineContext + +class ConfigState( + val cooldowns: Cooldown, + val database: Database, + val localeHandler: LocaleHandler, + val allowUnreadTicketUpdates: Boolean, + val pluginUpdateAvailable: Deferred?> +) { + + companion object { + suspend inline fun createPluginState( + crossinline database: () -> Database?, + crossinline cooldown: () -> Cooldown?, + crossinline localeHandler: suspend () -> LocaleHandler?, + crossinline allowUnreadTicketUpdates: () -> Boolean?, + crossinline checkForPluginUpdate: () -> Boolean?, + crossinline pluginVersion: () -> String, + absolutePathToPluginFolder: String, + context: CoroutineContext + ) = withContext(context) { + + val deferredCooldown = async { tryOrDefault(cooldown, Cooldown(false, 0)) } + val deferredAllowUnreadUpdates = async { tryOrDefault(allowUnreadTicketUpdates, true) + } + val deferredDatabase = async { + tryOrDefault( + attempted = { database()?.apply { initialiseDatabase() } }, + default = SQLite(absolutePathToPluginFolder) + ) + } + + val deferredPluginUpdate = async { + val shouldCheck = checkForPluginUpdate() + if (shouldCheck == true) { + val curVersion = pluginVersion() + val latestVersion = UpdateChecker(91178).getLatestVersion() + .run { this ?: curVersion } + + val curVersSplit = curVersion.split(".").map(String::toInt) + val latestVersSplit = latestVersion.split(".").map(String::toInt) + + for (i in 0..latestVersSplit.lastIndex) { + if (curVersSplit[i] > latestVersSplit[i]) + return@async null + } + return@async Pair(curVersion, latestVersion) + } + return@async null + } + + val deferredLocaleHandler = async { + tryOrDefaultSuspend(localeHandler, + LocaleHandler.buildLocalesAsync( + mainColourCode = "&3", + preferredLocale = "en_ca", + console_Locale = "en_ca", + forceLocale = false, + context = context + ) + ) + } + + ConfigState( + cooldowns = deferredCooldown.await(), + database = deferredDatabase.await(), + localeHandler = deferredLocaleHandler.await(), + allowUnreadTicketUpdates = deferredAllowUnreadUpdates.await(), + pluginUpdateAvailable = deferredPluginUpdate + ) + } + } + + + class Cooldown( + private val enabled: Boolean, + private val duration: Long, + ) { + @OptIn(ObsoleteCoroutinesApi::class) + private val threadContext = newSingleThreadContext("Cooldown") //This WILL be replaced in the future + private val map = mutableMapOf() + + suspend fun checkAndSetAsync(uuid: UUID?): Boolean { + return withContext(threadContext) { + if (!enabled || uuid == null) return@withContext false + + val curTime = Instant.now().epochSecond + val applies = map[uuid]?.let { it <= curTime } ?: false + return@withContext if (applies) true + else map.put(uuid, duration + curTime).run { true } + } + } + + suspend fun filterMapAsync() { + withContext(threadContext) { + map.forEach { + if (it.value > Instant.now().epochSecond) + map.remove(it.key) + } + } + } + } +} + +inline fun tryOrDefault(attempted: () -> T?, default: T): T = + tryOrNull(attempted).run { this ?: default } + +inline fun tryOrNull(function: () -> T): T? = + try { function() } + catch (e: Exception) { e.printStackTrace(); null } + +suspend inline fun tryOrDefaultSuspend(crossinline attempted: suspend () -> T?, default: T): T = + tryOrNullSuspend(attempted).run { this ?: default } + +suspend inline fun tryOrNullSuspend(crossinline function: suspend () -> T): T? = + try { function() } + catch (ignored: Exception) { null } + diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/Localization.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/Localization.kt similarity index 90% rename from src/main/kotlin/com/hoshikurama/github/ticketmanager/Localization.kt rename to common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/Localization.kt index c056acc..1e4e316 100644 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/Localization.kt +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/Localization.kt @@ -1,37 +1,54 @@ -package com.hoshikurama.github.ticketmanager +package com.github.hoshikurama.ticketmanager.common -import net.md_5.bungee.api.ChatColor +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext import org.yaml.snakeyaml.Yaml +import kotlin.coroutines.CoroutineContext const val translationNotFound = " TNF " -class AllLocales(val colourCode: String, - preferredLocale: String, - consoleLocale1: String, - forceLocale: Boolean +class LocaleHandler( + val mainColourCode: String, + private val activeTypes: Map, + private val defaultType: TMLocale, + val consoleLocale: TMLocale, ) { - private val activeTypes: Map - private val defaultType: TMLocale - val consoleLocale: TMLocale - init { - val fallback = EnglishCanada(colourCode) - val supportedTypes = mapOf( - "en_ca" to fallback, - "en_us" to EnglishUS(colourCode), - "en_uk" to EnglishUK(colourCode) - ) + companion object { + suspend fun buildLocalesAsync( + mainColourCode: String, + preferredLocale: String, + console_Locale: String, + forceLocale: Boolean, + context: CoroutineContext + ): LocaleHandler = withContext(context) { + val fallback = async { TMLocale(mainColourCode, "en_CA") } + + val activeTypes = + if (forceLocale) mapOf() + else mapOf( + "en_ca" to fallback, + "en_us" to async { TMLocale(mainColourCode, "en_CA") }, + "en_uk" to async { TMLocale(mainColourCode, "en_CA") } + ) + .mapValues { it.value.await() } - defaultType = supportedTypes.getOrDefault(preferredLocale.lowercase(), fallback) - consoleLocale = supportedTypes.getOrDefault(consoleLocale1.lowercase(), defaultType) - activeTypes = if (forceLocale) mapOf() else supportedTypes + val defaultType = activeTypes.getOrDefault(preferredLocale.lowercase(), fallback.await()) + val consoleLocale = activeTypes.getOrDefault(console_Locale.lowercase(), defaultType) + + LocaleHandler(mainColourCode, activeTypes, defaultType, consoleLocale) + } } fun getOrDefault(type: String) = activeTypes.getOrDefault(type.lowercase(), defaultType) fun getCommandBases() = if (activeTypes.isEmpty()) setOf(defaultType.commandBase) else activeTypes.map { it.value.commandBase }.toSet() } -sealed class TMLocale(colourCode: String, locale: String) { + +class TMLocale( + colourCode: String, + locale: String, +) { // View and Deep View Format val viewFormatHeader: String val viewFormatSep1: String @@ -86,6 +103,7 @@ sealed class TMLocale(colourCode: String, locale: String) { val warningsInvalidDBType: String val warningsConvertToSameDBType: String val warningsUnexpectedError: String + val warningsLongTaskDuringReload: String // Command Types val commandBase: String @@ -205,6 +223,7 @@ sealed class TMLocale(colourCode: String, locale: String) { val informationDBUpdateComplete: String val informationDBConvertInit: String val informationDBConvertSuccess: String + val informationReloadFailure: String // Modified Stacktrace val stacktraceLine1: String @@ -225,12 +244,11 @@ sealed class TMLocale(colourCode: String, locale: String) { val helpSep: String init { - val inputStream = mainPlugin.getResource("languages/Locales/$locale.yml") + val inputStream = this::class.java.classLoader.getResourceAsStream("locales/$locale.yml") val contents: Map = Yaml().load(inputStream) fun matchOrDefault(key: String) = contents[key] ?.replace("%CC%", colourCode) ?.replace("%nl%", "\n") - ?.let { ChatColor.translateAlternateColorCodes('&', it) } ?: translationNotFound viewFormatHeader = matchOrDefault("ViewFormat_Header") @@ -385,9 +403,7 @@ sealed class TMLocale(colourCode: String, locale: String) { searchClosedBy = matchOrDefault("Search_ClosedBy") searchLastClosedBy = matchOrDefault("Search_LastClosedBy") notifyPluginUpdate = matchOrDefault("Notify_Event_PluginUpdate") + warningsLongTaskDuringReload = matchOrDefault("Warning_LongTaskDuringReload") + informationReloadFailure = matchOrDefault("Info_ReloadFailure") } -} - -class EnglishCanada(colourCode: String) : TMLocale(colourCode, "en_CA") -class EnglishUS(colourCode: String) : TMLocale(colourCode, "en_CA") // No words differ from en_CA -class EnglishUK(colourCode: String) : TMLocale(colourCode, "en_CA") // No words differ from en_CA \ No newline at end of file +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/Miscellaneous.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/Miscellaneous.kt new file mode 100644 index 0000000..32aa55f --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/Miscellaneous.kt @@ -0,0 +1,146 @@ +package com.github.hoshikurama.ticketmanager.common + +import com.github.hoshikurama.ticketmanager.common.ticket.BasicTicket +import com.github.hoshikurama.ticketmanager.common.ticket.FullTicket +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.InputStream +import java.net.URL +import java.time.Instant +import java.util.* + +class UpdateChecker(private val resourceID: Int) { + fun getLatestVersion(): String? { + var inputStream: InputStream? = null + var scanner: Scanner? = null + + return try { + inputStream = URL("https://api.spigotmc.org/legacy/update.php?resource=$resourceID").openStream() + scanner = Scanner(inputStream!!) + if (scanner.hasNext()) scanner.next() else null + } + catch (ignored: Exception) { null } + finally { + inputStream?.close() + scanner?.close() + } + } +} + + +fun byteToPriority(byte: Byte) = when (byte.toInt()) { + 1 -> BasicTicket.Priority.LOWEST + 2 -> BasicTicket.Priority.LOW + 3 -> BasicTicket.Priority.NORMAL + 4 -> BasicTicket.Priority.HIGH + 5 -> BasicTicket.Priority.HIGHEST + else -> BasicTicket.Priority.NORMAL +} + +fun Long.toLargestRelativeTime(locale: TMLocale): String { + val timeAgo = Instant.now().epochSecond - this + + return when { + timeAgo >= 31556952L -> (timeAgo / 31556952L).toString() + locale.timeYears + timeAgo >= 604800L ->(timeAgo / 604800L).toString() + locale.timeWeeks + timeAgo >= 86400L ->(timeAgo / 86400L).toString() + locale.timeDays + timeAgo >= 3600L ->(timeAgo / 3600L).toString() + locale.timeHours + timeAgo >= 60L ->(timeAgo / 60L).toString() + locale.timeMinutes + else -> timeAgo.toString() + locale.timeSeconds + } +} + +fun relTimeToEpochSecond(relTime: String, locale: TMLocale): Long { + var seconds = 0L + var index = 0 + val unprocessed = StringBuilder(relTime) + + while (unprocessed.isNotEmpty() && index != unprocessed.lastIndex + 1) { + unprocessed[index].toString().toByteOrNull() + // If number... + ?.apply { index++ } + // If not a number... + ?: run { + val number = if (index == 0) 0 + else unprocessed.substring(0, index).toLong() + + seconds += number * when (unprocessed[index].toString()) { + locale.searchTimeSecond -> 1L + locale.searchTimeMinute -> 60L + locale.searchTimeHour -> 3600L + locale.searchTimeDay -> 86400L + locale.searchTimeWeek -> 604800L + locale.searchTimeYear -> 31556952L + else -> 0L + } + + unprocessed.delete(0, index+1) + index = 0 + } + } + + return Instant.now().epochSecond - seconds +} + +val sortForList: Comparator = Comparator.comparing(BasicTicket::priority).reversed().thenComparing(Comparator.comparing(BasicTicket::id).reversed()) + +val sortActions: Comparator = Comparator.comparing(FullTicket.Action::timestamp) + +fun T.notEquals(t: T) = this != t + +class MutexControlled(private var t: T) { + private val mutex = Mutex() + + suspend fun get(): T = mutex.withLock { t } + suspend fun set(t: T) = mutex.withLock { this.t = t } +} + +class IncrementalMutexController(private var n: Int) { + private val mutex = Mutex() + suspend fun getAndIncrement() = mutex.withLock { n.also { n++ } } +} + + +// Code from https://github.com/CruGlobal/android-gto-support/blob/47b44477e94e7d913de15066e3dd3eb8b54c4828/gto-support-kotlin-coroutines/src/main/java/org/ccci/gto/android/common/kotlin/coroutines/ReadWriteMutex.kt +interface ReadWriteMutex { + val write: Mutex + val read: Mutex +} + +fun ReadWriteMutex(): ReadWriteMutex = ReadWriteMutexImpl() + +internal class ReadWriteMutexImpl : ReadWriteMutex { + private val stateMutex = Mutex() + private val readerOwner = Any() + internal var readers = 0L + + override val write = Mutex() + override val read = object : Mutex { + override suspend fun lock(owner: Any?) { + stateMutex.withLock { + check(readers < Long.MAX_VALUE) { + "Attempt to lock the read mutex more than ${Long.MAX_VALUE} times concurrently" + } + // first reader should lock the write mutex + if (readers == 0L) write.lock(readerOwner) + readers++ + } + } + + override fun unlock(owner: Any?) { + runBlocking { + stateMutex.withLock { + check(readers > 0L) { "Attempt to unlock the read mutex when it wasn't locked" } + // release the write mutex lock when this is the last reader + if (--readers == 0L) write.unlock(readerOwner) + } + } + } + + override val isLocked get() = TODO("Not supported") + override val onLock get() = TODO("Not supported") + override fun holdsLock(owner: Any) = TODO("Not supported") + override fun tryLock(owner: Any?) = TODO("Not supported") + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/PluginState.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/PluginState.kt new file mode 100644 index 0000000..b3630dc --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/PluginState.kt @@ -0,0 +1,7 @@ +package com.github.hoshikurama.ticketmanager.common + +class PluginState { + val jobCount = MutexControlled(0) + val pluginLocked = MutexControlled(true) + val ticketCountMetrics = MutexControlled(0) +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/Database.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/Database.kt new file mode 100644 index 0000000..de9951a --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/Database.kt @@ -0,0 +1,73 @@ +package com.github.hoshikurama.ticketmanager.common.databases + +import com.github.hoshikurama.ticketmanager.common.TMLocale +import com.github.hoshikurama.ticketmanager.common.ticket.BasicTicket +import com.github.hoshikurama.ticketmanager.common.ticket.FullTicket +import kotlinx.coroutines.flow.Flow +import java.util.* +import kotlin.coroutines.CoroutineContext + +interface Database { + val type: Type + + enum class Type { + MySQL, SQLite, Memory + } + + // Individual property getters + suspend fun getActionsAsFlow(ticketID: Int): Flow + + // Individual property setters + suspend fun setAssignment(ticketID: Int, assignment: String?) + suspend fun setCreatorStatusUpdate(ticketID: Int, status: Boolean) + suspend fun setPriority(ticketID: Int, priority: BasicTicket.Priority) + suspend fun setStatus(ticketID: Int, status: BasicTicket.Status) + + // Get Specific Ticket Type + suspend fun getBasicTicket(ticketID: Int): BasicTicket? + + // Database additions + suspend fun addAction(ticketID: Int, action: FullTicket.Action) + suspend fun addFullTicket(fullTicket: FullTicket) + suspend fun addNewTicket(basicTicket: BasicTicket, context: CoroutineContext, message: String): Int + + // Database removals + suspend fun massCloseTickets(lowerBound: Int, upperBound: Int, uuid: UUID?, context: CoroutineContext) + + // Collections of tickets + suspend fun getOpenIDPriorityPairs(): Flow> + suspend fun getAssignedOpenIDPriorityPairs(assignment: String, unfixedGroupAssignment: List): Flow> + suspend fun getIDsWithUpdates(): Flow + suspend fun getIDsWithUpdatesFor(uuid: UUID): Flow + suspend fun getBasicTickets(ids: List): Flow + suspend fun getFullTicketsFromBasics(basicTickets: List, context: CoroutineContext): Flow + suspend fun getFullTickets(ids: List, context: CoroutineContext): Flow + + // Database searching + suspend fun searchDatabase( + context: CoroutineContext, + locale: TMLocale, + mainTableConstraints: List>, + searchFunction: (FullTicket) -> Boolean + ): Flow + + // Internal Database Functions + suspend fun closeDatabase() + suspend fun initialiseDatabase() + suspend fun updateNeeded(): Boolean + suspend fun migrateDatabase( + context: CoroutineContext, + to: Type, + mySQLBuilder: suspend () -> MySQL, + sqLiteBuilder: suspend () -> SQLite, + memoryBuilder: suspend () -> Memory, + onBegin: suspend () -> Unit, + onComplete: suspend () -> Unit, + ) + suspend fun updateDatabase( + context: CoroutineContext, + onBegin: suspend () -> Unit, + onComplete: suspend () -> Unit, + offlinePlayerNameToUuidOrNull: (String) -> UUID? + ) +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/Memory.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/Memory.kt new file mode 100644 index 0000000..5a0cbd1 --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/Memory.kt @@ -0,0 +1,344 @@ +package com.github.hoshikurama.ticketmanager.common.databases + +import com.github.hoshikurama.ticketmanager.common.* +import com.github.hoshikurama.ticketmanager.common.ticket.BasicTicket +import com.github.hoshikurama.ticketmanager.common.ticket.FullTicket +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.BufferedWriter +import java.nio.charset.Charset +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.util.* +import kotlin.coroutines.CoroutineContext +import kotlin.io.path.createFile +import kotlin.io.path.exists +import kotlin.io.path.notExists + +@OptIn(DelicateCoroutinesApi::class) +class Memory( + private val filePath: String, + backupFrequency: Long +) : Database { + override val type = Database.Type.Memory + + private val mapMutex = ReadWriteMutex() + private val ticketMap: MutableMap + private val fileIOOccurring = MutexControlled(false) + + private val nextTicketID: IncrementalMutexController + private val backupJob: Job + + init { + val path = Path.of("$filePath/TicketManager-Database4-Memory.ticketmanager") + + if (path.exists()) { + val encodedMap = Files.readString(path) + ticketMap = Json.decodeFromString(encodedMap) + + val highestID = ticketMap.maxByOrNull { it.key }?.key ?: 0 + nextTicketID = IncrementalMutexController(highestID + 1) + } else { + ticketMap = mutableMapOf() + nextTicketID = IncrementalMutexController(1) + } + + // Launches backup system for duration of database + backupJob = GlobalScope.launch(Dispatchers.IO) { + while (true) { + delay(1000L * backupFrequency) + + if (!fileIOOccurring.get()) { + fileIOOccurring.set(true) + writeDatabaseToFileBlocking() + fileIOOccurring.set(false) + } + } + } + } + + override suspend fun getActionsAsFlow(ticketID: Int): Flow { + return ticketMap[ticketID]!!.actions.asFlow() + } + + override suspend fun setAssignment(ticketID: Int, assignment: String?) { + mapMutex.write.withLock { + val t = ticketMap[ticketID]!! + ticketMap[ticketID] = FullTicket(t.id, t.creatorUUID, t.location, t.priority, t.status, assignment, t.creatorStatusUpdate, t.actions) + } + } + + override suspend fun setCreatorStatusUpdate(ticketID: Int, status: Boolean) { + mapMutex.write.withLock { + val t = ticketMap[ticketID]!! + ticketMap[ticketID] = FullTicket(t.id, t.creatorUUID, t.location, t.priority, t.status, t.assignedTo, status, t.actions) + } + } + + override suspend fun setPriority(ticketID: Int, priority: BasicTicket.Priority) { + mapMutex.write.withLock { + val t = ticketMap[ticketID]!! + ticketMap[ticketID] = FullTicket(t.id, t.creatorUUID, t.location, priority, t.status, t.assignedTo, t.creatorStatusUpdate, t.actions) + } + } + + override suspend fun setStatus(ticketID: Int, status: BasicTicket.Status) { + mapMutex.write.withLock { + val t = ticketMap[ticketID]!! + ticketMap[ticketID] = FullTicket(t.id, t.creatorUUID, t.location, t.priority, status, t.assignedTo, t.creatorStatusUpdate, t.actions) + } + } + + override suspend fun getBasicTicket(ticketID: Int): BasicTicket? { + return mapMutex.read.withLock { ticketMap[ticketID] } + } + + override suspend fun addAction(ticketID: Int, action: FullTicket.Action) { + mapMutex.write.withLock { + val t = ticketMap[ticketID]!! + ticketMap[ticketID] = FullTicket(t.id, t.creatorUUID, t.location, t.priority, t.status, t.assignedTo, t.creatorStatusUpdate, t.actions + action) + } + } + + override suspend fun addFullTicket(fullTicket: FullTicket) { + val newID = nextTicketID.getAndIncrement() + val newTicket = FullTicket(newID, fullTicket.creatorUUID, fullTicket.location, fullTicket.priority, fullTicket.status, fullTicket.assignedTo, fullTicket.creatorStatusUpdate, fullTicket.actions) + + mapMutex.write.withLock { + ticketMap[newID] = newTicket + } + } + + override suspend fun addNewTicket(basicTicket: BasicTicket, context: CoroutineContext, message: String): Int { + val id = nextTicketID.getAndIncrement() + val action = FullTicket.Action(FullTicket.Action.Type.OPEN, basicTicket.creatorUUID, message) + val fullTicket = FullTicket(id, basicTicket.creatorUUID, basicTicket.location, basicTicket.priority, basicTicket.status, basicTicket.assignedTo, basicTicket.creatorStatusUpdate, listOf(action)) + + mapMutex.write.withLock { + ticketMap[id] = fullTicket + } + + return id + } + + override suspend fun massCloseTickets(lowerBound: Int, upperBound: Int, uuid: UUID?, context: CoroutineContext) { + val ticketsToChange = mutableListOf() + + (lowerBound..upperBound).forEach { ticketID -> + mapMutex.read.withLock { + val ticket = ticketMap[ticketID] + + if (ticket != null && ticket.status == BasicTicket.Status.OPEN) { + ticketsToChange += ticket + } + } + } + + val curTime = Instant.now().epochSecond + val newTickets = ticketsToChange.map { + val action = FullTicket.Action(FullTicket.Action.Type.MASS_CLOSE, uuid, timestamp = curTime) + FullTicket(it.id, it.creatorUUID, it.location, it.priority, BasicTicket.Status.CLOSED, it.assignedTo, it.creatorStatusUpdate, it.actions + action) + } + + mapMutex.write.withLock { + newTickets.forEach { + ticketMap[it.id] = it + } + } + } + + override suspend fun getOpenIDPriorityPairs(): Flow> { + val openTickets: Sequence + + mapMutex.read.withLock { + openTickets = ticketMap.asSequence() + .map { it.value } + .filter { it.status == BasicTicket.Status.OPEN } + } + + return openTickets.map { it.id to it.priority.level }.asFlow() + } + + override suspend fun getAssignedOpenIDPriorityPairs( + assignment: String, + unfixedGroupAssignment: List + ): Flow> { + val openTickets: Sequence + val assignments = unfixedGroupAssignment.map { "::$it" } + assignment + + mapMutex.read.withLock { + openTickets = ticketMap.asSequence() + .map { it.value } + .filter { it.status == BasicTicket.Status.OPEN } + .filter { it.assignedTo in assignments } + } + + return openTickets.map { it.id to it.priority.level }.asFlow() + } + + override suspend fun getIDsWithUpdates(): Flow { + return mapMutex.read.withLock { + ticketMap.asSequence() + .map { it.value } + .filter { it.creatorStatusUpdate } + .map { it.id } + }.asFlow() + } + + override suspend fun getIDsWithUpdatesFor(uuid: UUID): Flow { + return mapMutex.read.withLock { + ticketMap.asSequence() + .map { it.value } + .filter { it.creatorUUID?.equals(uuid) == true } + .filter { it.creatorStatusUpdate } + .map { it.id } + }.asFlow() + } + + override suspend fun getBasicTickets(ids: List): Flow { + return mapMutex.read.withLock { + ids.mapNotNull { ticketMap[it] } + }.asFlow() + } + + override suspend fun getFullTicketsFromBasics( + basicTickets: List, + context: CoroutineContext + ): Flow { + val ids = basicTickets.map { it.id } + + return getBasicTickets(ids).map { it as FullTicket }.toList().asFlow() + } + + override suspend fun getFullTickets(ids: List, context: CoroutineContext): Flow { + return mapMutex.read.withLock { + ids.mapNotNull { ticketMap[it] } + }.asFlow() + } + + override suspend fun searchDatabase( + context: CoroutineContext, + locale: TMLocale, + mainTableConstraints: List>, + searchFunction: (FullTicket) -> Boolean + ): Flow { + val mainToFunction = mainTableConstraints.mapNotNull { (word, arg) -> + when (word) { + locale.searchAssigned -> { t: FullTicket -> t.assignedTo == arg } + locale.searchCreator -> { + val uuid = arg?.run(UUID::fromString); + { t: FullTicket -> t.creatorUUID == uuid } + } + locale.searchPriority -> { + val priority = arg!!.toByte().run(::byteToPriority); + { t: FullTicket -> t.priority == priority } + } + locale.searchStatus -> { + val status = BasicTicket.Status.valueOf(arg!!); + { t: FullTicket -> t.status == status } + } + else -> null + } + } + val newSearchFunction = { t: FullTicket -> mainToFunction.all { it(t) } && searchFunction(t) } + + return mapMutex.read.withLock { + ticketMap.asSequence() + .filter { newSearchFunction(it.value) } + .map { it.value } + }.asFlow() + } + + override suspend fun closeDatabase() { + while (fileIOOccurring.get()) delay(100) + + // Cancels GlobalScope backup job + backupJob.cancel() + + fileIOOccurring.set(true) + writeDatabaseToFileBlocking() + fileIOOccurring.set(false) + } + + override suspend fun initialiseDatabase() { + // Done on object instantiation + } + + override suspend fun updateNeeded(): Boolean { + return false // Introduced as V4 db with TM5 + } + + override suspend fun migrateDatabase( + context: CoroutineContext, + to: Database.Type, + mySQLBuilder: suspend () -> MySQL, + sqLiteBuilder: suspend () -> SQLite, + memoryBuilder: suspend () -> Memory, + onBegin: suspend () -> Unit, + onComplete: suspend () -> Unit + ) { + withContext(context) { + launch { onBegin() } + } + when (to) { + Database.Type.Memory -> return + + Database.Type.MySQL, + Database.Type.SQLite -> { + val otherDB = if (to == Database.Type.MySQL) mySQLBuilder() else sqLiteBuilder() + otherDB.initialiseDatabase() + + mapMutex.read.withLock { + ticketMap.map { it.value } + } + .map { + withContext(context) { + launch { otherDB.addFullTicket(it) } + } + } + + otherDB.closeDatabase() + } + } + + withContext(context) { + launch { onComplete() } + } + } + + override suspend fun updateDatabase( + context: CoroutineContext, + onBegin: suspend () -> Unit, + onComplete: suspend () -> Unit, + offlinePlayerNameToUuidOrNull: (String) -> UUID? + ) { + // Not Applicable yet + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun writeDatabaseToFileBlocking() { + val path = Path.of("$filePath/TicketManager-Database4-Memory.ticketmanager") + if (path.notExists()) path.createFile() + + val encodedString: String + mapMutex.read.withLock { + encodedString = Json.encodeToString(ticketMap) + } + + var writer: BufferedWriter? = null + try { + writer = Files.newBufferedWriter(path, Charset.forName("UTF-8")) + writer.write(encodedString) + } finally { + writer?.close() + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/MySQL.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/MySQL.kt new file mode 100644 index 0000000..3f6c557 --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/MySQL.kt @@ -0,0 +1,467 @@ +package com.github.hoshikurama.ticketmanager.common.databases + +import com.github.hoshikurama.ticketmanager.common.TMLocale +import com.github.hoshikurama.ticketmanager.common.byteToPriority +import com.github.hoshikurama.ticketmanager.common.sortActions +import com.github.hoshikurama.ticketmanager.common.ticket.BasicTicket +import com.github.hoshikurama.ticketmanager.common.ticket.ConcreteBasicTicket +import com.github.hoshikurama.ticketmanager.common.ticket.FullTicket +import com.github.hoshikurama.ticketmanager.common.ticket.toTicketLocation +import com.github.jasync.sql.db.ConnectionPoolConfiguration +import com.github.jasync.sql.db.RowData +import com.github.jasync.sql.db.SuspendingConnection +import com.github.jasync.sql.db.asSuspending +import com.github.jasync.sql.db.mysql.MySQLConnectionBuilder +import com.github.jasync.sql.db.mysql.MySQLQueryResult +import com.github.jasync.sql.db.util.ExecutorServiceUtils +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import java.time.Instant +import java.util.* +import java.util.concurrent.Executor +import kotlin.coroutines.CoroutineContext + +class MySQL( + host: String, + port: String, + dbName: String, + username: String, + password: String, + asyncDispatcher: CoroutineDispatcher = Dispatchers.Default, + asyncExecutor: Executor = ExecutorServiceUtils.CommonPool, +) : Database { + private val connectionPool = MySQLConnectionBuilder.createConnectionPool( + ConnectionPoolConfiguration( + host = host, + port = port.toInt(), + database = dbName, + username = username, + password = password, + coroutineDispatcher = asyncDispatcher, + executionContext = asyncExecutor, + ) + ) + private val suspendingCon: SuspendingConnection + get() = connectionPool.asSuspending + + override val type = Database.Type.MySQL + + override suspend fun getActionsAsFlow(ticketID: Int) = flow { + suspendingCon.sendPreparedStatement(query = "SELECT ACTION_ID, ACTION_TYPE, CREATOR_UUID, MESSAGE, TIMESTAMP FROM TicketManager_V4_Actions WHERE TICKET_ID = $ticketID;").rows + .forEach { emit(it.toAction()) } + } + + override suspend fun setAssignment(ticketID: Int, assignment: String?) { + suspendingCon.sendPreparedStatement("UPDATE TicketManager_V4_Tickets SET ASSIGNED_TO = ? WHERE ID = $ticketID;", listOf(assignment)) + } + + override suspend fun setCreatorStatusUpdate(ticketID: Int, status: Boolean) { + suspendingCon.sendPreparedStatement("UPDATE TicketManager_V4_Tickets SET STATUS_UPDATE_FOR_CREATOR = ? WHERE ID = $ticketID;", listOf(status)) + } + + override suspend fun setPriority(ticketID: Int, priority: BasicTicket.Priority) { + suspendingCon.sendPreparedStatement("UPDATE TicketManager_V4_Tickets SET PRIORITY = ? WHERE ID = $ticketID;", listOf(priority.level)) + } + + override suspend fun setStatus(ticketID: Int, status: BasicTicket.Status) { + suspendingCon.sendPreparedStatement("UPDATE TicketManager_V4_Tickets SET STATUS = ? WHERE ID = $ticketID;", listOf(status.name)) + } + + override suspend fun getBasicTicket(ticketID: Int): BasicTicket? { + val query = suspendingCon.sendPreparedStatement("SELECT * FROM TicketManager_V4_Tickets WHERE ID = $ticketID;").rows + return query.firstOrNull()?.toBasicTicket() + } + + override suspend fun addAction(ticketID: Int, action: FullTicket.Action) { + writeAction(ticketID, action) + } + + override suspend fun addFullTicket(fullTicket: FullTicket) { + writeBasicTicket(fullTicket) + fullTicket.actions.forEach { + writeAction(fullTicket.id, it) + } + } + + override suspend fun addNewTicket(basicTicket: BasicTicket, context: CoroutineContext, message: String): Int { + return withContext(context) { + val id = writeBasicTicket(basicTicket).toInt() + launch { writeAction(id, FullTicket.Action(FullTicket.Action.Type.OPEN, basicTicket.creatorUUID, message)) } + id + } + } + + override suspend fun massCloseTickets(lowerBound: Int, upperBound: Int, uuid: UUID?, context: CoroutineContext) { + withContext(context) { + val statusPairs = flow { + suspendingCon.sendPreparedStatement("SELECT ID, STATUS FROM TicketManager_V4_Tickets WHERE ID BETWEEN $lowerBound AND $upperBound;") + .rows + .map { (it.getInt(0)!!) to BasicTicket.Status.valueOf(it.getString(1)!!) } + .filter { it.second == BasicTicket.Status.OPEN } + .forEach { emit(it.first) } + } + + launch { + val idString = statusPairs.toList().joinToString(", ") + suspendingCon.sendPreparedStatement( + query = "UPDATE TicketManager_V4_Tickets SET STATUS = ? WHERE ID IN ($idString);", + values = listOf(BasicTicket.Status.CLOSED.name) + ) + } + + statusPairs.collect { + launch { + writeAction( + action = FullTicket.Action( + type = FullTicket.Action.Type.MASS_CLOSE, + user = uuid, + message = null, + timestamp = Instant.now().epochSecond + ), + ticketID = it + ) + } + } + } + } + + override suspend fun getOpenIDPriorityPairs(): Flow> = flow { + suspendingCon.sendPreparedStatement( + query = "SELECT ID, PRIORITY FROM TicketManager_V4_Tickets WHERE STATUS = ?;", + values = listOf(BasicTicket.Status.OPEN.name) + ) + .rows + .map { it.getInt(0)!! to it.getByte(1)!! } + .forEach { emit(it) } + } + + override suspend fun getAssignedOpenIDPriorityPairs( + assignment: String, + unfixedGroupAssignment: List + ): Flow> = flow { + val groupsSQLStatement = unfixedGroupAssignment.joinToString(" OR ") { "ASSIGNED_TO = ?" } + val groupsFixed = unfixedGroupAssignment.map { "::$it" } + + suspendingCon.sendPreparedStatement( + query = "SELECT ID, PRIORITY FROM TicketManager_V4_Tickets WHERE STATUS = ? AND ($groupsSQLStatement);", + values = listOf(BasicTicket.Status.OPEN.name) + groupsFixed + ) + .rows + .map { it.getInt(0)!! to it.getByte(1)!! } + .forEach { emit(it) } + } + + override suspend fun getIDsWithUpdates(): Flow = flow { + suspendingCon.sendPreparedStatement( + query = "SELECT ID FROM TicketManager_V4_Tickets WHERE STATUS_UPDATE_FOR_CREATOR = ?;", + values = listOf(true) + ) + .rows + .map { it.getInt(0)!! } + .forEach { emit(it) } + } + + override suspend fun getIDsWithUpdatesFor(uuid: UUID): Flow = flow { + suspendingCon.sendPreparedStatement( + query = "SELECT ID FROM TicketManager_V4_Tickets WHERE STATUS_UPDATE_FOR_CREATOR = ? AND CREATOR_UUID = ?;", + values = listOf(true, uuid.toString()) + ) + .rows + .map { it.getInt(0)!! } + .forEach { emit(it) } + } + + override suspend fun getBasicTickets(ids: List): Flow = flow { + val idsSQL = ids.joinToString(", ") { "$it" } + + suspendingCon.sendQuery("SELECT * FROM TicketManager_V4_Tickets WHERE ID IN ($idsSQL);") + .rows + .map { it.toBasicTicket() } + .forEach { emit(it) } + } + + override suspend fun getFullTicketsFromBasics( + basicTickets: List, + context: CoroutineContext + ): Flow = flow { + basicTickets + .map { + withContext(context) { + async { it.toFullTicket() } + } + } + .map { it.await() } + .forEach { emit(it) } + } + + override suspend fun getFullTickets(ids: List, context: CoroutineContext): Flow = flow { + val idsSQL = ids.joinToString(", ") { "$it" } + suspendingCon.sendQuery("SELECT * FROM TicketManager_V4_Tickets WHERE ID IN ($idsSQL);") + .rows + .map { it.toBasicTicket() } + .map { + withContext(context) { + async { it.toFullTicket() } + } + } + .map { it.await() } + .forEach { emit(it) } + } + + override suspend fun searchDatabase( + context: CoroutineContext, + locale: TMLocale, + mainTableConstraints: List>, + searchFunction: (FullTicket) -> Boolean + ): Flow = flow { + fun equalsOrIs(string: String?) = if (string == null) "IS NULL" else "= ?" + + val mainTableSQL = mainTableConstraints.joinToString(" AND ") { + when (it.first) { + locale.searchAssigned -> "ASSIGNED_TO ${equalsOrIs(it.second)}" + locale.searchCreator -> "CREATOR_UUID ${equalsOrIs(it.second)}" + locale.searchPriority -> "PRIORITY = ?" + locale.searchStatus -> "STATUS = ?" + else -> "" + } + } + var statementSQL = "SELECT * FROM TicketManager_V4_Tickets" + if (mainTableConstraints.isNotEmpty()) + statementSQL += " WHERE $mainTableSQL" + + suspendingCon.sendPreparedStatement("$statementSQL;", mainTableConstraints.mapNotNull { it.second }) + .rows + .map { it.toBasicTicket() } + .map { + withContext(context) { + async { it.toFullTicket() } + } + } + .map { it.await() } + .filter(searchFunction) + .forEach{ emit(it) } + } + + + override suspend fun closeDatabase() { + connectionPool.disconnect() + } + + override suspend fun initialiseDatabase() { + suspendingCon.connect() + + if (!tableExists("TicketManager_V4_Tickets")) { + suspendingCon.sendQuery( + """ + CREATE TABLE TicketManager_V4_Tickets ( + ID INT NOT NULL AUTO_INCREMENT, + CREATOR_UUID VARCHAR(36) CHARACTER SET latin1 COLLATE latin1_general_ci, + PRIORITY TINYINT NOT NULL, + STATUS VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + ASSIGNED_TO VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + STATUS_UPDATE_FOR_CREATOR BOOLEAN NOT NULL, + LOCATION VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + KEY STATUS_V4 (STATUS) USING BTREE, + KEY STATUS_UPDATE_FOR_CREATOR_V4 (STATUS_UPDATE_FOR_CREATOR) USING BTREE, + PRIMARY KEY (ID) + ) ENGINE=InnoDB; + """.trimIndent() + ) + } + if (!tableExists("TicketManager_V4_Actions")) { + suspendingCon.sendQuery( + """ + CREATE TABLE TicketManager_V4_Actions ( + ACTION_ID INT NOT NULL AUTO_INCREMENT, + TICKET_ID INT NOT NULL, + ACTION_TYPE VARCHAR(20) CHARACTER SET latin1 COLLATE latin1_general_ci NOT NULL, + CREATOR_UUID VARCHAR(36) CHARACTER SET latin1 COLLATE latin1_general_ci, + MESSAGE TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + TIMESTAMP BIGINT NOT NULL, + PRIMARY KEY (ACTION_ID) + ) ENGINE=InnoDB; + """.trimIndent() + ) + } + } + + override suspend fun updateNeeded(): Boolean { + return tableExists("TicketManagerTicketsV2") + } + + override suspend fun migrateDatabase( + context: CoroutineContext, + to: Database.Type, + mySQLBuilder: suspend () -> MySQL, + sqLiteBuilder: suspend () -> SQLite, + memoryBuilder: suspend () -> Memory, + onBegin: suspend () -> Unit, + onComplete: suspend () -> Unit + ) { + onBegin() + + when (to) { + Database.Type.MySQL -> return + + Database.Type.SQLite -> { + val sqlite = sqLiteBuilder() + sqlite.initialiseDatabase() + + // Gets all tables from MySQL + suspendingCon.sendPreparedStatement("SELECT * FROM TicketManager_V4_Tickets") + .rows + .map { it.toBasicTicket().toFullTicket(this) } + .forEach { + withContext(context) { + launch { sqlite.addFullTicket(it) } + } + } + + sqlite.closeDatabase() + } + + Database.Type.Memory -> { + val memory = sqLiteBuilder() + memory.initialiseDatabase() + + // Gets all tables from MySQL + suspendingCon.sendPreparedStatement("SELECT * FROM TicketManager_V4_Tickets") + .rows + .map { it.toBasicTicket().toFullTicket(this) } + .forEach { memory.addFullTicket(it) } + + memory.closeDatabase() + } + } + + onComplete() + } + + override suspend fun updateDatabase( + context: CoroutineContext, + onBegin: suspend () -> Unit, + onComplete: suspend () -> Unit, + offlinePlayerNameToUuidOrNull: (String) -> UUID? + ) { + withContext(context) { + onBegin() + + suspendingCon.sendPreparedStatement("SELECT * FROM TicketManagerTicketsV2;").rows + .forEach { + val fullTicket = FullTicket( + id = it.getInt(0)!!, + priority = byteToPriority(it.getByte(2)!!), + creatorStatusUpdate = it.getBoolean(9)!!, + status = it.getString(1)!!.let(BasicTicket.Status::valueOf), + assignedTo = it.getString(5), + + creatorUUID = it.getString(4)!!.let { s -> + if (s.lowercase() == "console") null + else UUID.fromString(s) + }, + + location = it.getString(6)!!.run { + if (equals("NoLocation")) null + else toTicketLocation() + }, + + actions = it.getString(8)!! + .split("/MySQLNewLine/") + .filter(String::isNotBlank) + .map { s -> s.split("/MySQLSep/") } + .mapIndexed { index, action -> + FullTicket.Action( + message = action[1], + type = if (index == 0) FullTicket.Action.Type.OPEN else FullTicket.Action.Type.COMMENT, + timestamp = it[7] as Long, + user = + if (action[0].lowercase() == "console") null + else offlinePlayerNameToUuidOrNull(action[0]) + ) + } + , + ) + + launch { + val id = writeBasicTicket(fullTicket).toInt() + fullTicket.actions.forEach { a -> writeAction(id, a) } + } + } + + onComplete() + } + } + + // PRIVATE FUNCTIONS + private suspend fun tableExists(table: String): Boolean { + suspendingCon.sendQuery("SHOW TABLES;").rows + .forEach { + if (it.getString(0) == table.lowercase()) + return true + } + return false + } + + private suspend fun writeBasicTicket(ticket: BasicTicket): Long { + val query = suspendingCon.sendPreparedStatement( + query = "INSERT INTO TicketManager_V4_Tickets (CREATOR_UUID, PRIORITY, STATUS, ASSIGNED_TO, STATUS_UPDATE_FOR_CREATOR, LOCATION) VALUES (?,?,?,?,?,?);", + listOf( + ticket.creatorUUID?.toString(), + ticket.priority.level, + ticket.status.name, + ticket.assignedTo, + ticket.creatorStatusUpdate, + ticket.location?.toString() + ) + ) as MySQLQueryResult + + return query.lastInsertId + } + + private suspend fun writeAction(ticketID: Int, action: FullTicket.Action) { + suspendingCon.sendPreparedStatement( + query = "INSERT INTO TicketManager_V4_Actions (TICKET_ID,ACTION_TYPE,CREATOR_UUID,MESSAGE,TIMESTAMP) VALUES (?,?,?,?,?);", + listOf( + ticketID, + action.type.name, + action.user?.toString(), + action.message, + action.timestamp, + ) + ) + } + + private fun RowData.toBasicTicket(): BasicTicket { + return ConcreteBasicTicket( + id = getInt(0)!!, + assignedTo = getString(4), + creatorStatusUpdate = getBoolean(5)!!, + creatorUUID = getString(1)?.let(UUID::fromString), + priority = getByte(2)!!.let(::byteToPriority), + status = getString(3)!!.let(BasicTicket.Status::valueOf), + location = getString(6)?.split(" ")?.let { + BasicTicket.TicketLocation( + world = it[0], + x = it[1].toInt(), + y = it[2].toInt(), + z = it[3].toInt() + ) + } + ) + } + + private fun RowData.toAction(): FullTicket.Action { + return FullTicket.Action( + type = FullTicket.Action.Type.valueOf(getString(1)!!), + user = getString(2)?.let(UUID::fromString), + message = getString(3), + timestamp = getLong(4)!!, + ) + } + + private suspend fun BasicTicket.toFullTicket() = + FullTicket(this, getActionsAsFlow(id).toList().sortedWith(sortActions)) +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/SQLite.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/SQLite.kt new file mode 100644 index 0000000..27a8c69 --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/databases/SQLite.kt @@ -0,0 +1,447 @@ +package com.github.hoshikurama.ticketmanager.common.databases + +import com.github.hoshikurama.ticketmanager.common.TMLocale +import com.github.hoshikurama.ticketmanager.common.byteToPriority +import com.github.hoshikurama.ticketmanager.common.ticket.BasicTicket +import com.github.hoshikurama.ticketmanager.common.ticket.ConcreteBasicTicket +import com.github.hoshikurama.ticketmanager.common.ticket.FullTicket +import com.github.hoshikurama.ticketmanager.common.ticket.toTicketLocation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotliquery.* +import java.sql.DriverManager +import java.time.Instant +import java.util.* +import kotlin.coroutines.CoroutineContext + + +class SQLite(absoluteDataFolderPath: String) : Database { + override val type = Database.Type.SQLite + private val url: String = "jdbc:sqlite:$absoluteDataFolderPath/TicketManager-SQLite.db" + + + private fun getSession() = Session(Connection(DriverManager.getConnection(url))) + + override suspend fun getActionsAsFlow(ticketID: Int): Flow { + return using(getSession()) { getActions(ticketID) } + .asFlow() + } + + override suspend fun setAssignment(ticketID: Int, assignment: String?) { + using(getSession()) { + it.run(queryOf("UPDATE TicketManager_V4_Tickets SET ASSIGNED_TO = ? WHERE ID = $ticketID;", assignment).asUpdate) + } + } + + override suspend fun setCreatorStatusUpdate(ticketID: Int, status: Boolean) { + using(getSession()) { + it.run(queryOf("UPDATE TicketManager_V4_Tickets SET STATUS_UPDATE_FOR_CREATOR = ? WHERE ID = $ticketID;", status).asUpdate) + } + } + + override suspend fun setPriority(ticketID: Int, priority: BasicTicket.Priority) { + using(getSession()) { + it.run(queryOf("UPDATE TicketManager_V4_Tickets SET PRIORITY = ? WHERE ID = $ticketID;", priority.level).asUpdate) + } + } + + override suspend fun setStatus(ticketID: Int, status: BasicTicket.Status) { + using(getSession()) { + it.run(queryOf("UPDATE TicketManager_V4_Tickets SET STATUS = ? WHERE ID = $ticketID;", status.name).asUpdate) + } + } + + override suspend fun getBasicTicket(ticketID: Int): BasicTicket? { + return using(getSession()) { session1 -> + session1.run( + queryOf("SELECT * FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") + .map { it.toBasicTicket() } + .asSingle + ) + } + } + + override suspend fun addAction(ticketID: Int, action: FullTicket.Action) { + using(getSession()) { + writeAction(action, ticketID, it) + } + } + + override suspend fun addFullTicket(fullTicket: FullTicket) { + using(getSession()) { session -> + writeTicket(fullTicket, session) + + fullTicket.actions.forEach { + writeAction(it, fullTicket.id, session) + } + } + } + + override suspend fun addNewTicket(basicTicket: BasicTicket, context: CoroutineContext, message: String): Int { + return using(getSession()) { session -> + val id = writeTicket(basicTicket, session)!!.toInt() + writeAction(FullTicket.Action(FullTicket.Action.Type.OPEN, basicTicket.creatorUUID, message), id, session) + id + } + } + + override suspend fun massCloseTickets(lowerBound: Int, upperBound: Int, uuid: UUID?, context: CoroutineContext) { + using(getSession()) { session -> + + val idStatusPairs = session.run(queryOf("SELECT ID, STATUS FROM TicketManager_V4_Tickets WHERE ID BETWEEN $lowerBound AND $upperBound;") + .map { it.int(1) to BasicTicket.Status.valueOf(it.string(2)) } + .asList + ) + + idStatusPairs.asSequence() + .filter { it.second == BasicTicket.Status.OPEN } + .forEach { + writeAction( + FullTicket.Action( + type = FullTicket.Action.Type.MASS_CLOSE, + user = uuid, + message = null, + timestamp = Instant.now().epochSecond + ), + ticketID = it.first, + session = session + ) + } + + val idString = idStatusPairs.map { it.first }.joinToString(", ") + session.run(queryOf("UPDATE TicketManager_V4_Tickets SET STATUS = ? WHERE ID IN ($idString);", BasicTicket.Status.CLOSED.name).asUpdate) + } + } + + override suspend fun getOpenIDPriorityPairs(): Flow> { + return using(getSession()) { session -> + session.run( + queryOf("SELECT ID, PRIORITY FROM TicketManager_V4_Tickets WHERE STATUS = ?;", BasicTicket.Status.OPEN.name) + .map { it.int(1) to it.byte(2) } + .asList + ) + }.asFlow() + } + + override suspend fun getAssignedOpenIDPriorityPairs( + assignment: String, + unfixedGroupAssignment: List + ): Flow> { + val groupsSQLStatement = unfixedGroupAssignment.joinToString(" OR ") { "ASSIGNED_TO = ?" } + val groupsFixed = unfixedGroupAssignment.map { "::$it" } + + return using(getSession()) { session -> + session.run( + queryOf( + statement = "SELECT ID, PRIORITY FROM TicketManager_V4_Tickets WHERE STATUS = ? AND ($groupsSQLStatement);", + params = (listOf(BasicTicket.Status.OPEN.name) + groupsFixed).toTypedArray() + ) + .map { it.int(1) to it.byte(2) } + .asList + ) + }.asFlow() + } + + override suspend fun getIDsWithUpdates(): Flow { + return using(getSession()) { session -> + session.run( + queryOf("SELECT ID FROM TicketManager_V4_Tickets WHERE STATUS_UPDATE_FOR_CREATOR = ?;", true) + .map { it.int(1) } + .asList + ) + }.asFlow() + } + + override suspend fun getIDsWithUpdatesFor(uuid: UUID): Flow { + return using(getSession()) { session -> + session.run( + queryOf("SELECT ID FROM TicketManager_V4_Tickets WHERE STATUS_UPDATE_FOR_CREATOR = ? AND CREATOR_UUID = ?;", true, uuid) + .map { it.int(1) } + .asList + ) + }.asFlow() + } + + override suspend fun getBasicTickets(ids: List): Flow { + val idsSQL = ids.joinToString(", ") { "$it" } + + return using(getSession()) { session -> + session.run( + queryOf("SELECT * FROM TicketManager_V4_Tickets WHERE ID IN ($idsSQL);") + .map { it.toBasicTicket() } + .asList + ) + }.asFlow() + } + + override suspend fun getFullTicketsFromBasics( + basicTickets: List, + context: CoroutineContext + ): Flow { + return basicTickets + .map { it.toFullTicket() } + .asFlow() + } + + override suspend fun getFullTickets(ids: List, context: CoroutineContext): Flow { + return ids.asFlow() + .mapNotNull { getBasicTicket(it) } + .map { it.toFullTicket() } + .toList() + .asFlow() + } + + override suspend fun searchDatabase( + context: CoroutineContext, + locale: TMLocale, + mainTableConstraints: List>, + searchFunction: (FullTicket) -> Boolean + ): Flow { + fun equalsOrIs(string: String?) = if (string == null) "IS NULL" else "= ?" + + val mainTableSQL = mainTableConstraints.joinToString(" AND ") { + when (it.first) { + locale.searchCreator -> "CREATOR_UUID ${equalsOrIs(it.second)}" + locale.searchAssigned -> "ASSIGNED_TO ${equalsOrIs(it.second)}" + locale.searchPriority -> "PRIORITY = ?" + locale.searchStatus -> "STATUS = ?" + else -> "" + } + } + + val inputtedArgs = mainTableConstraints + .mapNotNull { it.second } + .toTypedArray() + + var statementSQL = "SELECT * FROM TicketManager_V4_Tickets" + if (mainTableConstraints.isNotEmpty()) + statementSQL += " WHERE $mainTableSQL" + + return using(getSession()) { session -> + val basicTickets = session.run( + queryOf("$statementSQL;", *inputtedArgs) + .map { it.toBasicTicket() } + .asList + ) + + basicTickets + .map { it.toFullTicket() } + .filter(searchFunction) + }.asFlow() + } + + override suspend fun closeDatabase() { + // NOT needed as database makes individual connections + } + + override suspend fun initialiseDatabase() { + //Creates table if doesn't exist + using(getSession()) { + if (!tableExists("TicketManager_V4_Tickets", it)) { + it.run( + queryOf(""" + CREATE TABLE TicketManager_V4_Tickets ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + CREATOR_UUID VARCHAR(36) COLLATE NOCASE, + PRIORITY TINYINT NOT NULL, + STATUS VARCHAR(10) COLLATE NOCASE NOT NULL, + ASSIGNED_TO VARCHAR(255) COLLATE NOCASE, + STATUS_UPDATE_FOR_CREATOR BOOLEAN NOT NULL, + LOCATION VARCHAR(255) COLLATE NOCASE + );""".trimIndent() + ).asExecute + ) + it.run(queryOf("CREATE INDEX STATUS_V4 ON TicketManager_V4_Tickets (STATUS)").asExecute) + it.run(queryOf("CREATE INDEX STATUS_UPDATE_FOR_CREATOR_V4 ON TicketManager_V4_Tickets (STATUS_UPDATE_FOR_CREATOR)").asExecute) + } + + if (!tableExists("TicketManager_V4_Actions", it)) { + it.run( + queryOf(""" + CREATE TABLE TicketManager_V4_Actions ( + ACTION_ID INTEGER PRIMARY KEY AUTOINCREMENT, + TICKET_ID INTEGER NOT NULL, + ACTION_TYPE VARCHAR(20) COLLATE NOCASE NOT NULL, + CREATOR_UUID VARCHAR(36) COLLATE NOCASE, + MESSAGE TEXT COLLATE NOCASE, + TIMESTAMP BIGINT NOT NULL + );""".trimIndent() + ).asExecute + ) + } + } + } + + override suspend fun updateNeeded(): Boolean { + return using(getSession()) { + tableExists("TicketManagerTicketsV2", it) + } + } + + override suspend fun migrateDatabase( + context: CoroutineContext, + to: Database.Type, + mySQLBuilder: suspend () -> MySQL, + sqLiteBuilder: suspend () -> SQLite, + memoryBuilder: suspend () -> Memory, + onBegin: suspend () -> Unit, + onComplete: suspend () -> Unit + ) { + onBegin() + + when (to) { + Database.Type.SQLite -> return + + Database.Type.MySQL, + Database.Type.Memory -> { + val otherDB = if (to == Database.Type.MySQL) mySQLBuilder() else memoryBuilder() + otherDB.initialiseDatabase() + + using(getSession()) { session -> + session.run( + queryOf("SELECT * FROM TicketManager_V4_Tickets") + .map { it.toBasicTicket() } + .asList + ) + } + .forEach { + withContext(context) { + launch { + val fullTicket = it.toFullTicket(this@SQLite) + otherDB.addFullTicket(fullTicket) + } + } + } + otherDB.closeDatabase() + } + } + + onComplete() + } + + override suspend fun updateDatabase( + context: CoroutineContext, + onBegin: suspend () -> Unit, + onComplete: suspend () -> Unit, + offlinePlayerNameToUuidOrNull: (String) -> UUID? + ) { + coroutineScope { + onBegin() + + using(getSession()) { session -> + session.forEach(queryOf("SELECT * FROM TicketManagerTicketsV2;")) { row -> + val ticket = FullTicket( + id = row.int(1), + priority = byteToPriority(row.byte(3)), + creatorStatusUpdate = row.boolean(10), + status = BasicTicket.Status.valueOf(row.string(2)), + assignedTo = row.stringOrNull(6), + + creatorUUID = row.string(5).let { + if (it.lowercase() == "console") null + else UUID.fromString(it) + }, + location = row.string(7).run { + if (equals("NoLocation")) null + else toTicketLocation() + }, + actions = row.string(9) + .split("/MySQLNewLine/") + .filter { it.isNotBlank() } + .map { it.split("/MySQLSep/") } + .mapIndexed { index, action -> + FullTicket.Action( + message = action[1], + type = if (index == 0) FullTicket.Action.Type.OPEN else FullTicket.Action.Type.COMMENT, + timestamp = row.long(8), + user = + if (action[0].lowercase() == "console") null + else offlinePlayerNameToUuidOrNull(action[0]) + ) + } + ) + val id = writeTicket(ticket, session) + ticket.actions.forEach { writeAction(it, id!!.toInt(), session) } + } + + session.run(queryOf("DROP INDEX STATUS;").asExecute) + session.run(queryOf("DROP INDEX UPDATEDBYOTHERUSER;").asExecute) + session.run(queryOf("DROP TABLE TicketManagerTicketsV2;").asUpdate) + + } + + onComplete() + } + + } + + private fun writeTicket(ticket: BasicTicket, session: Session): Long? { + return session.run(queryOf("INSERT INTO TicketManager_V4_Tickets (CREATOR_UUID, PRIORITY, STATUS, ASSIGNED_TO, STATUS_UPDATE_FOR_CREATOR, LOCATION) VALUES (?,?,?,?,?,?);", + ticket.creatorUUID, + ticket.priority.level, + ticket.status.name, + ticket.assignedTo, + ticket.creatorStatusUpdate, + ticket.location?.toString() + ).asUpdateAndReturnGeneratedKey) + } + + private fun writeAction(action: FullTicket.Action, ticketID: Int, session: Session) { + session.run(queryOf("INSERT INTO TicketManager_V4_Actions (TICKET_ID,ACTION_TYPE,CREATOR_UUID,MESSAGE,TIMESTAMP) VALUES (?,?,?,?,?);", + ticketID, + action.type.name, + action.user?.toString(), + action.message, + action.timestamp + ).asExecute) + } + + private fun Row.toBasicTicket(): BasicTicket { + return ConcreteBasicTicket( + id = int(1), + creatorUUID = stringOrNull(2)?.let(UUID::fromString), + priority = byteToPriority(byte(3)), + status = BasicTicket.Status.valueOf(string(4)), + assignedTo = stringOrNull(5), + creatorStatusUpdate = boolean(6), + location = stringOrNull(7)?.split(" ")?.let { + BasicTicket.TicketLocation( + world = it[0], + x = it[1].toInt(), + y = it[2].toInt(), + z = it[3].toInt() + ) + } + ) + } + + private fun Row.toAction(): FullTicket.Action { + return FullTicket.Action( + type = FullTicket.Action.Type.valueOf(string(2)), + user = stringOrNull(3)?.let { UUID.fromString(it) }, + message = stringOrNull(4), + timestamp = long(5) + ) + } + + private fun getActions(ticketID: Int): List { + return using(getSession()) { session -> + session.run(queryOf("SELECT ACTION_ID, ACTION_TYPE, CREATOR_UUID, MESSAGE, TIMESTAMP FROM TicketManager_V4_Actions WHERE TICKET_ID = $ticketID;") + .map { row -> row.toAction() } + .asList + ) + } + } + + private fun tableExists(table: String, session: Session): Boolean { + return using(session.connection.underlying.metaData.getTables(null, null, table, null)) { + while (it.next()) + if (it.getString("TABLE_NAME")?.equals(table) == true) return@using true + return@using false + } + } + + private fun BasicTicket.toFullTicket() = FullTicket(this, getActions(id)) +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/BasicTicket.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/BasicTicket.kt new file mode 100644 index 0000000..76e62b9 --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/BasicTicket.kt @@ -0,0 +1,57 @@ +package com.github.hoshikurama.ticketmanager.common.ticket + +import com.github.hoshikurama.ticketmanager.common.TMLocale +import com.github.hoshikurama.ticketmanager.common.databases.Database +import kotlinx.serialization.Serializable +import java.util.* + +interface BasicTicket { + val id: Int // Ticket ID 1+... -1 placeholder during ticket creation + val creatorUUID: UUID? // UUID if player, null if Console + val location: TicketLocation? // TicketLocation if player, null if Console + val priority: Priority // Priority 1-5 or Lowest to Highest + val status: Status // Status OPEN or CLOSED + val assignedTo: String? // Null if not assigned to anybody + val creatorStatusUpdate: Boolean // Determines whether player should be notified + + @Serializable + data class TicketLocation(val world: String, val x: Int, val y: Int, val z: Int) { + override fun toString() = "$world $x $y $z" + } + + @Serializable + enum class Priority(val level: Byte, val colourCode: String) { + LOWEST(1, "&1"), + LOW(2, "&9"), + NORMAL(3, "&e"), + HIGH(4, "&c"), + HIGHEST(5, "&4") + } + + @Serializable + enum class Status(val colourCode: String) { + OPEN("&a"), CLOSED("&c") + } + + suspend fun toFullTicket(database: Database): FullTicket +} + + +fun BasicTicket.Priority.toLocaledWord(locale: TMLocale) = when (this) { + BasicTicket.Priority.LOWEST -> locale.priorityLowest + BasicTicket.Priority.LOW -> locale.priorityLow + BasicTicket.Priority.NORMAL -> locale.priorityNormal + BasicTicket.Priority.HIGH -> locale.priorityHigh + BasicTicket.Priority.HIGHEST -> locale.priorityHighest +} + +fun BasicTicket.Status.toLocaledWord(locale: TMLocale) = when (this) { + BasicTicket.Status.OPEN -> locale.statusOpen + BasicTicket.Status.CLOSED -> locale.statusClosed +} + +fun BasicTicket.uuidMatches(other: UUID?) = + creatorUUID?.equals(other) ?: (other == null) + +fun String.toTicketLocation() = split(" ") + .let { BasicTicket.TicketLocation(it[0], it[1].toInt(), it[2].toInt(), it[3].toInt()) } \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/BasicTicketHandler.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/BasicTicketHandler.kt new file mode 100644 index 0000000..dfcbea9 --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/BasicTicketHandler.kt @@ -0,0 +1,30 @@ +package com.github.hoshikurama.ticketmanager.common.ticket + +import com.github.hoshikurama.ticketmanager.common.databases.Database + +class BasicTicketHandler( + private val basicTicket: BasicTicket, + val database: Database, +) : BasicTicket by basicTicket { + + suspend fun setCreatorStatusUpdate(value: Boolean) = + database.setCreatorStatusUpdate(id, value) + + suspend fun setTicketPriority(value: BasicTicket.Priority) = + database.setPriority(id, value) + + suspend fun setTicketStatus(value: BasicTicket.Status) = + database.setStatus(id, value) + + suspend fun setAssignedTo(value: String?) = + database.setAssignment(id, value) + + suspend fun toFullTicket() = basicTicket.toFullTicket(database) + + companion object { + suspend fun buildHandler(database: Database, id: Int): BasicTicketHandler? { + val basicTicket = database.getBasicTicket(id) + return basicTicket?.let { BasicTicketHandler(it, database) } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/ConcreteBasicTicket.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/ConcreteBasicTicket.kt new file mode 100644 index 0000000..73a19dc --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/ConcreteBasicTicket.kt @@ -0,0 +1,25 @@ +package com.github.hoshikurama.ticketmanager.common.ticket + +import com.github.hoshikurama.ticketmanager.common.databases.Database +import com.github.hoshikurama.ticketmanager.common.sortActions +import kotlinx.coroutines.flow.toList +import java.util.* + +open class ConcreteBasicTicket( + override val id: Int = -1, + override val creatorUUID: UUID?, + override val location: BasicTicket.TicketLocation?, + override val priority: BasicTicket.Priority = BasicTicket.Priority.NORMAL, + override val status: BasicTicket.Status = BasicTicket.Status.OPEN, + override val assignedTo: String? = null, + override val creatorStatusUpdate: Boolean = false, +) : BasicTicket { + + override suspend fun toFullTicket(database: Database): FullTicket { + val sortedActions = database.getActionsAsFlow(id) + .toList() + .sortedWith(sortActions) + + return FullTicket(this, sortedActions) + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/FullTicket.kt b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/FullTicket.kt new file mode 100644 index 0000000..7e1041a --- /dev/null +++ b/common/src/main/kotlin/com/github/hoshikurama/ticketmanager/common/ticket/FullTicket.kt @@ -0,0 +1,61 @@ +package com.github.hoshikurama.ticketmanager.common.ticket + +import com.github.hoshikurama.ticketmanager.common.databases.Database +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.Instant +import java.util.* + +@Serializable +class FullTicket( + override val id: Int, + @Serializable(with = UUIDSerializer::class) + override val creatorUUID: UUID?, + override val location: BasicTicket.TicketLocation?, + override val priority: BasicTicket.Priority, + override val status: BasicTicket.Status, + override val assignedTo: String?, + override val creatorStatusUpdate: Boolean, + val actions: List +) : BasicTicket { + + constructor(basicTicket: BasicTicket, actionsList: List) : this( + id = basicTicket.id, + creatorUUID = basicTicket.creatorUUID, + location = basicTicket.location, + priority = basicTicket.priority, + status = basicTicket.status, + assignedTo = basicTicket.assignedTo, + creatorStatusUpdate = basicTicket.creatorStatusUpdate, + actions = actionsList + ) + + @Serializable + data class Action( + val type: Type, @Serializable(with = UUIDSerializer::class) val user: UUID?, val message: String? = null, val timestamp: Long = Instant.now().epochSecond) { + enum class Type { + ASSIGN, CLOSE, COMMENT, OPEN, REOPEN, SET_PRIORITY, MASS_CLOSE + } + } + + override suspend fun toFullTicket(database: Database) = this +} + +object UUIDSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: UUID?) { + val string = value?.toString() ?: "NULL" + encoder.encodeString(string) + } + + override fun deserialize(decoder: Decoder): UUID? { + val string = decoder.decodeString() + return if (string == "NULL") null else UUID.fromString(string) + } +} \ No newline at end of file diff --git a/src/main/resources/languages/Locales/Example.yml b/common/src/main/resources/locales/Example.yml similarity index 97% rename from src/main/resources/languages/Locales/Example.yml rename to common/src/main/resources/locales/Example.yml index 537639c..22b4b12 100644 --- a/src/main/resources/languages/Locales/Example.yml +++ b/common/src/main/resources/locales/Example.yml @@ -8,7 +8,6 @@ # %CC% - Colour Code Theme # %nl% - New Line # ############################ - # View and Deep View Format: ViewFormat_Header: '%num%' ViewFormat_Sep1: '' @@ -74,6 +73,7 @@ Warning_TicketAlreadyOpen: '' Warning_InvalidDBType: '' Warning_ConvertToSameDBType: '' Warning_UnexpectedError: '' +Warning_LongTaskDuringReload: '' # # Modified Stacktrace Stacktrace_Line1: '' @@ -139,6 +139,7 @@ Page_Next: '' Info_ReloadInitiated: '%user%' Info_Reload_TasksDone: '' Info_ReloadSuccess: '' +Info_ReloadFailure: '' Info_DBUpdate: '' Info_DBUpdateComplete: '' Info_DBConversionInit: '' @@ -195,4 +196,5 @@ Notify_Event_TicketCreation: '%user% %id% %%message%' Notify_Event_TicketModification: '%id%' Notify_Event_TicketReopen: '%user% %id%' Notify_Event_SetPriority: '%user% %id% %priority%' -Notify_Event_PluginUpdate: '%current% %latest%' \ No newline at end of file +Notify_Event_PluginUpdate: '%current% %latest%' + diff --git a/src/main/resources/languages/Locales/en_CA.yml b/common/src/main/resources/locales/en_CA.yml similarity index 90% rename from src/main/resources/languages/Locales/en_CA.yml rename to common/src/main/resources/locales/en_CA.yml index 3ed6951..638554b 100644 --- a/src/main/resources/languages/Locales/en_CA.yml +++ b/common/src/main/resources/locales/en_CA.yml @@ -63,11 +63,12 @@ Warning_TicketAlreadyOpen: '&c[TicketManager] This ticket is already open!' Warning_InvalidDBType: '&c[TicketManager] Invalid database type!' Warning_ConvertToSameDBType: '&c[TicketManager] Unable to convert database to type already in use!' Warning_UnexpectedError: '&c[TicketManager] Unexpected error has occurred!' +Warning_LongTaskDuringReload: '&c[TicketManager] Long-standing task detected during reload attempt. Forcefully ending all other tasks!' # # Modified Stacktrace Stacktrace_Line1: '%nl%%nl%%nl%&4[TicketManager] WARNING! An unexpected error has occurred!' -Stacktrace_Line2: '&c%nl% Exception: &7%exception%' -Stacktrace_Line3: '&c%nl% Information: %message%' +Stacktrace_Line2: '&c%nl% Exception: &7%exception%' +Stacktrace_Line3: '&c%nl% Information: &7%message%' Stacktrace_Line4: '&4%nl%=-=-=-=-=-=-=Modified Stacktrace:=-=-=-=-=-=-=' Stacktrace_Entry: '&c%nl% %method% (%file%:%line%)' # @@ -104,7 +105,7 @@ Command_DeepView: 'deepview' # # List Format: ListFormat_Header: '%nl%%CC%[TicketManager]&f Viewing All Open Tickets:' -ListFormat_AssignedHeader: '%CC%[TicketManager]&f Viewing Open Tickets Assigned to You:' +ListFormat_AssignedHeader: '%nl%%CC%[TicketManager]&f Viewing Open Tickets Assigned to You:' ListFormat_Entry: '%priorityCC%[%ID%] &8[%CC%%creator% &8-> %CC%%assign%&8]&f %comment%' # # Clickable Text: @@ -128,6 +129,7 @@ Page_Next: 'Next' Info_ReloadInitiated: '%CC%[TicketManager]&7 %user%%CC% has initiated a plugin restart! Plugin locked and waiting for ongoing tasks to complete...' Info_Reload_TasksDone: '%CC%[TicketManager] All other tasks complete! Reloading now...' Info_ReloadSuccess: '%CC%[TicketManager] Reload&a successful%CC%!' +Info_ReloadFailure: '%CC%[TicketManager] Reload&c failure%CC%!' Info_DBUpdate: '%CC%[TicketManager] Database updating to latest version! Plugin Locked! DO NOT TURN OFF SERVER!' Info_DBUpdateComplete: '%CC%[TicketManager] Database update complete! Plugin unlocked!' Info_DBConversionInit: '%CC%[TicketManager] Database conversion from &7%fromDB%%CC% to &7%toDB%%CC%! Plugin locked!' @@ -167,21 +169,21 @@ Parameters_Constraints: 'Constraints' Notify_UnreadUpdate_Single: '%CC%[TicketManager] Ticket &7%num%%CC% has an update! Type &7/ticket view %CC% to clear notification.' Notify_UnreadUpdate_Multi: '%CC%[TicketManager] Tickets &7%num%%CC% have updates! Type &7/ticket view %CC% for all updated tickets to clear notifications.' Notify_OpenAssigned: '%CC%[TicketManager]&7 %open% %CC%tickets open (&7%assigned%%CC% assigned to you)' -Notify_MassCloseSuccess: '%CC%[TicketManager] Mass close from &7%low%%CC% to &7%high%%CC% successful!' -Notify_TicketAssignSuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% successfully assigned to:&7 %assign%%CC%!' -Notify_TicketCloseSuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% successfully closed!' -Notify_TicketCloseWithCommentSuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% successfully closed with a comment!' -Notify_TicketCreationSuccessful: '%CC%[TicketManager] Ticket &7#%id%%CC% successfully created!' -Notify_TicketCommentSuccessful: '%CC%[TicketManager] Ticket &7#%id%%CC% successfully commented on!' -Notify_TicketReopenSuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% successfully re-opened!' -Notify_TicketSetPrioritySuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% successfully' +Notify_MassCloseSuccess: '%CC%[TicketManager] Mass-close from &7%low%%CC% to &7%high%%CC% sent!' +Notify_TicketAssignSuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% assigned to:&7 %assign%%CC%!' +Notify_TicketCloseSuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% closed!' +Notify_TicketCloseWithCommentSuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% closed with a comment!' +Notify_TicketCreationSuccessful: '%CC%[TicketManager] Ticket &7#%id%%CC% created!' +Notify_TicketCommentSuccessful: '%CC%[TicketManager] Ticket &7#%id%%CC% commented on!' +Notify_TicketReopenSuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% re-opened!' +Notify_TicketSetPrioritySuccess: '%CC%[TicketManager] Ticket &7#%id%%CC% priority changed!' Notify_Event_TicketAssign: '%CC%[TicketManager]&7 %user%%CC% assigned ticket &7#%id%%CC% to &7%assign%%CC%.' Notify_Event_TicketClose: '%CC%[TicketManager] &7%user%%CC% closed ticket &7#%id%%CC%.' Notify_Event_TicketCloseWithComment: '%CC%[TicketManager] &7%user%%CC% closed ticket &7#%id%%CC% with a comment:%nl%&7%message%' -Notify_Event_MassClose: '%CC%[TicketManager]&7 %user%%CC% mass-closed tickets from &7#%low%%CC% to &7#%high%%CC%.' +Notify_Event_MassClose: '%CC%[TicketManager]&7 %user%%CC% is mass-closing tickets from &7#%low%%CC% to &7#%high%%CC%.' Notify_Event_TicketComment: '%CC%[TicketManager]&7 %user%%CC% commented on ticket &7#%id%%CC%:&7%nl%%message%' Notify_Event_TicketCreation: '%CC%[TicketManager] &7%user%%CC% created ticket &7#%id%%CC%:&7%nl%%message%' Notify_Event_TicketModification: '%CC%[TicketManager] Ticket &7#%id%%CC% has been updated! Type &7/ticket view %id%%CC% to view this ticket.' -Notify_Event_TicketReopen: '%CC%[TicketManager]&7 %user%%CC% re-opened ticket &7#%id%%CC%.' +Notify_Event_TicketReopen: '%CC%[TicketManager]&7 %user%%CC% reopened ticket &7#%id%%CC%.' Notify_Event_SetPriority: '%CC%[TicketManager] &7%user%%CC% set ticket &7#%id%%CC% to priority %priority%' Notify_Event_PluginUpdate: '%CC%[TicketManager] TicketManager has an update!%nl% %CC%Current Version: &7%current%%nl% %CC%Latest Version: &7%latest%' \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 16499b8..19b2f93 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,7 @@ -rootProject.name = "TicketManagerKotlin" \ No newline at end of file +rootProject.name = "TicketManager" + +include( + "common", + "Paper", + "Spigot" +) \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/Globals.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/Globals.kt deleted file mode 100644 index 4d9da90..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/Globals.kt +++ /dev/null @@ -1,199 +0,0 @@ -package com.hoshikurama.github.ticketmanager - -import com.hoshikurama.github.ticketmanager.ticket.Ticket -import net.md_5.bungee.api.ChatColor -import net.md_5.bungee.api.chat.ClickEvent -import net.md_5.bungee.api.chat.HoverEvent -import net.md_5.bungee.api.chat.TextComponent -import net.md_5.bungee.api.chat.hover.content.Text -import org.bukkit.Bukkit -import org.bukkit.command.CommandSender -import org.bukkit.entity.Player -import java.time.Instant -import java.util.* -import java.util.logging.Level -import kotlin.Comparator - -internal val sortForList: Comparator = Comparator.comparing(Ticket::priority).reversed().thenComparing(Comparator.comparing(Ticket::id).reversed()) - -internal val mainPlugin: TicketManagerPlugin - get() = TicketManagerPlugin.plugin - -internal val pluginState: PluginState - get() = mainPlugin.configState - - -fun CommandSender.sendPlatformMessage( - component: net.md_5.bungee.api.chat.BaseComponent, - serverType: PluginState.ServerType? = pluginState.serverType - ) { - when (serverType) { - PluginState.ServerType.Paper -> sendMessage(component) - PluginState.ServerType.Spigot -> spigot().sendMessage(component) - else -> try { - Class.forName("com.destroystokyo.paper.VersionHistoryManager\$VersionData") - sendPlatformMessage(component, PluginState.ServerType.Paper) - } catch (e: Exception) { - sendPlatformMessage(component, PluginState.ServerType.Spigot) - } - } -} - -fun Player.sendPlatformMessage( - component: net.md_5.bungee.api.chat.BaseComponent, - serverType: PluginState.ServerType? = pluginState.serverType -) { - when (serverType) { - PluginState.ServerType.Paper -> sendMessage(component) - PluginState.ServerType.Spigot -> spigot().sendMessage(component) - else -> try { - Class.forName("com.destroystokyo.paper.VersionHistoryManager\$VersionData") - sendPlatformMessage(component, PluginState.ServerType.Paper) - } catch (e: Exception) { - sendPlatformMessage(component, PluginState.ServerType.Spigot) - } - } -} - -fun getUUUIDStringOrNull(playerName: String): String? { - return when (pluginState.serverType) { - PluginState.ServerType.Paper -> Bukkit.getPlayerUniqueId(playerName)?.toString() - PluginState.ServerType.Spigot -> - Bukkit.getOfflinePlayers() - .asSequence() - .filter { it.name?.equals(playerName) ?: false } - .map { it.uniqueId.toString() } - .firstOrNull() - } -} - -fun byteToPriority(byte: Byte) = when (byte.toInt()) { - 1 -> Ticket.Priority.LOWEST - 2 -> Ticket.Priority.LOW - 3 -> Ticket.Priority.NORMAL - 4 -> Ticket.Priority.HIGH - 5 -> Ticket.Priority.HIGHEST - else -> Ticket.Priority.NORMAL -} - -fun postModifiedStacktrace(e: Exception) { - Bukkit.getOnlinePlayers().asSequence() - .filter { it.has("ticketmanager.notify.warning") } - .map { it to getLocale(it) } - .forEach { p -> - val sentComponent = TextComponent("") - - listOf( - p.second.stacktraceLine1, - p.second.stacktraceLine2.replace("%exception%", e.javaClass.simpleName), - p.second.stacktraceLine3.replace("%message%", e.message ?: "?"), - p.second.stacktraceLine4 - ) - .map(::toColour) - .map(::TextComponent) - .forEach { sentComponent.addExtra(it) } - - // Adds stacktrace entries - e.stackTrace - .filter { it.className.startsWith("com.hoshikurama.github.ticketmanager") } - .map { - p.second.stacktraceEntry - .replace("%method%", it.methodName) - .replace("%file%", it.fileName ?: "?") - .replace("%line%", "${it.lineNumber}") - } - .map(::toColour) - .map(::TextComponent) - .forEach { sentComponent.addExtra(it) } - - p.first.sendPlatformMessage(sentComponent, null) - } -} - -internal fun anyLocksPresent() = mainPlugin.pluginLocked - -internal fun getLocale(player: Player) = when (pluginState.serverType) { - PluginState.ServerType.Paper -> pluginState.enabledLocales.getOrDefault(player.locale().toString()) - PluginState.ServerType.Spigot -> pluginState.enabledLocales.getOrDefault(player.locale) -} - -internal fun getLocale(sender: CommandSender): TMLocale { - return if (sender is Player) getLocale(sender) - else pluginState.enabledLocales.consoleLocale -} - -fun Player.has(permission: String): Boolean = mainPlugin.perms.has(this, permission) - -fun CommandSender.has(permission: String): Boolean = if (this is Player) has(permission) else true - -fun consoleLog(level: Level, message: String): Unit = Bukkit.getLogger().log(level, ChatColor.stripColor(message)) - -fun toColour(string: String): String = ChatColor.translateAlternateColorCodes('&', string) - -fun pushMassNotify(permission: String, localeMsg: (TMLocale) -> String, level: Level = Level.INFO) { - consoleLog(level, localeMsg(pluginState.enabledLocales.consoleLocale)) - - Bukkit.getOnlinePlayers().asSequence() - .filter { it.has(permission) } - .forEach { getLocale(it).run(localeMsg).run(::toColour).run { it.sendMessage(this) } } -} - -fun String.sendColouredMessageTo(player: Player) = - toColour(this).run { player.sendMessage(this) } - -fun String.sendColouredMessageTo(sender: CommandSender) = - toColour(this).run { sender.sendMessage(this) } - -fun stripColour(str: String): String { - return ChatColor.stripColor(str).replace("&", "&&") -} - -fun Long.toLargestRelativeTime(locale: TMLocale): String { - val timeAgo = Instant.now().epochSecond - this - - return when { - timeAgo >= 31556952L -> (timeAgo / 31556952L).toString() + locale.timeYears - timeAgo >= 604800L ->(timeAgo / 604800L).toString() + locale.timeWeeks - timeAgo >= 86400L ->(timeAgo / 86400L).toString() + locale.timeDays - timeAgo >= 3600L ->(timeAgo / 3600L).toString() + locale.timeHours - timeAgo >= 60L ->(timeAgo / 60L).toString() + locale.timeMinutes - else -> timeAgo.toString() + locale.timeSeconds - } -} - -fun relTimeToEpochSecond(relTime: String, locale: TMLocale): Long { - var seconds = 0L - var index = 0 - val unprocessed = StringBuilder(relTime) - - while (unprocessed.isNotEmpty() && index != unprocessed.lastIndex + 1) { - unprocessed[index].toString().toByteOrNull() - // If number... - ?.apply { index++ } - // If not a number... - ?: run { - val number = if (index == 0) 0 - else unprocessed.substring(0, index).toLong() - - seconds += number * when (unprocessed[index].toString()) { - locale.searchTimeSecond -> 1L - locale.searchTimeMinute -> 60L - locale.searchTimeHour -> 3600L - locale.searchTimeDay -> 86400L - locale.searchTimeWeek -> 604800L - locale.searchTimeYear -> 31556952L - else -> 0L - } - - unprocessed.delete(0, index+1) - index = 0 - } - } - - return Instant.now().epochSecond - seconds -} - -fun TextComponent.addViewTicketOnClick(id: Int, locale: TMLocale) { - hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickViewTicket)) - clickEvent = ClickEvent(ClickEvent.Action.RUN_COMMAND, locale.run { "/$commandBase $commandWordView $id" } ) -} \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/PluginState.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/PluginState.kt deleted file mode 100644 index fdc2b0b..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/PluginState.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.hoshikurama.github.ticketmanager - -import com.hoshikurama.github.ticketmanager.databases.Database -import com.hoshikurama.github.ticketmanager.databases.MySQL -import com.hoshikurama.github.ticketmanager.databases.SQLite -import com.hoshikurama.github.ticketmanager.events.Commands -import com.hoshikurama.github.ticketmanager.events.TabCompletePaper -import com.hoshikurama.github.ticketmanager.events.TabCompleteSpigot -import org.bukkit.Bukkit -import java.io.File -import java.io.InputStream -import java.net.URL -import java.time.Instant -import java.util.* -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicReference -import java.util.logging.Level - -class PluginState { - internal val enabledLocales: AllLocales - internal val cooldowns: Cooldown - internal val database: Database - internal val serverType: ServerType - internal val allowUnreadTicketUpdates: Boolean - internal val updateAvailable: AtomicReference?> = AtomicReference(null) - - init { - mainPlugin.pluginLocked = true - - var announceGeneration = false - if (!File(mainPlugin.dataFolder, "config.yml").exists()) { - mainPlugin.saveDefaultConfig() - announceGeneration = true - } - - mainPlugin.reloadConfig() - val config = mainPlugin.config - - config.run { - enabledLocales = AllLocales( - getString("Colour_Code", "&3")!!, - getString("Preferred_Locale", "en_ca")!!, - getString("Console_Locale", "en_ca")!!, - getBoolean("Force_Locale", false) - ) - - cooldowns = Cooldown( - getBoolean("Use_Cooldowns", false), - getLong("Cooldown_Time", 0L) - ) - - database = tryOrNull { - val type = getString("Database_Mode", "SQLite")!! - .let { tryOrNull { Database.Types.valueOf(it) } ?: Database.Types.SQLite } - - when (type) { - Database.Types.MySQL -> MySQL( - getString("MySQL_Host")!!, - getString("MySQL_Port")!!, - getString("MySQL_DBName")!!, - getString("MySQL_Username")!!, - getString("MySQL_Password")!!) - Database.Types.SQLite -> SQLite() - } - } ?: SQLite() - - allowUnreadTicketUpdates = getBoolean("Allow_Unread_Ticket_Updates", true) - - // Assigns update available - val allowUpdateCheck = getBoolean("Allow_UpdateChecking", false) - if (allowUpdateCheck) { - Bukkit.getScheduler().runTaskAsynchronously(mainPlugin, Runnable { - - val curVersion = mainPlugin.description.version - val latestVersion = UpdateChecker(91178).getLatestVersion() - .run { this ?: curVersion } - updateAvailable.set(curVersion to latestVersion) - }) - } else updateAvailable.set(null) - } - - serverType = tryOrNull { - Class.forName("com.destroystokyo.paper.VersionHistoryManager\$VersionData") - ServerType.Paper - } ?: ServerType.Spigot - - - if (announceGeneration) { - Bukkit.getScheduler().scheduleSyncDelayedTask(mainPlugin, { - pushMassNotify("ticketmanager.notify.warning", { it.warningsNoConfig }, Level.WARNING) - }, 100) - } - - // Register events and commands - enabledLocales.getCommandBases().forEach { - mainPlugin.getCommand(it)?.setExecutor(Commands()) - if (serverType == ServerType.Paper) - mainPlugin.server.pluginManager.registerEvents(TabCompletePaper(), mainPlugin) - else mainPlugin.getCommand(it)?.tabCompleter = TabCompleteSpigot() - // Remember to register any keyword in plugin.yml - } - - // Allows object to initialise while scheduling db update - if (database.updateNeeded()) { - Bukkit.getScheduler().runTaskLaterAsynchronously(mainPlugin, Runnable { - database.updateDatabase() - mainPlugin.pluginLocked = false - }, 20) - } else mainPlugin.pluginLocked = false - } - - class Cooldown(private val enabled: Boolean, private val duration: Long) { - private val map = ConcurrentHashMap() - - fun checkAndSet(uuid: UUID?): Boolean { - if (!enabled || uuid == null) return false - - val curTime = Instant.now().epochSecond - val applies = map[uuid]?.let { it <= curTime } ?: false - return if (applies) true - else map.put(uuid, duration + curTime).run { true } - } - - fun filterMap(): Unit = map.forEach { if (it.value > Instant.now().epochSecond) map.remove(it.key) } - } - - enum class ServerType { - Paper, Spigot - } - - private inline fun tryOrNull(function: () -> T): T? = - try { function() } - catch (ignored: Exception) { null } -} \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/TicketManagerPlugin.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/TicketManagerPlugin.kt deleted file mode 100644 index 230b92b..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/TicketManagerPlugin.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.hoshikurama.github.ticketmanager - -import com.hoshikurama.github.ticketmanager.events.PlayerJoin -import net.milkbowl.vault.permission.Permission -import org.bukkit.Bukkit -import org.bukkit.plugin.java.JavaPlugin -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import java.util.logging.Level - -class TicketManagerPlugin : JavaPlugin() { - private val pluginLockedInternal = AtomicBoolean(true) - private lateinit var metrics: Metrics - internal val ticketCountMetrics = AtomicInteger(0) - - lateinit var perms: Permission - private set - lateinit var configState: PluginState - internal set - - var pluginLocked: Boolean - get() = pluginLockedInternal.get() - set(v) = pluginLockedInternal.set(v) - - companion object { lateinit var plugin: TicketManagerPlugin } - init { plugin = this } - - - override fun onEnable() { - pluginLocked = true - - // Creates Plugin State from config file - configState = PluginState() - - // Find Vault plugin - server.servicesManager.getRegistration(Permission::class.java)?.provider - ?.let { perms = it } - ?: consoleLog(Level.SEVERE, configState.enabledLocales.consoleLocale.warningsVaultNotFound) - .also { this.pluginLoader.disablePlugin(this) } - - // Register Event - server.pluginManager.registerEvents(PlayerJoin(), this) - - // Launch Metrics - metrics = Metrics(this, metricsKey) - metrics.addCustomChart( - Metrics.SingleLineChart("tickets_made") - { ticketCountMetrics.getAndSet(0) } - ) - - - Bukkit.getScheduler().runTaskTimerAsynchronously(mainPlugin, Runnable { - if (anyLocksPresent()) return@Runnable - - // Mass Unread Notify - try { - if (pluginState.allowUnreadTicketUpdates) { - configState.database.getTicketIDsWithUpdates() - .groupBy({ it.first }, { it.second }) - .asSequence() - .mapNotNull { Bukkit.getPlayer(it.key)?.run { Pair(this, it.value) } } - .filter { it.first.has("ticketmanager.notify.unreadUpdates.scheduled") } - .forEach { - val template = if (it.second.size > 1) getLocale(it.first).notifyUnreadUpdateMulti - else getLocale(it.first).notifyUnreadUpdateSingle - val tickets = it.second.joinToString(", ") - - template.replace("%num%", tickets) - .sendColouredMessageTo(it.first) - } - } - - // Open and Assigned Notify - val tickets = pluginState.database.getOpen() - .map { it.assignedTo } - - Bukkit.getOnlinePlayers().asSequence() - .filter { it.has("ticketmanager.notify.openTickets.scheduled") } - .forEach { p -> - val open = tickets.size.toString() - val assigned = tickets.asSequence() - .filterNotNull() - .filter { s -> - if (s.startsWith("::")) - perms.getPlayerGroups(p) - .asSequence() - .map { "::$it" } - .filter { it == s } - .any() - else s == p.name - }.count().toString() - - getLocale(p).notifyOpenAssigned - .replace("%open%", open) - .replace("%assigned%", assigned) - .sendColouredMessageTo(p) - } - - configState.cooldowns.filterMap() - - } catch (e: Exception) { - e.printStackTrace() - postModifiedStacktrace(e) - } - }, 0, 12000) - } - - override fun onDisable() { - configState.database.closeDatabase() - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/databases/Database.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/databases/Database.kt deleted file mode 100644 index feaa5cd..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/databases/Database.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.hoshikurama.github.ticketmanager.databases - -import com.hoshikurama.github.ticketmanager.ticket.Ticket -import java.util.* - -interface Database { - enum class Types { - MySQL, SQLite - } - - val type: Types - - // Individual things - fun getAssignment(ticketID: Int): String? - fun getCreatorUUID(ticketID: Int): UUID? - fun getLocation(ticketID: Int): org.bukkit.Location? - fun getPriority(ticketID: Int): Ticket.Priority - fun getStatus(ticketID: Int): Ticket.Status - fun getStatusUpdateForCreator(ticketID: Int): Boolean - fun setAssignment(ticketID: Int, assignment: String?) - fun setPriority(ticketID: Int, priority: Ticket.Priority) - fun setStatus(ticketID: Int, status: Ticket.Status) - fun setStatusUpdateForCreator(ticketID: Int, status: Boolean) - - // More specific Ticket actions - fun addAction(ticketID: Int, action: Ticket.Action) - fun addTicket(ticket: Ticket, action: Ticket.Action): Int - fun getOpen(): List - fun getOpenAssigned(assignment: String, groupAssignment: List): List - fun getTicket(ID: Int): Ticket? - fun getTicketIDsWithUpdates(): List> - fun getTicketIDsWithUpdates(uuid: UUID): List - fun isValidID(ticketID: Int): Boolean - fun massCloseTickets(lowerBound: Int, upperBound: Int, uuid: UUID?) - fun searchDatabase(searchFunction: (Ticket) -> Boolean): List - - // Database Modifications - fun closeDatabase() - fun createDatabasesIfNeeded() - fun migrateDatabase(targetType: Types) - fun updateNeeded(): Boolean - fun updateDatabase() -} \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/databases/MySQL.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/databases/MySQL.kt deleted file mode 100644 index 41cb95d..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/databases/MySQL.kt +++ /dev/null @@ -1,413 +0,0 @@ -package com.hoshikurama.github.ticketmanager.databases - -import com.hoshikurama.github.ticketmanager.* -import com.hoshikurama.github.ticketmanager.ticket.Ticket -import com.hoshikurama.github.ticketmanager.ticket.toBukkitLocationOrNull -import kotliquery.* -import org.bukkit.Bukkit -import java.sql.Statement -import java.time.Instant -import java.util.* - -internal class MySQL( - host: String, - port: String, - dbName: String, - username: String, - password: String, -) : Database { - override val type = Database.Types.MySQL - private val dataSource = HikariCP.default("jdbc:mysql://$host:$port/$dbName?serverTimezone=UTC", username, password) - private val toBukkitLocOrNull: String.() -> org.bukkit.Location? = { - this.split(" ") - .let { Ticket.Location( - world = it[0], - x = it[1].toInt(), - y = it[2].toInt(), - z = it[3].toInt()) - } - .toBukkitLocationOrNull() - } - - init { - createDatabasesIfNeeded() - } - - override fun getAssignment(ticketID: Int): String? { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT ASSIGNED_TO FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { it.stringOrNull(1) } - .asSingle - ) - } - } - - override fun getCreatorUUID(ticketID: Int): UUID? { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT CREATOR_UUID FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { it.stringOrNull(1)?.run(UUID::fromString) } - .asSingle - ) - } - } - - override fun getLocation(ticketID: Int): org.bukkit.Location? { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT LOCATION FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { it.stringOrNull(1)?.toBukkitLocOrNull() } - .asSingle - ) - } - } - - override fun getPriority(ticketID: Int): Ticket.Priority { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT PRIORITY FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { byteToPriority(it.byte(1)) } - .asSingle - )!! - } - } - - override fun getStatus(ticketID: Int): Ticket.Status { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT STATUS FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { Ticket.Status.valueOf(it.string(1)) } - .asSingle - )!! - } - } - - override fun getStatusUpdateForCreator(ticketID: Int): Boolean { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT STATUS_UPDATE_FOR_CREATOR FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { it.boolean(1) } - .asSingle - )!! - } - } - - override fun setAssignment(ticketID: Int, assignment: String?) { - using(sessionOf(dataSource)) { - it.run(queryOf("UPDATE TicketManager_V4_Tickets SET ASSIGNED_TO = ? WHERE ID = $ticketID;", assignment).asUpdate) - } - } - - override fun setPriority(ticketID: Int, priority: Ticket.Priority) { - using(sessionOf(dataSource)) { - it.run(queryOf("UPDATE TicketManager_V4_Tickets SET PRIORITY = ? WHERE ID = $ticketID;", priority.level).asUpdate) - } - } - - override fun setStatus(ticketID: Int, status: Ticket.Status) { - using(sessionOf(dataSource)) { - it.run(queryOf("UPDATE TicketManager_V4_Tickets SET STATUS = ? WHERE ID = $ticketID;", status.name).asUpdate) - } - } - - override fun setStatusUpdateForCreator(ticketID: Int, status: Boolean) { - using(sessionOf(dataSource)) { - it.run(queryOf("UPDATE TicketManager_V4_Tickets SET STATUS_UPDATE_FOR_CREATOR = ? WHERE ID = $ticketID;", status).asUpdate) - } - } - - // More Specific Ticket Actions - - override fun addAction(ticketID: Int, action: Ticket.Action) { - using(sessionOf(dataSource)) { writeAction(action, ticketID, it) } - } - - override fun addTicket(ticket: Ticket, action: Ticket.Action): Int { - return using(sessionOf(dataSource)) { - val id = writeTicket(ticket, it) - writeAction(action, id.toInt(), it) - return@using id.toInt() - } - } - - override fun getOpen(): List { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT * FROM TicketManager_V4_Tickets WHERE STATUS = ?;", Ticket.Status.OPEN.toString()) - .map { it.toTicket(session) } - .asList) - }.sortedWith(sortForList) - } - - override fun getOpenAssigned(assignment: String, groupAssignment: List) = - getOpen().filter { it.assignedTo == assignment || it.assignedTo in groupAssignment } - - override fun getTicket(ID: Int): Ticket? { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT * FROM TicketManager_V4_Tickets WHERE ID = $ID;") - .map { row -> row.toTicket(session) } - .asSingle) - } - } - - override fun getTicketIDsWithUpdates(): List> { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT CREATOR_UUID, ID FROM TicketManager_V4_Tickets WHERE STATUS_UPDATE_FOR_CREATOR = ?;", true) - .map { Pair( first = UUID.fromString(it.string(1)), second = it.int(2)) } - .asList) - } - } - - - override fun getTicketIDsWithUpdates(uuid: UUID): List { - return using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT ID FROM TicketManager_V4_Tickets WHERE STATUS_UPDATE_FOR_CREATOR = ? AND CREATOR_UUID = ?;", true, uuid) - .map { it.int(1) } - .asList - ) - } - } - - override fun isValidID(ticketID: Int): Boolean { - val result = using(sessionOf(dataSource)) { session -> - session.run(queryOf("SELECT ID FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { true } - .asSingle - ) - } - return result ?: false - } - - override fun massCloseTickets(lowerBound: Int, upperBound: Int, uuid: UUID?) { - using(sessionOf(dataSource)) { session -> - val idStatusPairs = session.run(queryOf("SELECT ID, STATUS FROM TicketManager_V4_Tickets WHERE ID BETWEEN $lowerBound AND $upperBound;") - .map { it.int(1) to Ticket.Status.valueOf(it.string(2)) } - .asList - ) - - idStatusPairs.asSequence() - .filter { it.second == Ticket.Status.OPEN } - .forEach { - writeAction( - Ticket.Action( - type = Ticket.Action.Type.MASS_CLOSE, - user = uuid, - message = null, - timestamp = Instant.now().epochSecond - ), - ticketID = it.first, - session = session - ) - } - - val idString = idStatusPairs.map { it.first }.joinToString(", ") - session.run(queryOf("UPDATE TicketManager_V4_Tickets SET STATUS = ? WHERE ID IN ($idString);", Ticket.Status.CLOSED.name).asUpdate) - } - } - - override fun searchDatabase(searchFunction: (Ticket) -> Boolean): List { - val matchedTickets = mutableListOf() - - using(sessionOf(dataSource)) { session -> - session.forEach(queryOf("SELECT * FROM TicketManager_V4_Tickets")) { row -> - row.toTicket(session).takeIf(searchFunction)?.apply(matchedTickets::add) - } - } - return matchedTickets - } - - override fun closeDatabase() { - dataSource.close() - } - - override fun createDatabasesIfNeeded() { - using(sessionOf(dataSource)) { - if (!tableExists("TicketManager_V4_Tickets", it)) - it.run(queryOf(""" - CREATE TABLE TicketManager_V4_Tickets ( - ID INT NOT NULL AUTO_INCREMENT, - CREATOR_UUID VARCHAR(36) CHARACTER SET latin1 COLLATE latin1_general_ci, - PRIORITY TINYINT NOT NULL, - STATUS VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - ASSIGNED_TO VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, - STATUS_UPDATE_FOR_CREATOR BOOLEAN NOT NULL, - LOCATION VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, - KEY STATUS_V4 (STATUS) USING BTREE, - KEY STATUS_UPDATE_FOR_CREATOR_V4 (STATUS_UPDATE_FOR_CREATOR) USING BTREE, - PRIMARY KEY (ID) - ) ENGINE=InnoDB;""".trimIndent() - ).asExecute) - if (!tableExists("TicketManager_V4_Actions", it)) - it.run(queryOf(""" - CREATE TABLE TicketManager_V4_Actions ( - ACTION_ID INT NOT NULL AUTO_INCREMENT, - TICKET_ID INT NOT NULL, - ACTION_TYPE VARCHAR(20) CHARACTER SET latin1 COLLATE latin1_general_ci NOT NULL, - CREATOR_UUID VARCHAR(36) CHARACTER SET latin1 COLLATE latin1_general_ci, - MESSAGE TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, - TIMESTAMP BIGINT NOT NULL, - PRIMARY KEY (ACTION_ID) - ) ENGINE=InnoDB;""".trimIndent() - ).asExecute) - } - } - - override fun updateNeeded(): Boolean { - return using(sessionOf(dataSource)) { - tableExists("TicketManagerTicketsV2", it) - } - } - - override fun updateDatabase() { - pushMassNotify("ticketmanager.notify.info", { it.informationDBUpdate } ) - - fun playerNameToUUIDOrNull(name: String) = Bukkit.getOfflinePlayers() - .asSequence() - .filter { it.name == name } - .map { it.uniqueId } - .firstOrNull() - - using(sessionOf(dataSource)) { session -> - session.forEach(queryOf("SELECT * FROM TicketManagerTicketsV2;")) { row -> - val ticket = Ticket( - id = row.int(1), - priority = byteToPriority(row.byte(3)), - statusUpdateForCreator = row.boolean(10), - status = Ticket.Status.valueOf(row.string(2)), - assignedTo = row.stringOrNull(6), - - creatorUUID = row.string(5).let { - if (it.lowercase() == "console") null - else UUID.fromString(it) - }, - location = row.string(7).run { - if (equals("NoLocation")) null - else Ticket.Location(split(" ")) - }, - actions = row.string(9) - .split("/MySQLNewLine/") - .filter { it.isNotBlank() } - .map { it.split("/MySQLSep/") } - .mapIndexed { index, action -> - Ticket.Action( - message = action[1], - type = if (index == 0) Ticket.Action.Type.OPEN else Ticket.Action.Type.COMMENT, - timestamp = row.long(8), - user = - if (action[0].lowercase() == "console") null - else playerNameToUUIDOrNull(action[0]) - ) - } - ) - val id = writeTicket(ticket, session) - ticket.actions.forEach { writeAction(it, id.toInt(), session) } - } - - session.run(queryOf("DROP INDEX STATUS ON TicketManagerTicketsV2;").asExecute) - session.run(queryOf("DROP INDEX UPDATEDBYOTHERUSER ON TicketManagerTicketsV2;").asExecute) - session.run(queryOf("DROP TABLE TicketManagerTicketsV2;").asUpdate) - - pushMassNotify("ticketmanager.notify.info", { it.informationDBUpdateComplete } ) - } - } - - override fun migrateDatabase(targetType: Database.Types) { - mainPlugin.pluginLocked = true - - when (targetType) { - Database.Types.MySQL -> {} // MySQL -> MySQL not permitted! - - Database.Types.SQLite -> { - pushMassNotify("ticketmanager.notify.info", { - it.informationDBConvertInit - .replace("%fromDB%", Database.Types.MySQL.name) - .replace("%toDB%", Database.Types.SQLite.name) - }) - - try { - val sqLite = SQLite() - - // Writes to SQLite - using(sessionOf(dataSource)) { session -> - session.forEach(queryOf("SELECT * FROM TicketManager_V4_Tickets")) { row -> //NOTE: During conversion, ticket ID is not guaranteed to be preserved - row.toTicket(session).apply { - val newID = sqLite.addTicket(this, actions[0]) - if (actions.size > 1) actions.subList(1, actions.size) - .forEach { sqLite.addAction(newID, it) } - } - } - } - - pushMassNotify("ticketmanager.notify.info", { it.informationDBConvertSuccess } ) - - } catch (e: Exception) { - e.printStackTrace() - postModifiedStacktrace(e) - } - } - } - - mainPlugin.pluginLocked = false - } - - private fun writeTicket(ticket: Ticket, session: Session): Long { - val stmt = session.connection.underlying.prepareStatement( - "INSERT INTO TicketManager_V4_Tickets (CREATOR_UUID, PRIORITY, STATUS, ASSIGNED_TO, STATUS_UPDATE_FOR_CREATOR, LOCATION) VALUES (?,?,?,?,?,?);", - Statement.RETURN_GENERATED_KEYS) - stmt.setString(1, ticket.creatorUUID?.toString()) - stmt.setByte(2, ticket.priority.level) - stmt.setString(3, ticket.status.name) - stmt.setString(4, ticket.assignedTo) - stmt.setBoolean(5, ticket.statusUpdateForCreator) - stmt.setString(6, ticket.location?.toString()) - stmt.executeUpdate() - - val rs = stmt.generatedKeys - rs.next() - return rs.getLong(1) - } - - private fun writeAction(action: Ticket.Action, ticketID: Int, session: Session) { - session.run(queryOf("INSERT INTO TicketManager_V4_Actions (TICKET_ID,ACTION_TYPE,CREATOR_UUID,MESSAGE,TIMESTAMP) VALUES (?,?,?,?,?);", - ticketID, - action.type.name, - action.user?.toString(), - action.message, - action.timestamp - ).asExecute) - } - - private fun Row.toTicket(session: Session): Ticket { - val id = int(1) - return Ticket( - id = id, - creatorUUID = stringOrNull(2)?.let(UUID::fromString), - priority = byteToPriority(byte(3)), - status = Ticket.Status.valueOf(string(4)), - assignedTo = stringOrNull(5), - statusUpdateForCreator = boolean(6), - location = stringOrNull(7)?.split(" ")?.let { - Ticket.Location( - world = it[0], - x = it[1].toInt(), - y = it[2].toInt(), - z = it[3].toInt() - ) - }, - actions = session.run(queryOf("SELECT ACTION_ID, ACTION_TYPE, CREATOR_UUID, MESSAGE, TIMESTAMP FROM TicketManager_V4_Actions WHERE TICKET_ID = $id;") - .map { row -> - row.int(1) to Ticket.Action( - type = Ticket.Action.Type.valueOf(row.string(2)), - user = row.stringOrNull(3)?.let { UUID.fromString(it) }, - message = row.stringOrNull(4), - timestamp = row.long(5) - ) - }.asList - ) - .sortedBy { it.first } - .map { it.second } - ) - } - - private fun tableExists(table: String, session: Session): Boolean { - return using(session.connection.underlying.metaData.getTables(null, null, table, null)) { - while (it.next()) - if (it.getString("TABLE_NAME")?.equals(table) == true) return@using true - return@using false - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/databases/SQLite.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/databases/SQLite.kt deleted file mode 100644 index 6a14bfd..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/databases/SQLite.kt +++ /dev/null @@ -1,417 +0,0 @@ -package com.hoshikurama.github.ticketmanager.databases - -import com.hoshikurama.github.ticketmanager.* -import com.hoshikurama.github.ticketmanager.ticket.Ticket -import com.hoshikurama.github.ticketmanager.ticket.toBukkitLocationOrNull -import kotliquery.* -import org.bukkit.Bukkit -import java.sql.DriverManager -import java.time.Instant -import java.util.* - - -internal class SQLite : Database { - override val type = Database.Types.SQLite - private val url: String = "jdbc:sqlite:${mainPlugin.dataFolder.absolutePath}/TicketManager-SQLite.db" - private val toBukkitLocOrNull: String.() -> org.bukkit.Location? = { - this.split(" ") - .let { Ticket.Location( - world = it[0], - x = it[1].toInt(), - y = it[2].toInt(), - z = it[3].toInt()) - } - .toBukkitLocationOrNull() - } - - init { - createDatabasesIfNeeded() - } - - override fun getAssignment(ticketID: Int): String? { - return using(getSession()) { session -> - session.run(queryOf("SELECT ASSIGNED_TO FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { it.stringOrNull(1) } - .asSingle - ) - } - } - - override fun getCreatorUUID(ticketID: Int): UUID? { - return using(getSession()) { session -> - session.run(queryOf("SELECT CREATOR_UUID FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { it.stringOrNull(1)?.run(UUID::fromString) } - .asSingle - ) - } - } - - override fun getLocation(ticketID: Int): org.bukkit.Location? { - return using(getSession()) { session -> - session.run(queryOf("SELECT LOCATION FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { it.stringOrNull(1)?.toBukkitLocOrNull() } - .asSingle - ) - } - } - - override fun getPriority(ticketID: Int): Ticket.Priority { - return using(getSession()) { session -> - session.run(queryOf("SELECT PRIORITY FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { byteToPriority(it.byte(1)) } - .asSingle - )!! - } - } - - override fun getStatus(ticketID: Int): Ticket.Status { - return using(getSession()) { session -> - session.run(queryOf("SELECT STATUS FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { Ticket.Status.valueOf(it.string(1)) } - .asSingle - )!! - } - } - - override fun getStatusUpdateForCreator(ticketID: Int): Boolean { - return using(getSession()) { session -> - session.run(queryOf("SELECT STATUS_UPDATE_FOR_CREATOR FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { it.boolean(1) } - .asSingle - )!! - } - } - - override fun setAssignment(ticketID: Int, assignment: String?) { - using(getSession()) { - it.run(queryOf("UPDATE TicketManager_V4_Tickets SET ASSIGNED_TO = ? WHERE ID = $ticketID;", assignment).asUpdate) - } - } - - override fun setPriority(ticketID: Int, priority: Ticket.Priority) { - using(getSession()) { - it.run(queryOf("UPDATE TicketManager_V4_Tickets SET PRIORITY = ? WHERE ID = $ticketID;", priority.level).asUpdate) - } - } - - override fun setStatus(ticketID: Int, status: Ticket.Status) { - using(getSession()) { - it.run(queryOf("UPDATE TicketManager_V4_Tickets SET STATUS = ? WHERE ID = $ticketID;", status.name).asUpdate) - } - } - - override fun setStatusUpdateForCreator(ticketID: Int, status: Boolean) { - using(getSession()) { - it.run(queryOf("UPDATE TicketManager_V4_Tickets SET STATUS_UPDATE_FOR_CREATOR = ? WHERE ID = $ticketID;", status).asUpdate) - } - } - - override fun addAction(ticketID: Int, action: Ticket.Action) { - using(getSession()) { writeAction(action, ticketID, it) } - } - - override fun addTicket(ticket: Ticket, action: Ticket.Action): Int { - return using(getSession()) { - val id = writeTicket(ticket, it) - writeAction(action, id!!.toInt(), it) - return@using id.toInt() - } - } - - override fun getOpen(): List { - return using(getSession()) { session -> - session.run(queryOf("SELECT * FROM TicketManager_V4_Tickets WHERE STATUS = ?;", Ticket.Status.OPEN.toString()) - .map { it.toTicket(session) } - .asList - ) - }.sortedWith(sortForList) - } - - override fun getOpenAssigned(assignment: String, groupAssignment: List) = - getOpen().filter { it.assignedTo == assignment || it.assignedTo in groupAssignment } - - override fun getTicket(ID: Int): Ticket? { - return using(getSession()) { session -> - session.run(queryOf("SELECT * FROM TicketManager_V4_Tickets WHERE ID = $ID;") - .map { row -> row.toTicket(session) } - .asSingle - ) - } - } - - override fun getTicketIDsWithUpdates(): List> { - return using(getSession()) { session -> - session.run(queryOf("SELECT CREATOR_UUID, ID FROM TicketManager_V4_Tickets WHERE STATUS_UPDATE_FOR_CREATOR = ?;", true) - .map { Pair( first = UUID.fromString(it.string(1)), second = it.int(2)) } - .asList - ) - } - } - - override fun getTicketIDsWithUpdates(uuid: UUID): List { - return using(getSession()) { session -> - session.run(queryOf("SELECT ID FROM TicketManager_V4_Tickets WHERE STATUS_UPDATE_FOR_CREATOR = ? AND CREATOR_UUID = ?;", true, uuid) - .map { it.int(1) } - .asList - ) - } - } - - override fun isValidID(ticketID: Int): Boolean { - val result = using(getSession()) { session -> - session.run(queryOf("SELECT ID FROM TicketManager_V4_Tickets WHERE ID = $ticketID;") - .map { true } - .asSingle - ) - } - return result ?: false - } - - override fun massCloseTickets(lowerBound: Int, upperBound: Int, uuid: UUID?) { - using(getSession()) { session -> - val idStatusPairs = session.run(queryOf("SELECT ID, STATUS FROM TicketManager_V4_Tickets WHERE ID BETWEEN $lowerBound AND $upperBound;") - .map { it.int(1) to Ticket.Status.valueOf(it.string(2)) } - .asList - ) - - idStatusPairs.asSequence() - .filter { it.second == Ticket.Status.OPEN } - .forEach { - writeAction( - Ticket.Action( - type = Ticket.Action.Type.MASS_CLOSE, - user = uuid, - message = null, - timestamp = Instant.now().epochSecond - ), - ticketID = it.first, - session = session - ) - } - - val idString = idStatusPairs.map { it.first }.joinToString(", ") - session.run(queryOf("UPDATE TicketManager_V4_Tickets SET STATUS = ? WHERE ID IN ($idString);", Ticket.Status.CLOSED.name).asUpdate) - } - } - - override fun searchDatabase(searchFunction: (Ticket) -> Boolean): List { - val matchedTickets = mutableListOf() - - using(getSession()) { session -> - session.forEach(queryOf("SELECT * FROM TicketManager_V4_Tickets")) { row -> - row.toTicket(session).takeIf(searchFunction)?.apply(matchedTickets::add) - } - } - return matchedTickets - } - - override fun closeDatabase() { - // NOT needed as database makes individual connections - } - - override fun createDatabasesIfNeeded() { - using(getSession()) { - if (!tableExists("TicketManager_V4_Tickets", it)) { - it.run( - queryOf(""" - CREATE TABLE TicketManager_V4_Tickets ( - ID INTEGER PRIMARY KEY AUTOINCREMENT, - CREATOR_UUID VARCHAR(36) COLLATE NOCASE, - PRIORITY TINYINT NOT NULL, - STATUS VARCHAR(10) COLLATE NOCASE NOT NULL, - ASSIGNED_TO VARCHAR(255) COLLATE NOCASE, - STATUS_UPDATE_FOR_CREATOR BOOLEAN NOT NULL, - LOCATION VARCHAR(255) COLLATE NOCASE - );""".trimIndent() - ).asExecute - ) - it.run(queryOf("CREATE INDEX STATUS_V4 ON TicketManager_V4_Tickets (STATUS)").asExecute) - it.run(queryOf("CREATE INDEX STATUS_UPDATE_FOR_CREATOR_V4 ON TicketManager_V4_Tickets (STATUS_UPDATE_FOR_CREATOR)").asExecute) - } - - if (!tableExists("TicketManager_V4_Actions", it)) { - it.run( - queryOf(""" - CREATE TABLE TicketManager_V4_Actions ( - ACTION_ID INTEGER PRIMARY KEY AUTOINCREMENT, - TICKET_ID INTEGER NOT NULL, - ACTION_TYPE VARCHAR(20) COLLATE NOCASE NOT NULL, - CREATOR_UUID VARCHAR(36) COLLATE NOCASE, - MESSAGE TEXT COLLATE NOCASE, - TIMESTAMP BIGINT NOT NULL - );""".trimIndent() - ).asExecute - ) - } - } - } - - override fun migrateDatabase(targetType: Database.Types) { - mainPlugin.pluginLocked = true - - when (targetType) { - Database.Types.SQLite -> {} // SQLite -> SQLite is not permitted - - Database.Types.MySQL -> { - pushMassNotify("ticketmanager.notify.info", { - it.informationDBConvertInit - .replace("%fromDB%", Database.Types.SQLite.name) - .replace("%toDB%", Database.Types.MySQL.name) - } ) - - try { - val config = mainPlugin.config - val mySQL = MySQL( - config.getString("MySQL_Host")!!, - config.getString("MySQL_Port")!!, - config.getString("MySQL_DBName")!!, - config.getString("MySQL_Username")!!, - config.getString("MySQL_Password")!! - ) - - // Writes to MySQL - using(getSession()) { session -> - session.forEach(queryOf("SELECT * FROM TicketManager_V4_Tickets")) { row -> //NOTE: During conversion, ticket ID is not guaranteed to be preserved - row.toTicket(session).apply { - val newID = mySQL.addTicket(this, actions[0]) - if (actions.size > 1) actions.subList(1, actions.size) - .forEach { mySQL.addAction(newID, it) } - } - } - } - - pushMassNotify("ticketmanager.notify.info", { it.informationDBConvertSuccess } ) - - } catch (e: Exception) { - e.printStackTrace() - postModifiedStacktrace(e) - } - } - } - - mainPlugin.pluginLocked = false - } - - override fun updateNeeded(): Boolean { - return using(getSession()) { - tableExists("TicketManagerTicketsV2", it) - } - } - - override fun updateDatabase() { - pushMassNotify("ticketmanager.notify.info", { it.informationDBUpdate } ) - - fun playerNameToUUIDOrNull(name: String) = Bukkit.getOfflinePlayers() - .asSequence() - .filter { it.name == name } - .map { it.uniqueId } - .firstOrNull() - - using(getSession()) { session -> - session.forEach(queryOf("SELECT * FROM TicketManagerTicketsV2;")) { row -> - val ticket = Ticket( - id = row.int(1), - priority = byteToPriority(row.byte(3)), - statusUpdateForCreator = row.boolean(10), - status = Ticket.Status.valueOf(row.string(2)), - assignedTo = row.stringOrNull(6), - - creatorUUID = row.string(5).let { - if (it.lowercase() == "console") null - else UUID.fromString(it) - }, - location = row.string(7).run { - if (equals("NoLocation")) null - else Ticket.Location(split(" ")) - }, - actions = row.string(9) - .split("/MySQLNewLine/") - .filter { it.isNotBlank() } - .map { it.split("/MySQLSep/") } - .mapIndexed { index, action -> - Ticket.Action( - message = action[1], - type = if (index == 0) Ticket.Action.Type.OPEN else Ticket.Action.Type.COMMENT, - timestamp = row.long(8), - user = - if (action[0].lowercase() == "console") null - else playerNameToUUIDOrNull(action[0]) - ) - } - ) - val id = writeTicket(ticket, session) - ticket.actions.forEach { writeAction(it, id!!.toInt(), session) } - } - - session.run(queryOf("DROP INDEX STATUS;").asExecute) - session.run(queryOf("DROP INDEX UPDATEDBYOTHERUSER;").asExecute) - session.run(queryOf("DROP TABLE TicketManagerTicketsV2;").asUpdate) - - pushMassNotify("ticketmanager.notify.info", { it.informationDBUpdateComplete } ) - } - } - - private fun tableExists(table: String, session: Session): Boolean { - return using(session.connection.underlying.metaData.getTables(null, null, table, null)) { - while (it.next()) - if (it.getString("TABLE_NAME")?.equals(table) == true) return@using true - return@using false - } - } - - private fun getSession() = Session(Connection(DriverManager.getConnection(url))) - - private fun writeTicket(ticket: Ticket, session: Session): Long? { - return session.run(queryOf("INSERT INTO TicketManager_V4_Tickets (CREATOR_UUID, PRIORITY, STATUS, ASSIGNED_TO, STATUS_UPDATE_FOR_CREATOR, LOCATION) VALUES (?,?,?,?,?,?);", - ticket.creatorUUID, - ticket.priority.level, - ticket.status.name, - ticket.assignedTo, - ticket.statusUpdateForCreator, - ticket.location?.toString() - ).asUpdateAndReturnGeneratedKey) - } - - private fun writeAction(action: Ticket.Action, ticketID: Int, session: Session) { - session.run(queryOf("INSERT INTO TicketManager_V4_Actions (TICKET_ID,ACTION_TYPE,CREATOR_UUID,MESSAGE,TIMESTAMP) VALUES (?,?,?,?,?);", - ticketID, - action.type.name, - action.user?.toString(), - action.message, - action.timestamp - ).asExecute) - } - - private fun Row.toTicket(session: Session): Ticket { - val id = int(1) - return Ticket( - id = id, - creatorUUID = stringOrNull(2)?.let(UUID::fromString), - priority = byteToPriority(byte(3)), - status = Ticket.Status.valueOf(string(4)), - assignedTo = stringOrNull(5), - statusUpdateForCreator = boolean(6), - location = stringOrNull(7)?.split(" ")?.let { - Ticket.Location( - world = it[0], - x = it[1].toInt(), - y = it[2].toInt(), - z = it[3].toInt() - ) - }, - actions = session.run( - queryOf("SELECT ACTION_ID, ACTION_TYPE, CREATOR_UUID, MESSAGE, TIMESTAMP FROM TicketManager_V4_Actions WHERE TICKET_ID = $id;") - .map { row -> - row.int(1) to Ticket.Action( - type = Ticket.Action.Type.valueOf(row.string(2)), - user = row.stringOrNull(3)?.let { UUID.fromString(it) }, - message = row.stringOrNull(4), - timestamp = row.long(5) - ) - }.asList - ) - .sortedBy { it.first } - .map { it.second } - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/events/CommandPipeline.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/events/CommandPipeline.kt deleted file mode 100644 index 00b1a3f..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/events/CommandPipeline.kt +++ /dev/null @@ -1,1291 +0,0 @@ -package com.hoshikurama.github.ticketmanager.events - -import com.hoshikurama.github.ticketmanager.* -import com.hoshikurama.github.ticketmanager.databases.Database -import com.hoshikurama.github.ticketmanager.ticket.* -import net.md_5.bungee.api.ChatColor -import net.md_5.bungee.api.chat.ClickEvent -import net.md_5.bungee.api.chat.HoverEvent -import net.md_5.bungee.api.chat.TextComponent -import net.md_5.bungee.api.chat.hover.content.Text -import org.bukkit.Bukkit -import org.bukkit.command.Command -import org.bukkit.command.CommandExecutor -import org.bukkit.command.CommandSender -import org.bukkit.entity.Player -import java.util.* - -class Commands : CommandExecutor { - override fun onCommand(sender: CommandSender, command: Command, label: String, args1: Array): Boolean { - val senderLocale = getLocale(sender) - val args = args1.toList() - - if (args.isEmpty()) { - sender.sendMessage(senderLocale.warningsInvalidCommand) - return false - } - - if (anyLocksPresent()) { - sender.sendMessage(senderLocale.warningsLocked) - return false - } - - // Attempts to grab ticket and filters out invalid number for ticket - val attemptTicket = getTicketHandler(args, senderLocale) - val pseudoTicket: TicketHandler - if (attemptTicket == null) { - senderLocale.warningsInvalidNumber.sendColouredMessageTo(sender) - return false - } else pseudoTicket = attemptTicket - - if (args.isEmpty()) { - help(sender, senderLocale) - return false - } - - // Shortened Functions - val hasValidPermission = { hasValidPermission(sender, pseudoTicket, args, senderLocale) } - val isValidCommand = { isValidCommand(sender, pseudoTicket, args, senderLocale) } - val notUnderCooldown = { notUnderCooldown(sender, senderLocale, args) } - val executeCommand = { executeCommand(sender, args, senderLocale, pseudoTicket) } - - // Main Bukkit Task - Bukkit.getScheduler().runTaskAsynchronously(mainPlugin, Runnable { - try { - if (notUnderCooldown() && isValidCommand() && hasValidPermission()) - executeCommand()?.let { pushNotifications(sender, it, senderLocale, pseudoTicket) } - } catch (e: Exception) { - e.printStackTrace() - postModifiedStacktrace(e) - sender.sendMessage(senderLocale.warningsUnexpectedError) - } - }) - - return false - } - - private fun getTicketHandler(args: List, senderLocale: TMLocale): TicketHandler? { - val id: Int? = when (args[0]) { - senderLocale.commandWordAssign, - senderLocale.commandWordSilentAssign, - senderLocale.commandWordClaim, - senderLocale.commandWordSilentClaim, - senderLocale.commandWordClose, - senderLocale.commandWordSilentClose, - senderLocale.commandWordComment, - senderLocale.commandWordSilentComment, - senderLocale.commandWordReopen, - senderLocale.commandWordSilentReopen, - senderLocale.commandWordSetPriority, - senderLocale.commandWordSilentSetPriority, - senderLocale.commandWordTeleport, - senderLocale.commandWordUnassign, - senderLocale.commandWordSilentUnassign, - senderLocale.commandWordView, - senderLocale.commandWordDeepView -> args.getOrNull(1)?.toIntOrNull() - else -> 0 - } - - return id?.let(::TicketHandler) - } - - private fun hasValidPermission( - sender: CommandSender, - ticketHandler: TicketHandler, - args: List, - senderLocale: TMLocale - ): Boolean { - if (sender !is Player) return true - - fun has(perm: String) = sender.has(perm) - fun hasSilent() = has("ticketmanager.commandArg.silence") - fun hasDuality(basePerm: String): Boolean { - val senderUUID = sender.toUUIDOrNull() - val ownsTicket = ticketHandler.UUIDMatches(senderUUID) - return has("$basePerm.all") || (sender.has("$basePerm.own") && ownsTicket) - } - - return senderLocale.run { - when (args[0]) { - commandWordAssign, commandWordClaim, commandWordUnassign -> - has("ticketmanager.command.assign") - commandWordSilentAssign, commandWordSilentClaim,commandWordSilentUnassign -> - has("ticketmanager.command.assign") && hasSilent() - commandWordClose -> hasDuality("ticketmanager.command.close") - commandWordSilentClose -> hasDuality("ticketmanager.command.close") && hasSilent() - commandWordCloseAll -> has("ticketmanager.command.closeAll") - commandWordSilentCloseAll -> has("ticketmanager.command.closeAll") && hasSilent() - commandWordComment -> hasDuality("ticketmanager.command.comment") - commandWordSilentComment -> hasDuality("ticketmanager.command.comment") && hasSilent() - commandWordCreate -> has("ticketmanager.command.create") - commandWordHelp -> has("ticketmanager.command.help") - commandWordReload -> has("ticketmanager.command.reload") - commandWordList -> has("ticketmanager.command.list") - commandWordListAssigned -> has("ticketmanager.command.list") - commandWordReopen -> has("ticketmanager.command.reopen") - commandWordSilentReopen -> has("ticketmanager.command.reopen") && hasSilent() - commandWordSearch -> has("ticketmanager.command.search") - commandWordSetPriority -> has("ticketmanager.command.setPriority") - commandWordSilentSetPriority -> has("ticketmanager.command.setPriority") && hasSilent() - commandWordTeleport -> has("ticketmanager.command.teleport") - commandWordView -> hasDuality("ticketmanager.command.view") - commandWordDeepView -> hasDuality("ticketmanager.command.viewdeep") - commandWordConvertDB -> has("ticketmanager.command.convertDatabase") - commandWordHistory -> - sender.has("ticketmanager.command.history.all") || - sender.has("ticketmanager.command.history.own").let { hasPerm -> - if (args.size >= 2) hasPerm && args[1] == sender.name - else hasPerm - } - else -> true - } - } - .also { if (!it) senderLocale.warningsNoPermission.sendColouredMessageTo(sender) } - } - - private fun isValidCommand( - sender: CommandSender, - ticketHandler: TicketHandler, - args: List, - senderLocale: TMLocale - ): Boolean { - val dbRef = pluginState.database - fun invalidCommand() = senderLocale.warningsInvalidCommand.sendColouredMessageTo(sender) - fun notANumber() = senderLocale.warningsInvalidNumber.sendColouredMessageTo(sender) - fun notATicket() = senderLocale.warningsInvalidID.sendColouredMessageTo(sender) - fun outOfBounds() = senderLocale.warningsPriorityOutOfBounds.sendColouredMessageTo(sender) - fun ticketClosed() = senderLocale.warningsTicketAlreadyClosed.sendColouredMessageTo(sender) - fun ticketOpen() = senderLocale.warningsTicketAlreadyOpen.sendColouredMessageTo(sender) - - return senderLocale.run { - when (args[0]) { - commandWordAssign, commandWordSilentAssign -> - check(::invalidCommand) { args.size >= 3 } - .thenCheck(::notATicket) { dbRef.isValidID(args[1].toInt()) } - .thenCheck(::ticketClosed) { ticketHandler.status != Ticket.Status.CLOSED } - - commandWordClaim, commandWordSilentClaim -> - check(::invalidCommand) { args.size == 2 } - .thenCheck(::notATicket) { pluginState.database.isValidID(args[1].toInt()) } - .thenCheck(::ticketClosed) { ticketHandler.status != Ticket.Status.CLOSED } - - commandWordClose, commandWordSilentClose -> - check(::invalidCommand) { args.size >= 2 } - .thenCheck(::notATicket) { pluginState.database.isValidID(args[1].toInt()) } - .thenCheck(::ticketClosed) { ticketHandler.status != Ticket.Status.CLOSED } - - commandWordComment, commandWordSilentComment -> - check(::invalidCommand) { args.size >= 3 } - .thenCheck(::notATicket) { pluginState.database.isValidID(args[1].toInt()) } - .thenCheck(::ticketClosed) { ticketHandler.status != Ticket.Status.CLOSED } - - commandWordCloseAll, commandWordSilentCloseAll -> - check(::invalidCommand) { args.size == 3 } - .thenCheck(::notANumber) { args[1].toIntOrNull() != null } - .thenCheck(::notANumber) { args[2].toIntOrNull() != null } - - commandWordReopen, commandWordSilentReopen -> - check(::invalidCommand) { args.size == 2 } - .thenCheck(::notATicket) { pluginState.database.isValidID(args[1].toInt()) } - .thenCheck(::ticketOpen) { ticketHandler.status != Ticket.Status.OPEN } - - commandWordSetPriority, commandWordSilentSetPriority -> - check(::invalidCommand) { args.size == 3 } - .thenCheck(::outOfBounds) { args[2].toByteOrNull() != null } - .thenCheck(::notATicket) { pluginState.database.isValidID(args[1].toInt()) } - .thenCheck(::ticketClosed) { ticketHandler.status != Ticket.Status.CLOSED } - - commandWordUnassign, commandWordSilentUnassign -> - check(::invalidCommand) { args.size == 2 } - .thenCheck(::notATicket) { pluginState.database.isValidID(args[1].toInt()) } - .thenCheck(::ticketClosed) { ticketHandler.status != Ticket.Status.CLOSED } - - commandWordView -> - check(::invalidCommand) { args.size == 2 } - .thenCheck(::notATicket) { pluginState.database.isValidID(args[1].toInt()) } - - commandWordDeepView -> - check(::invalidCommand) { args.size == 2 } - .thenCheck(::notATicket) { pluginState.database.isValidID(args[1].toInt()) } - - commandWordTeleport -> - check(::invalidCommand) { args.size == 2 } - .thenCheck(::notATicket) { pluginState.database.isValidID(args[1].toInt()) } - - commandWordCreate -> check(::invalidCommand) { args.size >= 2 } - - commandWordHistory -> - check(::invalidCommand) { args.isNotEmpty() } - .thenCheck(::notANumber) { if (args.size >= 3) args[2].toIntOrNull() != null else true} - - commandWordList -> - check(::notANumber) { if (args.size == 2) args[1].toIntOrNull() != null else true } - - commandWordListAssigned -> - check(::notANumber) { if (args.size == 2) args[1].toIntOrNull() != null else true } - - commandWordSearch -> check(::invalidCommand) { args.size >= 2} - - commandWordReload -> true - commandWordVersion -> true - commandWordHelp -> true - - commandWordConvertDB -> - check(::invalidCommand) { args.size == 2 } - .thenCheck( { senderLocale.warningsInvalidDBType.sendColouredMessageTo(sender) }, - { - try { Database.Types.valueOf(args[1]); true } - catch (e: Exception) { false } - } - ) - .thenCheck( { senderLocale.warningsConvertToSameDBType.sendColouredMessageTo(sender) } ) - { pluginState.database.type != Database.Types.valueOf(args[1]) } - - else -> false.also { invalidCommand() } - } - } - } - - private fun notUnderCooldown( - sender: CommandSender, - senderLocale: TMLocale, - args: List - ): Boolean { - val underCooldown = when (args[0]) { - senderLocale.commandWordCreate, - senderLocale.commandWordComment, - senderLocale.commandWordSilentComment -> - pluginState.cooldowns.checkAndSet(sender.toUUIDOrNull()) - else -> false - } - - return !underCooldown.also { if (it) senderLocale.warningsUnderCooldown.sendColouredMessageTo(sender) } - } - - private fun executeCommand( - sender: CommandSender, - args: List, - senderLocale: TMLocale, - ticketHandler: TicketHandler - ): NotifyParams? { - return senderLocale.run { - when (args[0]) { - commandWordAssign -> assign(sender, args, false, senderLocale, ticketHandler) - commandWordSilentAssign -> assign(sender, args, true, senderLocale, ticketHandler) - commandWordClaim -> claim(sender, args, false, senderLocale, ticketHandler) - commandWordSilentClaim -> claim(sender, args, true, senderLocale, ticketHandler) - commandWordClose -> close(sender, args, false, ticketHandler) - commandWordSilentClose -> close(sender, args, true, ticketHandler) - commandWordCloseAll -> closeAll(sender, args, false) - commandWordSilentCloseAll -> closeAll(sender, args, true) - commandWordComment -> comment(sender, args, false, ticketHandler) - commandWordSilentComment -> comment(sender, args, true, ticketHandler) - commandWordCreate -> create(sender, args) - commandWordHelp -> help(sender, senderLocale).let { null } - commandWordHistory -> history(sender, args, senderLocale).let { null } - commandWordList -> list(sender, args, senderLocale).let { null } - commandWordListAssigned -> listAssigned(sender, args, senderLocale).let { null } - commandWordReload -> reload(sender, senderLocale).let { null } - commandWordReopen -> reopen(sender,args, false, ticketHandler) - commandWordSilentReopen -> reopen(sender,args, true, ticketHandler) - commandWordSearch -> search(sender, args, senderLocale).let { null } - commandWordSetPriority -> setPriority(sender, args, false, ticketHandler) - commandWordSilentSetPriority -> setPriority(sender, args, true, ticketHandler) - commandWordTeleport -> teleport(sender, ticketHandler).let { null } - commandWordUnassign -> unAssign(sender, args, false, senderLocale, ticketHandler) - commandWordSilentUnassign -> unAssign(sender, args, true, senderLocale, ticketHandler) - commandWordVersion -> version(sender, senderLocale).let { null } - commandWordView -> view(sender, senderLocale, ticketHandler).let { null } - commandWordDeepView -> viewDeep(sender, senderLocale, ticketHandler).let { null } - commandWordConvertDB -> convertDatabase(args).let { null } - else -> null - } - } - } - - private fun pushNotifications( - sender: CommandSender, - params: NotifyParams, - locale: TMLocale, - ticketHandler: TicketHandler - ) { - params.run { - if (sendSenderMSG) - senderLambda!!.invoke(locale).sendColouredMessageTo(sender) - - if (sendCreatorMSG) - ticketHandler.creatorUUID - ?.run(Bukkit::getPlayer) - ?.let { creatorLambda!!(getLocale(it)) } - ?.run { sendColouredMessageTo(creator!!) } - - if (sendMassNotifyMSG) - pushMassNotify(massNotifyPerm, massNotifyLambda!!) - } - } - - private class NotifyParams( - silent: Boolean, - ticketHandler: TicketHandler, - sender: CommandSender, - creatorAlertPerm: String, - val massNotifyPerm: String, - val senderLambda: ((TMLocale) -> String)?, - val creatorLambda: ((TMLocale) -> String)?, - val massNotifyLambda: ((TMLocale) -> String)?, - ) { - val creator: Player? = ticketHandler.creatorUUID?.let(Bukkit::getPlayer) - val sendSenderMSG: Boolean = (!sender.has(massNotifyPerm) || silent) - && senderLambda != null - val sendCreatorMSG: Boolean = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) - && !silent && (creator?.isOnline ?: false) - && (creator?.has(creatorAlertPerm) ?: false) - && (creator?.has(massNotifyPerm)?.run { !this } ?: false) - && creatorLambda != null - val sendMassNotifyMSG: Boolean = !silent - && massNotifyLambda != null - } - - /*-------------------------*/ - /* Commands */ - /*-------------------------*/ - - // /ticket assign - - private fun allAssignVariations( - sender: CommandSender, - silent: Boolean, - senderLocale: TMLocale, - assignmentID: String, - assignment: String?, - ticketHandler: TicketHandler - ): NotifyParams { - val shownAssignment = assignment ?: senderLocale.miscNobody - - pluginState.database.run { - setAssignment(ticketHandler.id, assignment) - addAction(ticketHandler.id, Ticket.Action(Ticket.Action.Type.ASSIGN, sender.toUUIDOrNull(), assignment)) - } - - return NotifyParams( - silent = silent, - sender = sender, - ticketHandler = ticketHandler, - senderLambda = { - it.notifyTicketAssignSuccess - .replace("%id%", assignmentID) - .replace("%assign%", shownAssignment) - }, - massNotifyLambda = { - it.notifyTicketAssignEvent - .replace("%user%", sender.name) - .replace("%id%", assignmentID) - .replace("%assign%", shownAssignment) - }, - creatorLambda = null, - creatorAlertPerm = "ticketmanager.notify.change.assign", - massNotifyPerm = "ticketmanager.notify.massNotify.assign" - ) - } - - private fun assign( - sender: CommandSender, - args: List, - silent: Boolean, - senderLocale: TMLocale, - ticketHandler: TicketHandler - ): NotifyParams { - val sqlAssignment = args.subList(2, args.size) - .joinToString(" ") - return allAssignVariations(sender, silent, senderLocale, args[1], sqlAssignment, ticketHandler) - } - - // /ticket claim - private fun claim( - sender: CommandSender, - args: List, - silent: Boolean, - senderLocale: TMLocale, - ticketHandler: TicketHandler - ): NotifyParams { - return allAssignVariations(sender, silent, senderLocale, args[1], sender.name, ticketHandler) - } - - // /ticket close [Comment...] - private fun close( - sender: CommandSender, - args: List, - silent: Boolean, - ticketHandler: TicketHandler - ): NotifyParams { - ticketHandler.statusUpdateForCreator = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && pluginState.allowUnreadTicketUpdates - return if (args.size >= 3) - closeWithComment(sender, args, silent, ticketHandler) - else closeWithoutComment(sender, args, silent, ticketHandler) - } - - private fun closeWithComment( - sender: CommandSender, - args: List, - silent: Boolean, - ticketHandler: TicketHandler - ): NotifyParams { - val message = args.subList(2, args.size) - .joinToString(" ") - .run(::stripColour) - - pluginState.database.addAction( - ticketID = ticketHandler.id, - action = Ticket.Action(Ticket.Action.Type.COMMENT, sender.toUUIDOrNull(), message) - ) - - pluginState.database.addAction( - ticketID = ticketHandler.id, - action = Ticket.Action(Ticket.Action.Type.CLOSE, sender.toUUIDOrNull()) - ) - ticketHandler.status = Ticket.Status.CLOSED - - return NotifyParams( - silent = silent, - sender = sender, - ticketHandler = ticketHandler, - senderLambda = { - it.notifyTicketCloseWCommentSuccess - .replace("%id%", args[1]) - }, - massNotifyLambda = { - it.notifyTicketCloseWCommentEvent - .replace("%user%", sender.name) - .replace("%id%", args[1]) - .replace("%message%", message) - }, - creatorLambda = { - it.notifyTicketModificationEvent - .replace("%id%", args[1]) - }, - massNotifyPerm = "ticketmanager.notify.massNotify.close", - creatorAlertPerm = "ticketmanager.notify.change.close" - ) - } - - private fun closeWithoutComment( - sender: CommandSender, - args: List, - silent: Boolean, - ticketHandler: TicketHandler - ): NotifyParams { - pluginState.database.addAction( - ticketID = ticketHandler.id, - action = Ticket.Action(Ticket.Action.Type.CLOSE, sender.toUUIDOrNull()) - ) - ticketHandler.status = Ticket.Status.CLOSED - - return NotifyParams( - silent = silent, - sender = sender, - ticketHandler = ticketHandler, - creatorLambda = { - it.notifyTicketModificationEvent - .replace("%id%", args[1]) - }, - massNotifyLambda = { - it.notifyTicketCloseEvent // %user% %id% - .replace("%user%", sender.name) - .replace("%id%", args[1]) - }, - senderLambda = { - it.notifyTicketCloseSuccess - .replace("%id%", args[1]) - }, - massNotifyPerm = "ticketmanager.notify.massNotify.close", - creatorAlertPerm = "ticketmanager.notify.change.close" - ) - } - - // /ticket closeall - private fun closeAll( - sender: CommandSender, - args: List, - silent: Boolean - ): NotifyParams { - val lowerBound = args[1].toInt() - val upperBound = args[2].toInt() - - pluginState.database.massCloseTickets(lowerBound, upperBound, sender.toUUIDOrNull()) - - return NotifyParams( - silent = silent, - sender = sender, - ticketHandler = TicketHandler(-1), - creatorLambda = null, - senderLambda = { - it.notifyTicketMassCloseSuccess - .replace("%low%", args[1]) - .replace("%high%", args[2]) - }, - massNotifyLambda = { - it.notifyTicketMassCloseEvent - .replace("%user%", sender.name) - .replace("%low%", args[1]) - .replace("%high%", args[2]) - }, - massNotifyPerm = "ticketmanager.notify.massNotify.massClose", - creatorAlertPerm = "ticketmanager.notify.change.massClose" - ) - } - - // /ticket comment - private fun comment( - sender: CommandSender, - args: List, - silent: Boolean, - ticketHandler: TicketHandler - ): NotifyParams { - val message = args.subList(2, args.size) - .joinToString(" ") - .run(::stripColour) - val nonCreatorMadeChange = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) - - pluginState.database.addAction( - ticketID = ticketHandler.id, - action = Ticket.Action(Ticket.Action.Type.COMMENT, sender.toUUIDOrNull(), message) - ) - - ticketHandler.statusUpdateForCreator = nonCreatorMadeChange && pluginState.allowUnreadTicketUpdates - - return NotifyParams( - silent = silent, - sender = sender, - ticketHandler = ticketHandler, - creatorLambda = { - it.notifyTicketModificationEvent - .replace("%id%", args[1]) - }, - senderLambda = { - it.notifyTicketCommentSuccess - .replace("%id%", args[1]) - }, - massNotifyLambda = { - it.notifyTicketCommentEvent - .replace("%user%", sender.name) - .replace("%id%", args[1]) - .replace("%message%", message) - }, - massNotifyPerm = "ticketmanager.notify.massNotify.comment", - creatorAlertPerm = "ticketmanager.notify.change.comment" - ) - } - - // /ticket convertdatabase - private fun convertDatabase( - args: List, - ) { - val type = args[1].run(Database.Types::valueOf) - pluginState.database.migrateDatabase(type) - } - - // /ticket create - private fun create( - sender: CommandSender, - args: List - ): NotifyParams { - val message = args.subList(1, args.size) - .joinToString(" ") - .run(::stripColour) - val action = Ticket.Action(Ticket.Action.Type.OPEN, sender.toUUIDOrNull(), message) - val ticket = Ticket(sender.toUUIDOrNull(), sender.toLocationOrNull()) - val id = pluginState.database.addTicket(ticket, action).toString() - - mainPlugin.ticketCountMetrics.incrementAndGet() - return NotifyParams( - silent = false, - sender = sender, - ticketHandler = TicketHandler(-1), - creatorLambda = null, - senderLambda = { - it.notifyTicketCreationSuccess - .replace("%id%", id) - }, - massNotifyLambda = { - it.notifyTicketCreationEvent - .replace("%user%", sender.name) - .replace("%id%", id) - .replace("%message%", message) - }, - creatorAlertPerm = "ticketmanager.NO NODE", - massNotifyPerm = "ticketmanager.notify.massNotify.create", - ) - } - - // /ticket help - private fun help(sender: CommandSender, locale: TMLocale) { - val hasSilentPerm = sender.has("ticketmanager.commandArg.silence") - val cc = pluginState.enabledLocales.colourCode - - // Builds base header - val sentComponent = TextComponent(locale.helpHeader) - sentComponent.addExtra(locale.helpLine1) - if (hasSilentPerm) { - sentComponent.addExtra(locale.helpLine2) - sentComponent.addExtra(locale.helpLine3) - } - sentComponent.addExtra(locale.helpSep) - - locale.run { - listOf( // Triple(silence-able, format, permissions) - Triple(true, "$commandWordAssign &f<$parameterID> <$parameterAssignment...>", listOf("ticketmanager.command.assign")), - Triple(true, "$commandWordClaim &f<$parameterID>", listOf("ticketmanager.command.claim")), - Triple(true, "$commandWordClose &f<$parameterID> &7[$parameterComment...]", listOf("ticketmanager.command.close.all", "ticketmanager.command.close.own")), - Triple(true, "$commandWordCloseAll &f<$parameterLowerID> <$parameterUpperID>", listOf("ticketmanager.command.closeAll")), - Triple(true, "$commandWordComment &f<$parameterID> <$parameterComment...>", listOf("ticketmanager.command.comment.all", "ticketmanager.command.comment.own")), - Triple(false, "$commandWordConvertDB &f<$parameterTargetDB>", listOf("ticketmanager.command.convertDatabase")), - Triple(false, "$commandWordCreate &f<$parameterComment...>", listOf("ticketmanager.command.create")), - Triple(false, commandWordHelp, listOf("ticketmanager.command.help")), - Triple(false, "$commandWordHistory &7[$parameterUser] [$parameterPage]", listOf("ticketmanager.command.history.all", "ticketmanager.command.history.own")), - Triple(false, "$commandWordList &7[$parameterPage]", listOf("ticketmanager.command.list")), - Triple(false, "$commandWordListAssigned &7[$parameterPage]", listOf("ticketmanager.command.list")), - Triple(false, commandWordReload, listOf("ticketmanager.command.reload")), - Triple(true, "$commandWordReopen &f<$parameterID>", listOf("ticketmanager.command.reopen")), - Triple(false, "$commandWordSearch &f<$parameterConstraints...>", listOf("ticketmanager.command.search")), - Triple(true, "$commandWordSetPriority &f<$parameterID> <$parameterLevel>", listOf("ticketmanager.command.setPriority")), - Triple(false, "$commandWordTeleport &f<$parameterID>", listOf("ticketmanager.command.teleport")), - Triple(true, "$commandWordUnassign &f<$parameterID>", listOf("ticketmanager.command.assign")), - Triple(false, "$commandWordView &f<$parameterID>", listOf("ticketmanager.command.view.all", "ticketmanager.command.view.own")), - Triple(false, "$commandWordDeepView &f<$parameterID>", listOf("ticketmanager.command.viewdeep.all", "ticketmanager.command.viewdeep.own")) - ) - } - .filter { it.third.any(sender::has) } - .run { this + Triple(false, locale.commandWordVersion, "NA") } - .map { - val commandString = "$cc/${locale.commandBase} ${it.second}" - if (hasSilentPerm) - if (it.first) "\n&a[✓] $commandString" - else "\n&c[✕] $commandString" - else "\n$commandString" - } - .map(::toColour) - .map(::TextComponent) - .forEach { sentComponent.addExtra(it) } - - sender.sendPlatformMessage(sentComponent) - } - - // /ticket history [User] [Page] - private fun history( - sender: CommandSender, - args: List, - locale: TMLocale - ) { - val targetName = if (args.size >= 2) args[1].takeIf { it != locale.consoleName } else sender.name.takeIf { sender is Player } - val requestedPage = if (args.size >= 3) args[2].toInt() else 1 - - // Leaves console as null. Otherwise attempts UUID grab or [PLAYERNOTFOUND] - fun String.attemptToUUIDString(): String? = - if (equals(locale.consoleName)) null - else Bukkit.getOfflinePlayers().asSequence() - .firstOrNull { equals(it.name) } - ?.run { uniqueId.toString() } - ?: "[PLAYERNOTFOUND]" - - - val searchedUser = targetName?.attemptToUUIDString() - - val resultSize: Int - val resultsChunked = pluginState.database.searchDatabase { it.creatorUUID?.toString() == searchedUser } - .sortedByDescending(Ticket::id) - .also { resultSize = it.size } - .chunked(6) - - val sentComponent = locale.historyHeader - .replace("%name%", targetName ?: locale.consoleName) - .replace("%count%", "$resultSize") - .run(::toColour) - .run(::TextComponent) - - val actualPage = if (requestedPage >= 1 && requestedPage < resultsChunked.size) requestedPage else 1 - - if (resultsChunked.isNotEmpty()) { - resultsChunked.getOrElse(requestedPage - 1) { resultsChunked[1] } - .forEach { - locale.historyEntry - .run { "\n$this" } - .replace("%id%", "${it.id}") - .replace("%SCC%", it.status.getColourCode()) - .replace("%status%", it.status.toColouredString(locale)) - .replace("%comment%", it.actions[0].message!! - .run { if (length > 80) "${substring(0, 81)}..." else this } ) - .run(::toColour) - .run(::TextComponent) - .apply { addViewTicketOnClick(it.id, locale) } - .apply { sentComponent.addExtra(this) } - } - - if (resultsChunked.size > 1) { - buildPageComponent(actualPage, resultsChunked.size, locale) { "/${it.commandBase} ${it.commandWordHistory} ${targetName ?: it.consoleName} " } - .apply(sentComponent::addExtra) - } - } - - sender.sendPlatformMessage(sentComponent) - } - - // /ticket list [Page] - private fun list( - sender: CommandSender, - args: List, - locale: TMLocale - ) { - val sentMSG = createGeneralList(args, locale, locale.listFormatHeader, - getTickets = { db -> db.getOpen() }, - baseCommand = locale.run{ { "/$commandBase $commandWordList " } } - ) - - sender.sendPlatformMessage(sentMSG) - } - - private fun createGeneralList( - args: List, - locale: TMLocale, - headerFormat: String, - getTickets: (Database) -> List, - baseCommand: (TMLocale) -> String - ): TextComponent { - val chunkedTickets = getTickets(pluginState.database).chunked(8) - val page = if (args.size == 2 && args[1].toInt() in 1..chunkedTickets.size) args[1].toInt() else 1 - - val sentMSG = headerFormat - .run(::toColour) - .run(::TextComponent) - - if (chunkedTickets.isNotEmpty()) { - chunkedTickets[page - 1] - .map { createListEntry(it, locale) } - .forEach { sentMSG.addExtra(it) } - } - - if (chunkedTickets.size > 1) { - sentMSG.addExtra(buildPageComponent(page, chunkedTickets.size, locale, baseCommand)) - } - - return sentMSG - } - - private fun buildPageComponent( - curPage: Int, - pageCount: Int, - locale: TMLocale, - baseCommand: (TMLocale) -> String, - ): TextComponent { - fun TextComponent.addForward() { - this.color = ChatColor.WHITE - this.clickEvent = ClickEvent(ClickEvent.Action.RUN_COMMAND, baseCommand(locale) + "${curPage + 1}") - this.hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickNextPage)) - } - fun TextComponent.addBack() { - this.color = ChatColor.WHITE - this.clickEvent = ClickEvent(ClickEvent.Action.RUN_COMMAND, baseCommand(locale) + "${curPage - 1}") - this.hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickBackPage)) - } - - val back = TextComponent("[${locale.pageBack}]") - val next = TextComponent("[${locale.pageNext}]") - - val separator = TextComponent("...............") - separator.color = ChatColor.DARK_GRAY - - val cc = pluginState.enabledLocales.colourCode - val ofSection = "$cc($curPage${locale.pageOf}$pageCount)" - .run(::toColour) - .run(::TextComponent) - - when (curPage) { - 1 -> { - back.color = ChatColor.DARK_GRAY - next.addForward() - } - pageCount -> { - back.addBack() - next.color = ChatColor.DARK_GRAY - } - else -> { - back.addBack() - next.addForward() - } - } - - val textComponent = TextComponent("\n") - textComponent.apply { - addExtra(back) - addExtra(separator) - addExtra(ofSection) - addExtra(separator) - addExtra(next) - } - - return textComponent - } - - - private fun createListEntry(ticket: Ticket, locale: TMLocale): TextComponent { - val creator = ticket.creatorUUID.toName(locale) - val fixedAssign = ticket.assignedTo ?: "" - - // Shortens comment preview to fit on one line - val fixedComment = ticket.run { - if (12 + id.toString().length + creator.length + fixedAssign.length + actions[0].message!!.length > 58) - actions[0].message!!.substring(0, 43 - id.toString().length - fixedAssign.length - creator.length) + "..." - else actions[0].message!! - } - - return locale.listFormatEntry - .run { "\n$this" } - .replace("%priorityCC%", ticket.priority.getColourCode()) - .replace("%ID%", "${ticket.id}") - .replace("%creator%", creator) - .replace("%assign%", fixedAssign) - .replace("%comment%", fixedComment) - .run(::toColour) - .run(::TextComponent) - .apply { addViewTicketOnClick(ticket.id, locale) } - } - - // /ticket listassigned [Page] - private fun listAssigned( - sender: CommandSender, - args: List, - locale: TMLocale - ) { - val groups = if (sender is Player) - mainPlugin.perms.getPlayerGroups(sender).map { "::$it" } - else listOf() - - val sentMSG = createGeneralList(args, locale, locale.listFormatAssignedHeader, - getTickets = { db -> db.getOpenAssigned(sender.name, groups ) }, - baseCommand = locale.run { { "/$commandBase $commandWordListAssigned " } } - ) - - sender.sendPlatformMessage(sentMSG) - } - - // /ticket reload - private fun reload(sender: CommandSender, locale: TMLocale) { - mainPlugin.pluginLocked = true - pushMassNotify("ticketmanager.notify.info", { it.informationReloadInitiated.replace("%user%", sender.name) } ) - - // Forces async thread to wait for other TicketManager tasks to complete - while (Bukkit.getScheduler().pendingTasks.filter { it.owner == mainPlugin }.size > 2) - Thread.sleep(1000) - - pushMassNotify("ticketmanager.notify.info", { it.informationReloadTasksDone } ) - pluginState.database.closeDatabase() - mainPlugin.configState = PluginState() - - pushMassNotify("ticketmanager.notify.info", { it.informationReloadSuccess }) - if (!sender.has("ticketmanager.notify.info")) - locale.informationReloadSuccess.sendColouredMessageTo(sender) - - mainPlugin.pluginLocked = false - } - - // /ticket reopen - private fun reopen( - sender: CommandSender, - args: List, - silent: Boolean, - ticketHandler: TicketHandler - ): NotifyParams { - val action = Ticket.Action(Ticket.Action.Type.REOPEN, sender.toUUIDOrNull()) - - ticketHandler.statusUpdateForCreator = sender.nonCreatorMadeChange(ticketHandler.creatorUUID) && pluginState.allowUnreadTicketUpdates - pluginState.database.addAction(ticketHandler.id, action) - ticketHandler.status = Ticket.Status.OPEN - - return NotifyParams( - silent = silent, - sender = sender, - ticketHandler = ticketHandler, - creatorLambda = { - it.notifyTicketModificationEvent - .replace("%id%", args[1]) - }, - senderLambda = { - it.notifyTicketReopenSuccess - .replace("%id%", args[1]) - }, - massNotifyLambda = { - it.notifyTicketReopenEvent - .replace("%user%", sender.name) - .replace("%id%", args[1]) - }, - creatorAlertPerm = "ticketmanager.notify.change.reopen", - massNotifyPerm = "ticketmanager.notify.massNotify.reopen", - ) - } - - // /ticket search - private fun search( - sender: CommandSender, - args: List, - locale: TMLocale - ) { - fun String.attemptToUUIDString(): String? = - if (equals(locale.consoleName)) null - else Bukkit.getOfflinePlayers().asSequence() - .firstOrNull { equals(it.name) } - ?.run { uniqueId.toString() } - ?: "[PLAYERNOTFOUND]" - - - // Beginning of code execution - sender.sendMessage(locale.searchFormatQuerying) - val constraintTypes = locale.run { - listOf( - searchAssigned, - searchCreator, - searchKeywords, - searchPriority, - searchStatus, - searchTime, - searchWorld, - searchPage, - searchClosedBy, - searchLastClosedBy, - ) - } - - val localedConstraintMap = args.subList(1, args.size) - .asSequence() - .map { it.split(":", limit = 2) } - .filter { it[0] in constraintTypes } - .filter { it.size >= 2 } - .associate { it[0] to it[1] } - - val searchFunctions = localedConstraintMap - .mapNotNull { entry -> - when (entry.key) { - locale.searchWorld -> { t: Ticket -> t.location?.world?.equals(entry.value) ?: false } - locale.searchAssigned -> { t: Ticket -> t.assignedTo == entry.value } - - locale.searchCreator -> { - val searchedUser = entry.value.attemptToUUIDString(); - { t: Ticket -> t.creatorUUID?.toString() == searchedUser } - } - - locale.searchPriority -> { - val searchedPriority = entry.value.toByteOrNull() ?: 0; - { t: Ticket -> t.priority.level == searchedPriority } - } - - locale.searchTime -> { - val creationTime = relTimeToEpochSecond(entry.value, locale); - { t: Ticket -> t.actions[0].timestamp >= creationTime } - } - - locale.searchStatus -> { - val constraintStatus = when (entry.value) { - locale.statusOpen -> Ticket.Status.OPEN.name - locale.statusClosed -> Ticket.Status.CLOSED.name - else -> entry.value - } - { t: Ticket -> t.status.name == constraintStatus} - } - - locale.searchKeywords -> { - val words = entry.value.split(","); - - { t: Ticket -> - val comments = t.actions - .filter { it.type == Ticket.Action.Type.OPEN || it.type == Ticket.Action.Type.COMMENT } - .map { it.message!! } - words.map { w -> comments.any { it.contains(w) } } - .all { it } - } - } - - locale.searchLastClosedBy -> { - val searchedUser = entry.value.attemptToUUIDString(); - { t: Ticket -> - t.actions.lastOrNull { e -> e.type == Ticket.Action.Type.CLOSE } - ?.run { user?.toString() == searchedUser } - ?: false - } - - } - - locale.searchClosedBy -> { - val searchedUser = entry.value.attemptToUUIDString(); - { t: Ticket -> t.actions.any{ it.type == Ticket.Action.Type.CLOSE && it.user?.toString() == searchedUser } } - } - - else -> null - } - } - .asSequence() - - val composedSearch = { t: Ticket -> searchFunctions.map { it(t) }.all { it } } - - - // Results computation - val resultSize: Int - val chunkedTickets = pluginState.database.searchDatabase(composedSearch) - .sortedByDescending(Ticket::id) - .apply { resultSize = size } - .chunked(8) - - val page = localedConstraintMap[locale.searchPage]?.toIntOrNull() - .let { if (it != null && it >= 1 && it < chunkedTickets.size) it else 1 } - - // Initial header - val sentComponent = TextComponent(locale.searchFormatHeader - .replace("%size%", "$resultSize") - .run(::toColour) - ) - - // Function for fixing messageLength - val fixMSGLength = { t: Ticket -> t.actions[0].message!!.run { if (length > 25) "${substring(0,21)}..." else this } } - - // Adds entries - if (chunkedTickets.isNotEmpty()) { - chunkedTickets[page-1] - .map { - locale.searchFormatEntry - .run { "\n$this" } - .replace("%PCC%", it.priority.getColourCode()) - .replace("%SCC%", it.status.getColourCode()) - .replace("%id%", "${it.id}") - .replace("%status%", it.status.toColouredString(locale)) - .replace("%creator%", it.creatorUUID.toName(locale)) - .replace("%assign%", it.assignedTo ?: "") - .replace("%world%", it.location?.world ?: "") - .replace("%time%", it.actions[0].timestamp.toLargestRelativeTime(locale)) - .run(::toColour) - .replace("%comment%", fixMSGLength(it)) - .run(::TextComponent) - .apply { addViewTicketOnClick(it.id, locale) } - } - .forEach { sentComponent.addExtra(it) } - } - - // Implements pages if needed - if (chunkedTickets.size > 1) { - val pageComponent = buildPageComponent(page, chunkedTickets.size, locale) { - // Removes page constraint and converts rest to key:arg - val constraints = localedConstraintMap - .filter { it.key != locale.searchPage } - .map { (k,v) -> "$k:$v" } - "/${locale.commandBase} ${locale.commandWordSearch} $constraints ${locale.searchPage}:" - } - sentComponent.addExtra(pageComponent) - } - sender.sendPlatformMessage(sentComponent) - } - - // /ticket setpriority - private fun setPriority( - sender: CommandSender, - args: List, - silent: Boolean, - ticketHandler: TicketHandler - ): NotifyParams { - pluginState.database.addAction( - ticketID = ticketHandler.id, - action = Ticket.Action(Ticket.Action.Type.SET_PRIORITY, sender.toUUIDOrNull(), args[2]) - ) - ticketHandler.priority = byteToPriority(args[2].toByte()) - - return NotifyParams( - silent = silent, - sender = sender, - ticketHandler = ticketHandler, - creatorLambda = null, - senderLambda = { - it.notifyTicketSetPrioritySuccess - .replace("%id%", args[1]) - .replace("%priority%", ticketHandler.priority.toColouredString(it)) - }, - massNotifyLambda = { - it.notifyTicketSetPriorityEvent - .replace("%user%", sender.name) - .replace("%id%", args[1]) - .replace("%priority%", ticketHandler.priority.toColouredString(it)) - }, - creatorAlertPerm = "ticketmanager.notify.change.priority", - massNotifyPerm = "ticketmanager.notify.massNotify.priority" - ) - } - - // /ticket teleport - private fun teleport(sender: CommandSender, ticketHandler: TicketHandler) { - if (sender is Player) - ticketHandler.location?.let { - Bukkit.getScheduler().runTask(mainPlugin, - Runnable { sender.teleport(it) } - ) - } - } - - // /ticket unassign - private fun unAssign( - sender: CommandSender, - args: List, - silent: Boolean, - senderLocale: TMLocale, - ticketHandler: TicketHandler - ): NotifyParams { - return allAssignVariations(sender, silent, senderLocale, args[1], null, ticketHandler) - } - - // /ticket version - private fun version( - sender: CommandSender, - locale: TMLocale - ) { - val sentComponent = TextComponent("") - val components = listOf( - "&3===========================&r\n", - "&3&lTicketManager:&r&7 by HoshiKurama&r\n", - " &3GitHub Wiki: ", - "&7&nHERE&r\n", - " &3V4.0.0&r\n", - "&3===========================&r" - ) - .map(::toColour) - .map(::TextComponent) - - components[3].clickEvent = ClickEvent(ClickEvent.Action.OPEN_URL, locale.wikiLink) - components[3].hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickWiki)) - - components.forEach { sentComponent.addExtra(it) } - - sender.sendPlatformMessage(sentComponent) - } - - // /ticket view - private fun view( - sender: CommandSender, - locale: TMLocale, - ticketHandler: TicketHandler - ) { - val ticket = pluginState.database.getTicket(ticketHandler.id)!! - val message = buildTicketInfo(ticket, locale) - - if (!sender.nonCreatorMadeChange(ticket.creatorUUID)) - ticketHandler.statusUpdateForCreator = false - - ticket.actions.asSequence() - .filter { it.type == Ticket.Action.Type.COMMENT || it.type == Ticket.Action.Type.OPEN } - .map { locale.viewFormatComment - .run { "\n$this" } - .run(::toColour) - .replace("%user%", it.user.toName(locale)) - .replace("%comment%", it.message!!) - } - .map(::TextComponent) - .forEach { message.addExtra(it) } - - sender.sendPlatformMessage(message) - } - - private fun buildTicketInfo(ticket: Ticket, locale: TMLocale): TextComponent { - val message = TextComponent("") - - val textComponents = listOf( - locale.viewFormatHeader - .replace("%num%", "${ticket.id}"), - locale.viewFormatSep1, - locale.viewFormatInfo1 - .replace("%creator%", ticket.creatorUUID.toName(locale)) - .replace("%assignment%", ticket.assignedTo ?: ""), - locale.viewFormatInfo2 - .replace("%priority%", ticket.priority.toColouredString(locale)) - .replace("%status%", ticket.status.toColouredString(locale)), - locale.viewFormatInfo3 - .replace("%location%", ticket.location?.toString() ?: ""), - locale.viewFormatSep2 - ) - .map(::toColour) - .map { "\n$it" } - .map(::TextComponent) - - if (ticket.location != null) { - textComponents[4].hoverEvent = HoverEvent(HoverEvent.Action.SHOW_TEXT, Text(locale.clickTeleport)) - textComponents[4].clickEvent = ClickEvent(ClickEvent.Action.RUN_COMMAND, - locale.run { "/$commandBase $commandWordTeleport ${ticket.id}" }) - textComponents[4] - } - - textComponents.forEach { message.addExtra(it) } - return message - } - - // /ticket viewdeep - private fun viewDeep( - sender: CommandSender, - locale: TMLocale, - ticketHandler: TicketHandler - ) - { - val ticket = pluginState.database.getTicket(ticketHandler.id)!! - val sentMessage = buildTicketInfo(ticket, locale) - - if (!sender.nonCreatorMadeChange(ticket.creatorUUID)) - ticketHandler.statusUpdateForCreator = false - - fun Ticket.Action.formatDeepAction(): String { - val result = when (type) { - Ticket.Action.Type.OPEN, Ticket.Action.Type.COMMENT -> - locale.viewFormatDeepComment - .replace("%comment%", message!!) - - Ticket.Action.Type.SET_PRIORITY -> - locale.viewFormatDeepSetPriority - .replace("%priority%", byteToPriority(message!!.toByte()) - .toColouredString(locale)) - - Ticket.Action.Type.ASSIGN -> - locale.viewFormatDeepAssigned - .replace("%assign%", message ?: "") - - Ticket.Action.Type.REOPEN -> locale.viewFormatDeepReopen - Ticket.Action.Type.CLOSE -> locale.viewFormatDeepClose - Ticket.Action.Type.MASS_CLOSE -> locale.viewFormatDeepMassClose - } - return result - .replace("%user%", user.toName(locale)) - .replace("%time%", timestamp.toLargestRelativeTime(locale)) - } - - ticket.actions - .map { it.formatDeepAction() } - .map { "\n$it" } - .map(::toColour) - .map(::TextComponent) - .forEach { sentMessage.addExtra(it) } - - sender.sendPlatformMessage(sentMessage) - } -} - -/*-------------------------*/ -/* Other Functions */ -/*-------------------------*/ - -private inline fun check(error: () -> Unit, predicate: () -> Boolean): Boolean { - return if (predicate()) true else error().run { false } -} - -private inline fun Boolean.thenCheck(error: () -> Unit, predicate: () -> Boolean): Boolean { - return if (!this) false - else if (predicate()) true - else error().run { false } -} - -private fun CommandSender.toUUIDOrNull() = if (this is Player) this.uniqueId else null -private fun CommandSender.toLocationOrNull() = if (this is Player) Ticket.Location(this.location) else null - -private fun CommandSender.nonCreatorMadeChange(creatorUUID: UUID?): Boolean { - if (creatorUUID == null) return false - return this.toUUIDOrNull()?.notEquals(creatorUUID) ?: true -} - -fun T.notEquals(t: T) = this != t \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/events/PlayerJoin.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/events/PlayerJoin.kt deleted file mode 100644 index 8bd600e..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/events/PlayerJoin.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.hoshikurama.github.ticketmanager.events - -import com.hoshikurama.github.ticketmanager.* -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener -import org.bukkit.event.player.PlayerJoinEvent - -class PlayerJoin : Listener { - - @EventHandler - fun onPlayerJoin(event: PlayerJoinEvent) { - if (anyLocksPresent()) return - val player = event.player - - // Plugin Update checking - val pluginUpdateStatus = pluginState.updateAvailable.get() - if (player.has("ticketmanager.notify.pluginUpdate") && pluginUpdateStatus != null) { - if (pluginUpdateStatus.first.replace(".", "").toInt() < pluginUpdateStatus.second.replace(".", "").toInt()) { - getLocale(player).notifyPluginUpdate - .replace("%current%", pluginUpdateStatus.first) - .replace("%latest%", pluginUpdateStatus.second) - .run { sendColouredMessageTo(player) } - } - } - - // Unread Updates - if (player.has("ticketmanager.notify.unreadUpdates.onJoin")) { - pluginState.database.getTicketIDsWithUpdates(player.uniqueId) - .run { if (size == 0) null else this } - ?.run { - val template = - if (size == 1) getLocale(player).notifyUnreadUpdateSingle - else getLocale(player).notifyUnreadUpdateMulti - val tickets = this.joinToString(", ") - - template.replace("%num%", tickets) - .sendColouredMessageTo(player) - } - } - - // View Open-Count and Assigned-Count Tickets - if (player.has("ticketmanager.notify.openTickets.onJoin")) { - val open = pluginState.database.getOpen().size.toString() - val assigned = - pluginState.database.getOpenAssigned(player.name, mainPlugin.perms.getPlayerGroups(player).toList()) - .count().toString() - - getLocale(player).run { notifyOpenAssigned } - .replace("%open%", open) - .replace("%assigned%", assigned) - .sendColouredMessageTo(player) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/events/TabCompletion.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/events/TabCompletion.kt deleted file mode 100644 index 8441b18..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/events/TabCompletion.kt +++ /dev/null @@ -1,292 +0,0 @@ -package com.hoshikurama.github.ticketmanager.events - -import com.destroystokyo.paper.event.server.AsyncTabCompleteEvent -import com.hoshikurama.github.ticketmanager.TMLocale -import com.hoshikurama.github.ticketmanager.databases.Database -import com.hoshikurama.github.ticketmanager.getLocale -import com.hoshikurama.github.ticketmanager.has -import com.hoshikurama.github.ticketmanager.mainPlugin -import org.bukkit.Bukkit -import org.bukkit.World -import org.bukkit.command.Command -import org.bukkit.command.CommandSender -import org.bukkit.command.TabCompleter -import org.bukkit.entity.Player -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener - -class TabCompletePaper: Listener { - @EventHandler - fun onTabCompleteAsync(event: AsyncTabCompleteEvent) { - if (event.buffer.startsWith("/ticket ")) { - val args = event.buffer - .replace(" +".toRegex(), " ") - .split(" ") - .run { subList(1, this.size) } - - event.completions = tabCompleteEventFunction(event.sender, args)?.toMutableList() ?: mutableListOf("") - } - } -} - -class TabCompleteSpigot : TabCompleter { - override fun onTabComplete(sender: CommandSender, command: Command, alias: String, args: Array - ): MutableList? = tabCompleteEventFunction(sender, args.toList())?.toMutableList() -} - -private fun tabCompleteEventFunction(sender: CommandSender, args: List): List? { - if (!sender.has("ticketmanager.commandArg.autotab")) return null - val locale = getLocale(sender) - val perms = LazyPermissions(locale,sender) - - return locale.run { - if (args.size <= 1) return@run perms.getPermissiveCommands() - .filter { it.startsWith(args[0]) } - - when (args[0]) { - commandWordAssign, commandWordSilentAssign -> when { // /ticket assign - !perms.hasAssignVariation -> listOf("") - args.size == 2 -> listOf("<$parameterID>") - .filter { it.startsWith(args[1]) } - args.size == 3 -> { - val groups = mainPlugin.perms.groups.map { "::$it" } - (listOf("<$parameterAssignment...>") + offlinePlayerNames() + groups + listOf(locale.consoleName)) - .filter { it.startsWith(args[2]) } - } - else -> listOf("") - } - - commandWordClaim, commandWordSilentClaim, commandWordUnassign, commandWordSilentUnassign -> when { // /ticket claim - !perms.hasAssignVariation -> listOf("") - args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } - else -> listOf("") - } - - commandWordClose, commandWordSilentClose -> when { // /ticket close [Comment...] - !perms.hasClose -> listOf("") - args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } - else -> (listOf("[$parameterComment...]") + onlineSeenPlayers(sender)) - .filter { it.startsWith(args[args.lastIndex]) } - } - - commandWordCloseAll, commandWordSilentCloseAll -> when { // /ticket closeall - !perms.hasMassClose -> listOf("") - args.size == 2 -> listOf("<$parameterLowerID>").filter { it.startsWith(args[1]) } - args.size == 3 -> listOf("<$parameterUpperID>").filter { it.startsWith(args[2]) } - else -> listOf("") - } - - commandWordComment, commandWordSilentComment -> when { // /ticket comment - !perms.hasComment -> listOf("") - args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } - else -> (listOf("<$parameterComment...>") + onlineSeenPlayers(sender)) - .filter { it.startsWith(args[args.lastIndex]) } - } - - commandWordCreate -> when { // /ticket create - !perms.hasCreate -> listOf("") - else -> (listOf("<$parameterComment...>") + onlineSeenPlayers(sender)) - .filter { it.startsWith(args[args.lastIndex]) } - } - - commandWordHelp -> listOf("") - - commandWordHistory -> when { // /ticket history [User] [Page] - !perms.hasHistory -> listOf("") - args.size == 2 -> (listOf("[$parameterUser]", locale.consoleName) + offlinePlayerNames()) - .filter { it.startsWith(args[1]) } - args.size == 3 -> listOf("[$parameterPage]").filter { it.startsWith(args[2]) } - else -> listOf("") - } - - commandWordList, commandWordListAssigned -> when { // /ticket list(assigned) [Page] - !perms.hasListVariation -> listOf("") - args.size == 2 -> listOf("[$parameterPage]").filter { it.startsWith(args[1]) } - else -> listOf("") - } - - commandWordReopen, commandWordSilentReopen -> when { // /ticket reopen - !perms.hasReopen -> listOf("") - args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } - else -> listOf("") - } - - commandWordSetPriority, commandWordSilentSetPriority -> when { // /ticket setpriority - !perms.hasPriority -> listOf("") - args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } - args.size == 3 -> listOf("<$parameterLevel>", "1", "2", "3", "4", "5").filter { it.startsWith(args[2]) } - else -> listOf("") - } - - commandWordTeleport -> when { // /ticket teleport - !perms.hasTeleport -> listOf("") - args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } - else -> listOf("") - } - - commandWordVersion -> listOf("") - - commandWordView -> when { // /ticket view - !perms.hasView -> listOf("") - args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } - else -> listOf("") - } - - commandWordDeepView -> when { - !perms.hasDeepView -> listOf("") - args.size == 2 -> listOf("<$parameterID>").filter { it.startsWith(args[1]) } - else -> listOf("") - } - - commandWordReload -> listOf("") - - commandWordSearch -> { //ticket search keywords:separated,by,commas status:OPEN/CLOSED time:5w creator:creator priority:value assignedto:player world:world - if (!perms.hasSearch) return listOf("") - - val curArgument = args[args.lastIndex] - val splitArgs = curArgument.split(":", limit = 2) - - if (splitArgs.size < 2) - return locale.run { listOf( - "$searchAssigned:", - "$searchCreator:", - "$searchKeywords:", - "$searchPriority:", - "$searchStatus:", - "$searchWorld:", - "$searchClosedBy:", - "$searchLastClosedBy:", - ) } - .filter { it.startsWith(curArgument) } - - // String now has form "constraint:" - return when (splitArgs[0]) { - searchAssigned -> { - val groups = mainPlugin.perms.groups.map { "::$it" } - (offlinePlayerNames() + groups) - .filter { it.startsWith(splitArgs[1]) } - .map { "${splitArgs[0]}:$it" } - } - - searchCreator, searchLastClosedBy, searchClosedBy -> { - (offlinePlayerNames() + listOf(locale.consoleName)) - .filter { it.startsWith(splitArgs[1]) } - .map { "${splitArgs[0]}:$it" } - } - - searchPriority -> { - listOf("1", "2", "3", "4", "5") - .filter { it.startsWith(splitArgs[1]) } - .map { "${splitArgs[0]}:$it" } - } - - locale.searchStatus -> { - listOf(locale.statusOpen, locale.statusClosed) - .filter { it.startsWith(splitArgs[1]) } - .map { "${splitArgs[0]}:$it" } - } - - searchWorld -> { - Bukkit.getWorlds() - .map(World::getName) - .filter { it.startsWith(splitArgs[1]) } - .map { "${splitArgs[0]}:$it" } - } - - searchTime -> { - locale.run { listOf(searchTimeSecond, searchTimeMinute, searchTimeHour, searchTimeDay, searchTimeWeek, searchTimeYear) } - .filter { curArgument[curArgument.lastIndex].digitToIntOrNull() != null } - .map { "${splitArgs[0]}:$it" } - } - - searchKeywords -> listOf(curArgument) - - else -> listOf("") - } - } - - commandWordConvertDB -> when { // /ticket convertDatabase - !perms.hasConvertDB -> listOf("") - args.size == 2 -> - Database.Types.values() - .map(Database.Types::name) - .filter { it.startsWith(args[1]) } - else -> listOf("") - } - - else -> listOf("") - } - } -} - -fun onlineSeenPlayers(sender: CommandSender): List { - return if (sender is Player) - Bukkit.getOnlinePlayers() - .filter(sender::canSee) - .map { it.name } - else Bukkit.getOnlinePlayers() - .map { it.name } -} - -fun offlinePlayerNames() = Bukkit.getOfflinePlayers().mapNotNull { it.name }.toList() - -class LazyPermissions(private val locale: TMLocale, private val sender: CommandSender) { - val hasAssignVariation by lazy { sender.has("ticketmanager.command.assign") } - val hasCreate by lazy { sender.has("ticketmanager.command.create") } - val hasListVariation by lazy { sender.has("ticketmanager.command.list") } - val hasReopen by lazy { sender.has("ticketmanager.command.reopen") } - val hasSearch by lazy { sender.has("ticketmanager.command.search") } - val hasPriority by lazy { sender.has("ticketmanager.command.setPriority") } - val hasTeleport by lazy { sender.has("ticketmanager.command.teleport") } - val hasMassClose by lazy { sender.has("ticketmanager.command.closeAll") } - val hasConvertDB by lazy { sender.has("ticketmanager.command.convertDatabase") } - private val hasHelp by lazy { sender.has("ticketmanager.command.help") } - private val hasReload by lazy { sender.has("ticketmanager.command.reload") } - private val hasSilent by lazy { sender.has("ticketmanager.commandArg.silence") } - val hasClose by lazy { sender.has("ticketmanager.command.close.all") - || sender.has("ticketmanager.command.close.own") } - val hasComment by lazy { sender.has("ticketmanager.command.comment.all") - || sender.has("ticketmanager.command.comment.own") } - val hasView by lazy { sender.has("ticketmanager.command.view.all") - || sender.has("ticketmanager.command.view.own") } - val hasDeepView by lazy { sender.has("ticketmanager.command.viewdeep.all") - || sender.has("ticketmanager.command.viewdeep.own") } - val hasHistory by lazy { sender.has("ticketmanager.command.history.all") - || sender.has("ticketmanager.command.history.own") } - - - fun getPermissiveCommands(): List { - return mapOf( - locale.commandWordAssign to hasAssignVariation, - locale.commandWordSilentAssign to (hasAssignVariation && hasSilent), - locale.commandWordClaim to hasAssignVariation, - locale.commandWordSilentClaim to (hasAssignVariation && hasSilent), - locale.commandWordClose to hasClose, - locale.commandWordSilentClose to (hasClose && hasSilent), - locale.commandWordCloseAll to hasMassClose, - locale.commandWordSilentCloseAll to (hasMassClose && hasSilent), - locale.commandWordComment to hasComment, - locale.commandWordSilentComment to (hasComment && hasSilent), - locale.commandWordConvertDB to hasConvertDB, - locale.commandWordCreate to hasCreate, - locale.commandWordHelp to hasHelp, - locale.commandWordHistory to hasHistory, - locale.commandWordList to hasListVariation, - locale.commandWordListAssigned to hasListVariation, - locale.commandWordReload to hasReload, - locale.commandWordReopen to hasReopen, - locale.commandWordSilentReopen to (hasReopen && hasSilent), - locale.commandWordSearch to hasSearch, - locale.commandWordSetPriority to hasPriority, - locale.commandWordSilentSetPriority to (hasPriority && hasSilent), - locale.commandWordTeleport to hasTeleport, - locale.commandWordUnassign to hasAssignVariation, - locale.commandWordSilentUnassign to (hasAssignVariation && hasSilent), - locale.commandWordVersion to true, - locale.commandWordView to hasView, - locale.commandWordDeepView to hasDeepView - ) - .filter { it.value } - .map { it.key } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/ticket/Ticket.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/ticket/Ticket.kt deleted file mode 100644 index c6654c8..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/ticket/Ticket.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.hoshikurama.github.ticketmanager.ticket - -import com.hoshikurama.github.ticketmanager.TMLocale -import org.bukkit.Bukkit -import org.bukkit.Location -import java.time.Instant -import java.util.* - -class Ticket( - val creatorUUID: UUID?, // UUID if player, null if Console - val location: Location?, // Location if player, null if Console - val actions: List = listOf(), // List of actions - val priority: Priority = Priority.NORMAL, // Priority 1-5 or Lowest to Highest - val status: Status = Status.OPEN, // Status OPEN or CLOSED - val assignedTo: String? = null, // Null if not assigned to anybody - val statusUpdateForCreator: Boolean = false, // Determines whether player should be notified - val id: Int = -1, // Ticket ID 1+... -1 placeholder during ticket creation - ) { - - enum class Priority(val level: Byte) { - LOWEST(1), LOW(2), NORMAL(3), HIGH(4), HIGHEST(5) - } - enum class Status { - OPEN, CLOSED - } - - data class Action(val type: Type, val user: UUID?, val message: String? = null, val timestamp: Long = Instant.now().epochSecond) { - enum class Type { - ASSIGN, CLOSE, COMMENT, OPEN, REOPEN, SET_PRIORITY, MASS_CLOSE - } - } - data class Location(val world: String, val x: Int, val y: Int, val z: Int) { - constructor(bukkitLoc: org.bukkit.Location) : this(bukkitLoc.world!!.name, bukkitLoc.blockX, bukkitLoc.blockY, bukkitLoc.blockZ) - constructor(split: List) : this(split[0], split[1].toInt(), split[2].toInt(), split[3].toInt()) - - override fun toString() = "$world $x $y $z" - } -} - - -fun UUID?.toName(locale: TMLocale): String { - if (this == null) return locale.consoleName - return this.run(Bukkit::getOfflinePlayer).name ?: "UUID" -} - -fun Ticket.Priority.getColourCode() = when(this) { - Ticket.Priority.LOWEST -> "&1" - Ticket.Priority.LOW -> "&9" - Ticket.Priority.NORMAL -> "&e" - Ticket.Priority.HIGH -> "&c" - Ticket.Priority.HIGHEST -> "&4" -} - -fun Ticket.Priority.toColouredString(locale: TMLocale): String { - val word = when (this) { - Ticket.Priority.LOWEST -> locale.priorityLowest - Ticket.Priority.LOW -> locale.priorityLow - Ticket.Priority.NORMAL -> locale.priorityNormal - Ticket.Priority.HIGH -> locale.priorityHigh - Ticket.Priority.HIGHEST -> locale.priorityHighest - } - return word.let { this.getColourCode() + it } -} - -fun Ticket.Status.toColouredString(locale: TMLocale) = when (this) { - Ticket.Status.OPEN -> "&a${locale.statusOpen}" - Ticket.Status.CLOSED -> "&c${locale.statusClosed}" -} - -fun Ticket.Status.getColourCode() = when (this) { - Ticket.Status.OPEN -> "&a" - Ticket.Status.CLOSED -> "&c" -} - -fun Ticket.Location.toBukkitLocationOrNull() = - Bukkit.getWorld(world)?.let { Location(it, x.toDouble(), y.toDouble(), z.toDouble()) } \ No newline at end of file diff --git a/src/main/kotlin/com/hoshikurama/github/ticketmanager/ticket/TicketHandler.kt b/src/main/kotlin/com/hoshikurama/github/ticketmanager/ticket/TicketHandler.kt deleted file mode 100644 index eff768f..0000000 --- a/src/main/kotlin/com/hoshikurama/github/ticketmanager/ticket/TicketHandler.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.hoshikurama.github.ticketmanager.ticket - -import com.hoshikurama.github.ticketmanager.pluginState -import java.util.* - -class TicketHandler(val id: Int) { - internal val creatorUUID: UUID? by lazy { - pluginState.database.getCreatorUUID(id) - } - - internal val location: org.bukkit.Location? by lazy { - pluginState.database.getLocation(id) - } - - internal var statusUpdateForCreator: Boolean - get() = statusUpdateForCreatorInternal - set(v) = pluginState.database.setStatusUpdateForCreator(id, v) - - internal var priority: Ticket.Priority - get() = priorityInternal - set(v) = pluginState.database.setPriority(id, v) - - internal var status: Ticket.Status - get() = statusInternal - set(v) = pluginState.database.setStatus(id, v) - - internal var assignedTo: String? - get() = assignedToInternal - set(v) = pluginState.database.setAssignment(id, v) - - - private val priorityInternal: Ticket.Priority by lazy { - pluginState.database.getPriority(id) - } - private val statusInternal: Ticket.Status by lazy { - pluginState.database.getStatus(id) - } - private val assignedToInternal by lazy { - pluginState.database.getAssignment(id) - } - private val statusUpdateForCreatorInternal: Boolean by lazy { - pluginState.database.getStatusUpdateForCreator(id) - } - - fun UUIDMatches(uuid: UUID?) = creatorUUID?.equals(uuid) ?: (uuid == null) -} \ No newline at end of file