diff --git a/src/main/java/ext/Json.kt b/src/main/java/ext/Json.kt index 78ceb88e..f35efcfe 100644 --- a/src/main/java/ext/Json.kt +++ b/src/main/java/ext/Json.kt @@ -2,8 +2,10 @@ package ext import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -21,16 +23,58 @@ val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, obj else -> 400 - "Invalid boolean value ${parser.text}" } }) -val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer() { - override fun deserialize(parser: JsonParser, context: DeserializationContext) = - // First try standard formats via asDateTime() method - parser.text.takeIf { it.isNotEmpty() }?.run { asDateTime() ?: try { - // Try maimai2 format (yyyy-MM-dd HH:mm:ss.0) - LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0")) - } catch (e: Exception) { - 400 - "Invalid date time value ${parser.text}" - } } -}) +val JSON_DATETIME = SimpleModule() + .addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) + .addDeserializer(LocalDateTime::class.java, object : JsonDeserializer() { + override fun deserialize(parser: JsonParser, context: DeserializationContext): LocalDateTime? { + parser.readLocalDateTimeArray(context)?.let { return it } + + // First try standard formats via asDateTime() method + return parser.text.takeIf { it.isNotEmpty() }?.run { asDateTime() ?: try { + // Try maimai2 format (yyyy-MM-dd HH:mm:ss.0) + LocalDateTime.parse(parser.text, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.0")) + } catch (e: Exception) { + 400 - "Invalid date time value ${parser.text}" + } } + } + }) + +fun JsonParser.readLocalDateTimeArray(context: DeserializationContext): LocalDateTime? { + if (!isExpectedStartArrayToken) return null + + val parts = mutableListOf() + while (nextToken() != JsonToken.END_ARRAY) { + if (currentToken() != JsonToken.VALUE_NUMBER_INT) { + throw context.weirdStringException( + parts.toString(), + LocalDateTime::class.java, + "Invalid legacy date-time array", + ) + } + parts += intValue + } + + if (parts.size !in 5..7) { + throw context.weirdStringException( + parts.toString(), + LocalDateTime::class.java, + "Invalid legacy date-time array; expected 5 to 7 integer components", + ) + } + + return try { + LocalDateTime.of( + parts[0], parts[1], parts[2], parts[3], parts[4], + parts.getOrElse(5) { 0 }, parts.getOrElse(6) { 0 }, + ) + } catch (e: Exception) { + throw context.weirdStringException( + parts.toString(), + LocalDateTime::class.java, + "Invalid legacy date-time array: ${e.message}", + ) + } +} val JACKSON = jacksonObjectMapper().apply { setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) findAndRegisterModules() diff --git a/src/main/java/icu/samnyan/aqua/sega/chusan/model/userdata/Chu3UserData.kt b/src/main/java/icu/samnyan/aqua/sega/chusan/model/userdata/Chu3UserData.kt index f4943abd..1fadea15 100644 --- a/src/main/java/icu/samnyan/aqua/sega/chusan/model/userdata/Chu3UserData.kt +++ b/src/main/java/icu/samnyan/aqua/sega/chusan/model/userdata/Chu3UserData.kt @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.databind.annotation.JsonSerialize +import ext.readLocalDateTimeArray import icu.samnyan.aqua.net.games.BaseEntity import icu.samnyan.aqua.net.games.IUserData import icu.samnyan.aqua.sega.chusan.model.request.UserEmoney @@ -24,6 +25,8 @@ import java.time.temporal.ChronoField class FlexibleDateTimeDeserializer : JsonDeserializer() { @Throws(IOException::class) public override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime { + p.readLocalDateTimeArray(ctxt)?.let { return it } + val value = p.text return FORMATTERS.firstNotNullOfOrNull { formatter -> runCatching { LocalDateTime.parse(value, formatter) }.getOrNull() diff --git a/src/test/kotlin/test/Chu3ImportJsonTest.kt b/src/test/kotlin/test/Chu3ImportJsonTest.kt index 8e0ea189..34446f88 100644 --- a/src/test/kotlin/test/Chu3ImportJsonTest.kt +++ b/src/test/kotlin/test/Chu3ImportJsonTest.kt @@ -3,6 +3,7 @@ package test import com.fasterxml.jackson.databind.JsonMappingException import ext.JACKSON import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserData +import icu.samnyan.aqua.sega.chusan.model.userdata.UserCharge import icu.samnyan.aqua.sega.chusan.model.userdata.UserCMissionProgress import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec @@ -44,6 +45,26 @@ class Chu3ImportJsonTest : StringSpec({ imported.lastPlayDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2, 3) } + "legacy backup date arrays remain importable" { + val userData = JACKSON.readValue( + """{ + "eventWatchedDate":[2026,8,4,1,2], + "firstPlayDate":[2026,8,4,1,2,3], + "lastPlayDate":[2026,8,4,1,2,3,123456000] + }""", + Chu3UserData::class.java, + ) + val charge = JACKSON.readValue( + """{"purchaseDate":[2026,8,4,1,2,3,123456000]}""", + UserCharge::class.java, + ) + + userData.eventWatchedDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2) + userData.firstPlayDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2, 3) + userData.lastPlayDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2, 3, 123456000) + charge.purchaseDate shouldBe LocalDateTime.of(2026, 8, 4, 1, 2, 3, 123456000) + } + "invalid CHUNITHM dates are rejected with a useful message" { val error = shouldThrow { JACKSON.readValue( diff --git a/src/test/kotlin/test/ImportExportParityTest.kt b/src/test/kotlin/test/ImportExportParityTest.kt new file mode 100644 index 00000000..b5709ad3 --- /dev/null +++ b/src/test/kotlin/test/ImportExportParityTest.kt @@ -0,0 +1,74 @@ +package test + +import ext.JACKSON +import ext.parseJackson +import icu.samnyan.aqua.net.games.chu3.Chu3DataExport +import icu.samnyan.aqua.net.games.mai2.Maimai2DataExport +import icu.samnyan.aqua.net.games.ongeki.OngekiDataExport +import icu.samnyan.aqua.sega.chusan.model.userdata.Chu3UserLinkedVerse +import icu.samnyan.aqua.sega.chusan.model.userdata.UserCharge +import icu.samnyan.aqua.sega.chusan.model.userdata.UserCourse +import icu.samnyan.aqua.sega.chusan.model.userdata.UserDuel +import icu.samnyan.aqua.sega.chusan.model.userdata.UserGacha +import icu.samnyan.aqua.sega.chusan.model.userdata.UserLoginBonus +import icu.samnyan.aqua.sega.chusan.model.userdata.UserPlaylog +import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserKaleidx +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDateTime + +class ImportExportParityTest : StringSpec({ + val timestamp = LocalDateTime.of(2026, 8, 4, 1, 2, 3, 123_456_000) + + "CHUNITHM export JSON can be imported without changing data" { + val data = Chu3DataExport().apply { + userData.eventWatchedDate = timestamp + userData.firstPlayDate = timestamp + userData.lastPlayDate = timestamp + userChargeList = listOf(UserCharge().apply { + purchaseDate = timestamp + validDate = timestamp + paramDate = timestamp + }) + userCourseList = listOf(UserCourse().apply { lastPlayDate = timestamp }) + userDuelList = listOf(UserDuel().apply { lastPlayDate = timestamp }) + userPlaylogList = listOf(UserPlaylog().apply { + playDate = timestamp + userPlayDate = timestamp + }) + userGachaList = listOf(UserGacha().apply { dailyGachaDate = timestamp }) + userLinkedVerseList = listOf(Chu3UserLinkedVerse().apply { clearDate = timestamp }) + userLoginBonusList = listOf(UserLoginBonus(lastUpdateDate = timestamp)) + } + + assertJsonRoundTrip(data) + val tree = JACKSON.readTree(JACKSON.writeValueAsString(data)) + tree.at("/userData/firstPlayDate").isTextual shouldBe true + tree.at("/userChargeList/0/purchaseDate").isTextual shouldBe true + } + + "maimai DX export JSON can be imported without changing data" { + val data = Maimai2DataExport().apply { + userKaleidxScopeList = listOf(Mai2UserKaleidx().apply { + bestAchievementDate = timestamp + bestDeluxscoreDate = timestamp + clearDate = timestamp + lastPlayDate = timestamp + }) + } + + assertJsonRoundTrip(data) + JACKSON.readTree(JACKSON.writeValueAsString(data)) + .at("/userKaleidxScopeList/0/lastPlayDate").isTextual shouldBe true + } + + "ONGEKI export JSON can be imported without changing data" { + assertJsonRoundTrip(OngekiDataExport()) + } +}) + +private inline fun assertJsonRoundTrip(value: T) { + val exportedJson = JACKSON.writeValueAsString(value) + val imported = exportedJson.parseJackson(T::class.java) + JACKSON.readTree(JACKSON.writeValueAsString(imported)) shouldBe JACKSON.readTree(exportedJson) +}