diff options
author | Linnea Gräf <nea@nea.moe> | 2025-01-29 00:26:56 +0100 |
---|---|---|
committer | Linnea Gräf <nea@nea.moe> | 2025-01-29 00:26:56 +0100 |
commit | 29857bff0875c43e055e89d34bc0d616d5d7dc4f (patch) | |
tree | 5875aed4144c0c1f004a86f6768eb5d6013d68f6 | |
parent | ad03f074c603f38f9319411d785e598e8a92e42c (diff) | |
download | ultra-notifier-master.tar.gz ultra-notifier-master.tar.bz2 ultra-notifier-master.zip |
-rw-r--r-- | build.gradle.kts | 1 | ||||
-rw-r--r-- | src/main/kotlin/datamodel/ChatType.kt | 116 | ||||
-rw-r--r-- | src/main/kotlin/util/GsonUtil.kt | 55 | ||||
-rw-r--r-- | src/main/kotlin/util/KSerializable.kt | 112 | ||||
-rw-r--r-- | src/main/kotlin/util/iterutil.kt | 36 |
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 + } + } + } + } +} |