summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLinnea Gräf <nea@nea.moe>2025-01-29 00:26:56 +0100
committerLinnea Gräf <nea@nea.moe>2025-01-29 00:26:56 +0100
commit29857bff0875c43e055e89d34bc0d616d5d7dc4f (patch)
tree5875aed4144c0c1f004a86f6768eb5d6013d68f6
parentad03f074c603f38f9319411d785e598e8a92e42c (diff)
downloadultra-notifier-master.tar.gz
ultra-notifier-master.tar.bz2
ultra-notifier-master.zip
Load universe from jsonHEADmaster
-rw-r--r--build.gradle.kts1
-rw-r--r--src/main/kotlin/datamodel/ChatType.kt116
-rw-r--r--src/main/kotlin/util/GsonUtil.kt55
-rw-r--r--src/main/kotlin/util/KSerializable.kt112
-rw-r--r--src/main/kotlin/util/iterutil.kt36
5 files changed, 274 insertions, 46 deletions
diff --git a/build.gradle.kts b/build.gradle.kts
index 9b25b72..9bec6d0 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -80,6 +80,7 @@ dependencies {
runtimeOnly("me.djtheredstoner:DevAuth-fabric:$devauthVersion")
}
runtimeOnly("com.google.guava:guava:17.0")
+ implementation(kotlin("reflect"))
shadowImpl("com.github.therealbush:eventbus:1.0.2")
include(version.universalCraft)
shadowImpl("io.github.juuxel:libninepatch:1.2.0")
diff --git a/src/main/kotlin/datamodel/ChatType.kt b/src/main/kotlin/datamodel/ChatType.kt
index b157de9..47abe2a 100644
--- a/src/main/kotlin/datamodel/ChatType.kt
+++ b/src/main/kotlin/datamodel/ChatType.kt
@@ -1,15 +1,18 @@
package moe.nea.ultranotifier.datamodel
-import jdk.jfr.Category
+import moe.nea.ultranotifier.UltraNotifier
import moe.nea.ultranotifier.event.SubscriptionTarget
import moe.nea.ultranotifier.event.TickEvent
import moe.nea.ultranotifier.event.UltraSubscribe
import moe.nea.ultranotifier.event.VisibleChatMessageAddedEvent
+import moe.nea.ultranotifier.util.GsonUtil
+import moe.nea.ultranotifier.util.duplicatesBy
import moe.nea.ultranotifier.util.minecrat.MC
import moe.nea.ultranotifier.util.minecrat.category
import moe.nea.ultranotifier.util.minecrat.getFormattedTextCompat
import moe.nea.ultranotifier.util.minecrat.removeFormattingCodes
import net.minecraft.text.Text
+import util.KSerializable
import java.util.function.Predicate
import java.util.regex.Pattern
@@ -17,8 +20,8 @@ data class ChatTypeId(
val id: String
)
+@KSerializable
data class ChatType(
- val id: ChatTypeId,
val name: String,
val patterns: List<ChatPattern>,
)
@@ -36,6 +39,8 @@ data class ChatPattern(
}
data class CategoryId(val id: String)
+
+@KSerializable
data class ChatCategory(
val id: CategoryId,
val label: String,
@@ -44,7 +49,7 @@ data class ChatCategory(
data class ChatUniverse(
val name: String,
- val types: List<ChatType>,
+ val types: Map<ChatTypeId, ChatType>,
val categories: List<ChatCategory>,
) {
fun categorize(
@@ -53,18 +58,16 @@ data class ChatUniverse(
val types = this.types
.asSequence()
.filter {
- it.patterns.any {
+ it.value.patterns.any {
it.predicate.test(text)
}
}
.map {
- it.id
+ it.key
}
.toSet()
- // TODO: potentially allow recalculating categories on the fly
- val categories = categories.filterTo(mutableSetOf()) { it.chatTypes.any { it in types } }
return CategorizedChatLine(
- text, types, categories
+ text, types
)
}
}
@@ -72,54 +75,78 @@ data class ChatUniverse(
data class CategorizedChatLine(
val text: String,
val types: Set<ChatTypeId>,
- val categories: Set<ChatCategory>,
+// val categories: Set<ChatCategory>,
+)
+
+@KSerializable
+data class UniverseMeta(
+ // TODO: implement the ip filter
+ val ipFilter: List<ChatPattern> = listOf(),
+ val name: String,
)
interface HasCategorizedChatLine {
val categorizedChatLine_ultraNotifier: CategorizedChatLine
}
+data class UniverseId(
+ val id: String
+)
+
+private fun loadAllUniverses(): Map<UniverseId, ChatUniverse> = buildMap {
+ for (file in UltraNotifier.configFolder
+ .resolve("universes/")
+ .listFiles() ?: emptyArray()) {
+ runCatching {
+ val meta = GsonUtil.read<UniverseMeta>(file.resolve("meta.json"))
+ val types = GsonUtil.read<Map<ChatTypeId, ChatType>>(file.resolve("chattypes.json"))
+ val categories = GsonUtil.read<List<ChatCategory>>(file.resolve("categories.json"))
+ // Validate categories linking properly
+ for (category in categories) {
+ for (chatType in category.chatTypes) {
+ if (chatType in types.keys) {
+ UltraNotifier.logger.warn("Missing definition for $chatType required by ${category.id} in $file")
+ }
+ }
+ }
+ for (category in categories.asSequence().duplicatesBy { it.id }) {
+ UltraNotifier.logger.warn("Found duplicate category ${category.id} in $file")
+ }
+
+ put(
+ UniverseId(file.name),
+ ChatUniverse(
+ meta.name,
+ types,
+ categories,
+ ))
+ }.getOrElse {
+ UltraNotifier.logger.warn("Could not load universe at $file", it)
+ }
+ }
+}
+
object ChatCategoryArbiter : SubscriptionTarget {
val specialAll = CategoryId("special-all")
- val universe = ChatUniverse(
- "Hypixel SkyBlock",
- listOf(
- ChatType(
- ChatTypeId("bazaar"),
- "Bazaar",
- listOf(
- ChatPattern("(?i).*Bazaar.*")
- )
- ),
- ChatType(
- ChatTypeId("auction-house"),
- "Auction House",
- listOf(
- ChatPattern("(?i).*Auction House.*")
- )
- ),
- ),
- listOf(
- ChatCategory(
- specialAll,
- "All",
- setOf()
- ),
- ChatCategory(
- CategoryId("economy"),
- "Economy",
- setOf(ChatTypeId("bazaar"), ChatTypeId("auction-house"))
- )
- )
+
+ var allUniverses = loadAllUniverses()
+
+ var activeUniverse: ChatUniverse? = allUniverses.values.single()
+ private val allCategoryList = listOf(
+ ChatCategory(specialAll, "All", setOf())
)
- val categories get() = universe.categories
+ val categories // TODO: memoize
+ get() = (activeUniverse?.categories ?: listOf()) + allCategoryList
+
var selectedCategoryId = specialAll
set(value) {
field = value
selectedCategory = findCategory(value)
}
- var lastSelectedId = selectedCategoryId
+ private var lastSelectedId = selectedCategoryId
+ var selectedCategory: ChatCategory = findCategory(selectedCategoryId)
+ private set
@UltraSubscribe
fun onTick(event: TickEvent) {
@@ -129,15 +156,12 @@ object ChatCategoryArbiter : SubscriptionTarget {
}
}
- var selectedCategory: ChatCategory = findCategory(selectedCategoryId)
- private set
-
@UltraSubscribe
fun onVisibleChatMessage(event: VisibleChatMessageAddedEvent) {
val cl = event.chatLine.category
if (selectedCategory.id == specialAll)
return
- if (selectedCategory !in cl.categories)
+ if (cl.types.none { it in selectedCategory.chatTypes })
event.cancel()
}
@@ -145,7 +169,7 @@ object ChatCategoryArbiter : SubscriptionTarget {
fun categorize(content: Text): CategorizedChatLine {
val stringContent = content.getFormattedTextCompat().removeFormattingCodes()
- return universe.categorize(stringContent)
+ return activeUniverse?.categorize(stringContent) ?: CategorizedChatLine(stringContent, setOf())
}
}
diff --git a/src/main/kotlin/util/GsonUtil.kt b/src/main/kotlin/util/GsonUtil.kt
new file mode 100644
index 0000000..d689330
--- /dev/null
+++ b/src/main/kotlin/util/GsonUtil.kt
@@ -0,0 +1,55 @@
+package moe.nea.ultranotifier.util
+
+import com.google.gson.GsonBuilder
+import com.google.gson.TypeAdapter
+import com.google.gson.reflect.TypeToken
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonWriter
+import moe.nea.ultranotifier.datamodel.CategoryId
+import moe.nea.ultranotifier.datamodel.ChatPattern
+import moe.nea.ultranotifier.datamodel.ChatTypeId
+import net.minecraft.util.Identifier
+import util.KotlinTypeAdapterFactory
+import java.io.File
+
+object GsonUtil {
+ val sharedGsonBuilder = GsonBuilder()
+ .registerTypeAdapterFactory(KotlinTypeAdapterFactory())
+ .registerTypeHierarchyAdapter(Identifier::class.java, object : TypeAdapter<Identifier>() {
+ override fun write(out: JsonWriter, value: Identifier) {
+ out.value(value.namespace + ":" + value.path)
+ }
+
+ override fun read(`in`: JsonReader): Identifier {
+ val identifierName = `in`.nextString()
+ val parts = identifierName.split(":")
+ require(parts.size != 2) { "$identifierName is not a valid identifier" }
+ return identifier(parts[0], parts[1])
+ }
+ }.nullSafe())
+ .registerTypeHierarchyAdapter(ChatPattern::class.java, stringWrapperAdapter(ChatPattern::text, ::ChatPattern))
+ .registerTypeHierarchyAdapter(CategoryId::class.java, stringWrapperAdapter(CategoryId::id, ::CategoryId))
+ .registerTypeHierarchyAdapter(ChatTypeId::class.java, stringWrapperAdapter(ChatTypeId::id, ::ChatTypeId))
+
+ private fun <T> stringWrapperAdapter(from: (T) -> String, to: (String) -> T): TypeAdapter<T> {
+ return object : TypeAdapter<T>() {
+ override fun write(out: JsonWriter, value: T) {
+ out.value(from(value))
+ }
+
+ override fun read(`in`: JsonReader): T {
+ return to(`in`.nextString())
+ }
+ }.nullSafe()
+ }
+
+ inline fun <reified T : Any> read(meta: File): T {
+ // TODO: add exception
+ meta.reader().use { reader ->
+ return gson.fromJson(reader, object : TypeToken<T>() {}.type)
+ }
+ }
+
+ val gson = sharedGsonBuilder.create()
+ val prettyGson = sharedGsonBuilder.setPrettyPrinting().create()
+}
diff --git a/src/main/kotlin/util/KSerializable.kt b/src/main/kotlin/util/KSerializable.kt
new file mode 100644
index 0000000..ef4c953
--- /dev/null
+++ b/src/main/kotlin/util/KSerializable.kt
@@ -0,0 +1,112 @@
+package util
+import com.google.gson.*
+import com.google.gson.annotations.SerializedName
+import com.google.gson.reflect.TypeToken
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonToken
+import com.google.gson.stream.JsonWriter
+import kotlin.reflect.*
+import kotlin.reflect.full.findAnnotation
+import kotlin.reflect.full.isSubtypeOf
+import kotlin.reflect.full.memberProperties
+import kotlin.reflect.full.primaryConstructor
+import kotlin.reflect.jvm.javaType
+import com.google.gson.internal.`$Gson$Types` as InternalGsonTypes
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.CLASS)
+annotation class KSerializable(
+)
+
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.PROPERTY)
+annotation class ExtraData
+
+
+class KotlinTypeAdapterFactory : TypeAdapterFactory {
+
+ internal data class ParameterInfo(
+ val param: KParameter,
+ val adapter: TypeAdapter<Any?>,
+ val name: String,
+ val field: KProperty1<Any, Any?>
+ )
+
+ override fun <T : Any> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T>? {
+ val kotlinClass = type.rawType.kotlin as KClass<T>
+ if (kotlinClass.findAnnotation<KSerializable>() == null) return null
+ if (!kotlinClass.isData) return null
+ val primaryConstructor = kotlinClass.primaryConstructor ?: return null
+ val params = primaryConstructor.parameters.filter { it.findAnnotation<ExtraData>() == null }
+ val extraDataParam = primaryConstructor.parameters
+ .find { it.findAnnotation<ExtraData>() != null }
+ ?.let { param ->
+ require(typeOf<MutableMap<String, JsonElement>>().isSubtypeOf(param.type))
+ param to kotlinClass.memberProperties.find { it.name == param.name && it.returnType.isSubtypeOf(typeOf<Map<String, JsonElement>>()) } as KProperty1<Any, Map<String, JsonElement>>
+ }
+ val parameterInfos = params.map { param ->
+ ParameterInfo(
+ param,
+ gson.getAdapter(
+ TypeToken.get(InternalGsonTypes.resolve(type.type, type.rawType, param.type.javaType))
+ ) as TypeAdapter<Any?>,
+ param.findAnnotation<SerializedName>()?.value ?: param.name!!,
+ kotlinClass.memberProperties.find { it.name == param.name }!! as KProperty1<Any, Any?>
+ )
+ }.associateBy { it.name }
+ val jsonElementAdapter = gson.getAdapter(JsonElement::class.java)
+
+ return object : TypeAdapter<T>() {
+ override fun write(out: JsonWriter, value: T?) {
+ if (value == null) {
+ out.nullValue()
+ return
+ }
+ out.beginObject()
+ parameterInfos.forEach { (name, paramInfo) ->
+ out.name(name)
+ paramInfo.adapter.write(out, paramInfo.field.get(value))
+ }
+ if (extraDataParam != null) {
+ val extraData = extraDataParam.second.get(value)
+ extraData.forEach { (extraName, extraValue) ->
+ out.name(extraName)
+ jsonElementAdapter.write(out, extraValue)
+ }
+ }
+ out.endObject()
+ }
+
+ override fun read(reader: JsonReader): T? {
+ if (reader.peek() == JsonToken.NULL) {
+ reader.nextNull()
+ return null
+ }
+ reader.beginObject()
+ val args = mutableMapOf<KParameter, Any?>()
+ val extraData = mutableMapOf<String, JsonElement>()
+ while (reader.peek() != JsonToken.END_OBJECT) {
+ val name = reader.nextName()
+ val paramData = parameterInfos[name]
+ if (paramData == null) {
+ extraData[name] = jsonElementAdapter.read(reader)
+ continue
+ }
+ val value = paramData.adapter.read(reader)
+ args[paramData.param] = value
+ }
+ reader.endObject()
+ if (extraDataParam == null) {
+ if (extraData.isNotEmpty()) {
+ throw JsonParseException("Encountered unknown keys ${extraData.keys} while parsing $type")
+ }
+ } else {
+ args[extraDataParam.first] = extraData
+ }
+ return primaryConstructor.callBy(args)
+ }
+ }
+ }
+}
+
diff --git a/src/main/kotlin/util/iterutil.kt b/src/main/kotlin/util/iterutil.kt
new file mode 100644
index 0000000..7845b05
--- /dev/null
+++ b/src/main/kotlin/util/iterutil.kt
@@ -0,0 +1,36 @@
+package moe.nea.ultranotifier.util
+
+
+fun <T, K : Any> Sequence<T>.duplicatesBy(keyFunc: (T) -> K): Sequence<T> {
+ return object : Sequence<T> {
+ override fun iterator(): Iterator<T> {
+ val observed = HashSet<K>()
+ val oldIterator = this@duplicatesBy.iterator()
+
+ return object : Iterator<T> {
+ var next: T? = null
+ var hasNext = false
+ override fun hasNext(): Boolean {
+ if (hasNext) return true
+ while (oldIterator.hasNext()) {
+ val elem = oldIterator.next()
+ val key = keyFunc(elem)
+ if (observed.add(key))
+ continue
+ hasNext = true
+ next = elem
+ }
+ return hasNext
+ }
+
+ override fun next(): T {
+ if (!hasNext()) throw NoSuchElementException()
+ hasNext = false
+ val elem = next as T
+ next = null
+ return elem
+ }
+ }
+ }
+ }
+}