Added user search, improved api docs, improved replay compression by 20-25%, user-details allows userId and username
This commit is contained in:
parent
5fbdfaa322
commit
ebaf4c82c5
@ -155,6 +155,18 @@
|
|||||||
<version>1.9</version>
|
<version>1.9</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Graphs/Plots -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jetbrains.kotlinx</groupId>
|
||||||
|
<artifactId>kandy-lets-plot</artifactId>
|
||||||
|
<version>0.6.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jetbrains.kotlinx</groupId>
|
||||||
|
<artifactId>kotlin-statistics-jvm</artifactId>
|
||||||
|
<version>0.2.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jetbrains.kotlin</groupId>
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
<artifactId>kotlin-test-junit5</artifactId>
|
<artifactId>kotlin-test-junit5</artifactId>
|
||||||
@ -269,4 +281,12 @@
|
|||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>jetbrains</id>
|
||||||
|
<name>jetbrains</name>
|
||||||
|
<url>https://packages.jetbrains.team/maven/p/kds/kotlin-ds-maven</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import com.nisemoe.nise.konata.Replay
|
|||||||
import com.nisemoe.nise.konata.compareSingleReplayWithSet
|
import com.nisemoe.nise.konata.compareSingleReplayWithSet
|
||||||
import com.nisemoe.nise.osu.OsuApi
|
import com.nisemoe.nise.osu.OsuApi
|
||||||
import com.nisemoe.nise.scheduler.ImportScores
|
import com.nisemoe.nise.scheduler.ImportScores
|
||||||
|
import com.nisemoe.nise.service.CompressReplay
|
||||||
import org.jooq.DSLContext
|
import org.jooq.DSLContext
|
||||||
import org.nisemoe.mari.judgements.CompressJudgements
|
import org.nisemoe.mari.judgements.CompressJudgements
|
||||||
import org.nisemoe.mari.replays.OsuReplay
|
import org.nisemoe.mari.replays.OsuReplay
|
||||||
@ -218,7 +219,10 @@ class UploadReplayController(
|
|||||||
|
|
||||||
val replaysForKonata = allReplays
|
val replaysForKonata = allReplays
|
||||||
.filter { it.replayId != referenceReplay.id }
|
.filter { it.replayId != referenceReplay.id }
|
||||||
.map { Replay(string = it.replayData, id = it.replayId, mods = it.replayMods) }
|
.map {
|
||||||
|
val referenceReplayData = CompressReplay.decompressReplayToString(it.replayData)
|
||||||
|
Replay(string = referenceReplayData, id = it.replayId, mods = it.replayMods)
|
||||||
|
}
|
||||||
.toTypedArray()
|
.toTypedArray()
|
||||||
|
|
||||||
val comparisonResult = compareSingleReplayWithSet(referenceReplay, replaysForKonata)
|
val comparisonResult = compareSingleReplayWithSet(referenceReplay, replaysForKonata)
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class UserDetailsController(
|
|||||||
@PostMapping("user-queue")
|
@PostMapping("user-queue")
|
||||||
fun addUserToQueue(@RequestBody request: UserQueueRequest): ResponseEntity<Unit> {
|
fun addUserToQueue(@RequestBody request: UserQueueRequest): ResponseEntity<Unit> {
|
||||||
// Check if the user_id currently exists
|
// Check if the user_id currently exists
|
||||||
this.userService.getUserById(userId = request.userId)
|
this.userService.getUserDetails(request.userId)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
|
|
||||||
val userQueueDetails = this.userQueueService.getUserQueueDetails(request.userId)
|
val userQueueDetails = this.userQueueService.getUserQueueDetails(request.userId)
|
||||||
@ -53,12 +53,18 @@ class UserDetailsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class UserDetailsRequest(
|
data class UserDetailsRequest(
|
||||||
val userId: String
|
val userId: Long?,
|
||||||
|
val username: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
@PostMapping("user-details")
|
@PostMapping("user-details")
|
||||||
fun getUserDetails(@RequestBody request: UserDetailsRequest): ResponseEntity<UserDetailsResponse> {
|
fun getUserDetails(@RequestBody request: UserDetailsRequest): ResponseEntity<UserDetailsResponse> {
|
||||||
val userDetailsExtended = this.userService.getUserDetails(username = request.userId)
|
// Check if BOTH are null or BOTH are not null
|
||||||
|
if((request.userId == null) == (request.username == null))
|
||||||
|
return ResponseEntity.badRequest().build()
|
||||||
|
|
||||||
|
val identifier: Any = request.userId ?: request.username ?: return ResponseEntity.badRequest().build()
|
||||||
|
val userDetailsExtended = this.userService.getUserDetails(identifier = identifier)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
|
|
||||||
val userId = userDetailsExtended.userDetails.user_id
|
val userId = userDetailsExtended.userDetails.user_id
|
||||||
|
|||||||
@ -82,7 +82,7 @@ class ScoreService(
|
|||||||
|
|
||||||
val replayData = result.get(SCORES.REPLAY, ByteArray::class.java) ?: return null
|
val replayData = result.get(SCORES.REPLAY, ByteArray::class.java) ?: return null
|
||||||
|
|
||||||
val replay = CompressReplay.decompressReplay(replayData)
|
val replay = CompressReplay.decompressReplayToString(replayData)
|
||||||
|
|
||||||
var beatmapFile = result.get(BEATMAPS.BEATMAP_FILE, String::class.java)
|
var beatmapFile = result.get(BEATMAPS.BEATMAP_FILE, String::class.java)
|
||||||
if(beatmapFile == null) {
|
if(beatmapFile == null) {
|
||||||
@ -103,7 +103,7 @@ class ScoreService(
|
|||||||
|
|
||||||
return ReplayViewerData(
|
return ReplayViewerData(
|
||||||
beatmap = beatmapFile,
|
beatmap = beatmapFile,
|
||||||
replay = String(replay, Charsets.UTF_8).trimEnd(','),
|
replay = replay,
|
||||||
judgements = getJudgements(replayId),
|
judgements = getJudgements(replayId),
|
||||||
mods = mods
|
mods = mods
|
||||||
)
|
)
|
||||||
|
|||||||
@ -42,40 +42,17 @@ class UserService(
|
|||||||
return dslContext.fetchCount(SCORES, SCORES.USER_ID.eq(userId))
|
return dslContext.fetchCount(SCORES, SCORES.USER_ID.eq(userId))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUserById(userId: Long): UserDetails? {
|
fun getUserDetails(identifier: Any): UserDetailsExtended? {
|
||||||
val user = dslContext.selectFrom(USERS)
|
val user = when (identifier) {
|
||||||
.where(USERS.USER_ID.eq(userId))
|
is Long -> dslContext.selectFrom(USERS)
|
||||||
.fetchOneInto(UsersRecord::class.java)
|
.where(USERS.USER_ID.eq(identifier))
|
||||||
|
.fetchOneInto(UsersRecord::class.java)
|
||||||
if (user != null) {
|
is String -> dslContext.selectFrom(USERS)
|
||||||
return UserDetails(
|
.where(USERS.USERNAME.equalIgnoreCase(identifier.lowercase()))
|
||||||
user.userId!!,
|
.fetchOneInto(UsersRecord::class.java)
|
||||||
user.username!!,
|
else -> null
|
||||||
user.rank,
|
|
||||||
user.ppRaw,
|
|
||||||
user.joinDate?.let { Format.formatLocalDateTime(it) },
|
|
||||||
user.secondsPlayed,
|
|
||||||
user.country,
|
|
||||||
user.countryRank,
|
|
||||||
user.playcount
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The database does NOT have the user; we will now use the osu!api
|
|
||||||
val apiUser = this.osuApi.getUserProfile(userId = userId.toString(), mode = "osu", key = "id")
|
|
||||||
?: return null
|
|
||||||
|
|
||||||
// Persist to database
|
|
||||||
insertApiUser(apiUser)
|
|
||||||
|
|
||||||
return this.mapUserToDatabase(apiUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getUserDetails(username: String): UserDetailsExtended? {
|
|
||||||
val user = dslContext.selectFrom(USERS)
|
|
||||||
.where(USERS.USERNAME.equalIgnoreCase(username.lowercase()))
|
|
||||||
.fetchOneInto(UsersRecord::class.java)
|
|
||||||
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
val userDetails = UserDetails(
|
val userDetails = UserDetails(
|
||||||
user.userId!!,
|
user.userId!!,
|
||||||
@ -96,8 +73,11 @@ class UserService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The database does NOT have the user; we will now use the osu!api
|
// The database does NOT have the user; we will now use the osu!api
|
||||||
val apiUser = this.osuApi.getUserProfile(userId = username, mode = "osu", key = "username")
|
val apiUser = when (identifier) {
|
||||||
?: return null
|
is Long -> this.osuApi.getUserProfile(userId = identifier.toString(), mode = "osu", key = "id")
|
||||||
|
is String -> this.osuApi.getUserProfile(userId = identifier, mode = "osu", key = "username")
|
||||||
|
else -> null
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
// Persist to database
|
// Persist to database
|
||||||
insertApiUser(apiUser)
|
insertApiUser(apiUser)
|
||||||
|
|||||||
@ -5,6 +5,13 @@ import com.nisemoe.generated.tables.records.UsersRecord
|
|||||||
import com.nisemoe.generated.tables.references.SCORES
|
import com.nisemoe.generated.tables.references.SCORES
|
||||||
import com.nisemoe.generated.tables.references.USERS
|
import com.nisemoe.generated.tables.references.USERS
|
||||||
import com.nisemoe.nise.osu.OsuApi
|
import com.nisemoe.nise.osu.OsuApi
|
||||||
|
import org.jetbrains.kotlinx.kandy.dsl.plot
|
||||||
|
import org.jetbrains.kotlinx.kandy.ir.Plot
|
||||||
|
import org.jetbrains.kotlinx.kandy.letsplot.export.save
|
||||||
|
import org.jetbrains.kotlinx.kandy.letsplot.feature.layout
|
||||||
|
import org.jetbrains.kotlinx.kandy.letsplot.multiplot.model.PlotBunch
|
||||||
|
import org.jetbrains.kotlinx.kandy.util.color.Color
|
||||||
|
import org.jetbrains.kotlinx.statistics.kandy.layers.histogram
|
||||||
import org.jooq.DSLContext
|
import org.jooq.DSLContext
|
||||||
import org.springframework.context.annotation.Profile
|
import org.springframework.context.annotation.Profile
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
@ -13,6 +20,7 @@ import java.time.LocalDateTime
|
|||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
||||||
data class UserReport(
|
data class UserReport(
|
||||||
val username: String,
|
val username: String,
|
||||||
val susScore: Double,
|
val susScore: Double,
|
||||||
@ -94,7 +102,9 @@ class Agent(
|
|||||||
SCORES.EDGE_HITS,
|
SCORES.EDGE_HITS,
|
||||||
SCORES.SNAPS,
|
SCORES.SNAPS,
|
||||||
SCORES.KEYPRESSES_MEDIAN_ADJUSTED,
|
SCORES.KEYPRESSES_MEDIAN_ADJUSTED,
|
||||||
SCORES.ERROR_KURTOSIS
|
SCORES.ERROR_KURTOSIS,
|
||||||
|
SCORES.KEYPRESSES_TIMES,
|
||||||
|
SCORES.SLIDEREND_RELEASE_TIMES
|
||||||
)
|
)
|
||||||
.from(SCORES)
|
.from(SCORES)
|
||||||
.where(SCORES.ADJUSTED_UR.lessOrEqual(256.0))
|
.where(SCORES.ADJUSTED_UR.lessOrEqual(256.0))
|
||||||
@ -118,10 +128,44 @@ class Agent(
|
|||||||
urgencyScore = susScore.second
|
urgencyScore = susScore.second
|
||||||
)
|
)
|
||||||
reports.add(newReport)
|
reports.add(newReport)
|
||||||
println(newReport)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reports.sortByDescending { it.urgencyScore }
|
reports.sortByDescending { it.urgencyScore }
|
||||||
|
|
||||||
|
println("Found ${reports.size} reports.")
|
||||||
|
|
||||||
|
for(report in reports) {
|
||||||
|
|
||||||
|
println(report)
|
||||||
|
|
||||||
|
val plotBunch = mutableListOf<Plot>()
|
||||||
|
|
||||||
|
if(report.userScores.size <= 1) {
|
||||||
|
println("Not enough data to plot for ${report.username}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (score in report.userScores) {
|
||||||
|
if(score.KEYPRESSES_TIMES == null) continue
|
||||||
|
|
||||||
|
plotBunch.add(plot {
|
||||||
|
histogram(score.KEYPRESSES_TIMES.toList()) {
|
||||||
|
fillColor = Color.BLACK
|
||||||
|
}
|
||||||
|
layout {
|
||||||
|
size = 600 to 200
|
||||||
|
xAxisLabel = "x"
|
||||||
|
}
|
||||||
|
layout.title = "Kandy Getting Started Example"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
val plotBunchs = plotBunch.mapIndexed { i, it ->
|
||||||
|
PlotBunch.Item(it, 0, 0 + (200 * i), 600, 200 * plotBunch.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlotBunch(plotBunchs).save(report.username + ".png")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mapScoreToMetrics(user: UsersRecord, score: ScoresRecord): ScoreMetrics {
|
fun mapScoreToMetrics(user: UsersRecord, score: ScoresRecord): ScoreMetrics {
|
||||||
@ -134,7 +178,9 @@ class Agent(
|
|||||||
edgeHits = score.edgeHits!!,
|
edgeHits = score.edgeHits!!,
|
||||||
snaps = score.snaps!!,
|
snaps = score.snaps!!,
|
||||||
keypressesMedianAdjusted = score.keypressesMedianAdjusted!!,
|
keypressesMedianAdjusted = score.keypressesMedianAdjusted!!,
|
||||||
errorKurtosis = score.errorKurtosis!!
|
errorKurtosis = score.errorKurtosis!!,
|
||||||
|
SLIDEREND_RELEASE_TIMES = score.sliderendReleaseTimes?.filterNotNull()?.toTypedArray(),
|
||||||
|
KEYPRESSES_TIMES = score.keypressesTimes?.filterNotNull()?.toTypedArray()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +193,9 @@ class Agent(
|
|||||||
val edgeHits: Int,
|
val edgeHits: Int,
|
||||||
val snaps: Int,
|
val snaps: Int,
|
||||||
val keypressesMedianAdjusted: Double,
|
val keypressesMedianAdjusted: Double,
|
||||||
val errorKurtosis: Double
|
val errorKurtosis: Double,
|
||||||
|
val SLIDEREND_RELEASE_TIMES: Array<Double>?,
|
||||||
|
val KEYPRESSES_TIMES: Array<Double>?
|
||||||
)
|
)
|
||||||
|
|
||||||
val ppWeight = 1.0
|
val ppWeight = 1.0
|
||||||
|
|||||||
@ -23,9 +23,7 @@ import java.time.OffsetDateTime
|
|||||||
@Profile("fix:scores")
|
@Profile("fix:scores")
|
||||||
@Service
|
@Service
|
||||||
class FixOldScores(
|
class FixOldScores(
|
||||||
private val dslContext: DSLContext,
|
private val dslContext: DSLContext
|
||||||
private val osuApi: OsuApi,
|
|
||||||
private val circleguardService: CircleguardService
|
|
||||||
){
|
){
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -138,85 +136,4 @@ class FixOldScores(
|
|||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
// fun processScore(score: ScoresRecord) {
|
|
||||||
//
|
|
||||||
// // Fetch the beatmap file from database
|
|
||||||
// var beatmapFile = dslContext.select(BEATMAPS.BEATMAP_FILE)
|
|
||||||
// .from(BEATMAPS)
|
|
||||||
// .where(BEATMAPS.BEATMAP_ID.eq(score.beatmapId))
|
|
||||||
// .fetchOneInto(String::class.java)
|
|
||||||
//
|
|
||||||
// if(beatmapFile == null) {
|
|
||||||
// this.logger.warn("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from database")
|
|
||||||
//
|
|
||||||
// beatmapFile = this.osuApi.getBeatmapFile(beatmapId = score.beatmapId!!)
|
|
||||||
//
|
|
||||||
// if(beatmapFile == null) {
|
|
||||||
// this.logger.error("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from osu!api")
|
|
||||||
// return
|
|
||||||
// } else {
|
|
||||||
// dslContext.update(BEATMAPS)
|
|
||||||
// .set(BEATMAPS.BEATMAP_FILE, beatmapFile)
|
|
||||||
// .where(BEATMAPS.BEATMAP_ID.eq(score.beatmapId))
|
|
||||||
// .execute()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// val processedReplay: CircleguardService.ReplayResponse? = try {
|
|
||||||
// this.circleguardService.processReplay(
|
|
||||||
// replayData = score.replay!!.decodeToString(), beatmapData = beatmapFile, mods = score.mods ?: 0
|
|
||||||
// ).get()
|
|
||||||
// } catch (e: Exception) {
|
|
||||||
// this.logger.error("Circleguard failed to process replay with score_id: ${score.id}")
|
|
||||||
// this.logger.error(e.stackTraceToString())
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if (processedReplay == null || processedReplay.judgements.isEmpty()) {
|
|
||||||
// this.logger.error("Circleguard returned null and failed to process replay with score_id: ${score.id}")
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// val scoreId = dslContext.update(SCORES)
|
|
||||||
// .set(SCORES.UR, processedReplay.ur)
|
|
||||||
// .set(SCORES.ADJUSTED_UR, processedReplay.adjusted_ur)
|
|
||||||
// .set(SCORES.FRAMETIME, processedReplay.frametime)
|
|
||||||
// .set(SCORES.SNAPS, processedReplay.snaps)
|
|
||||||
// .set(SCORES.MEAN_ERROR, processedReplay.mean_error)
|
|
||||||
// .set(SCORES.ERROR_VARIANCE, processedReplay.error_variance)
|
|
||||||
// .set(SCORES.ERROR_STANDARD_DEVIATION, processedReplay.error_standard_deviation)
|
|
||||||
// .set(SCORES.MINIMUM_ERROR, processedReplay.minimum_error)
|
|
||||||
// .set(SCORES.MAXIMUM_ERROR, processedReplay.maximum_error)
|
|
||||||
// .set(SCORES.ERROR_RANGE, processedReplay.error_range)
|
|
||||||
// .set(SCORES.ERROR_COEFFICIENT_OF_VARIATION, processedReplay.error_coefficient_of_variation)
|
|
||||||
// .set(SCORES.ERROR_KURTOSIS, processedReplay.error_kurtosis)
|
|
||||||
// .set(SCORES.ERROR_SKEWNESS, processedReplay.error_skewness)
|
|
||||||
// .set(SCORES.SNAPS, processedReplay.snaps)
|
|
||||||
// .set(SCORES.EDGE_HITS, processedReplay.edge_hits)
|
|
||||||
// .set(SCORES.KEYPRESSES_TIMES, processedReplay.keypresses_times?.toTypedArray())
|
|
||||||
// .set(SCORES.KEYPRESSES_MEDIAN, processedReplay.keypresses_median)
|
|
||||||
// .set(SCORES.KEYPRESSES_MEDIAN_ADJUSTED, processedReplay.keypresses_median_adjusted)
|
|
||||||
// .set(SCORES.KEYPRESSES_STANDARD_DEVIATION, processedReplay.keypresses_standard_deviation)
|
|
||||||
// .set(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED, processedReplay.keypresses_standard_deviation_adjusted)
|
|
||||||
// .set(SCORES.SLIDEREND_RELEASE_TIMES, processedReplay.sliderend_release_times?.toTypedArray())
|
|
||||||
// .set(SCORES.SLIDEREND_RELEASE_MEDIAN, processedReplay.sliderend_release_median)
|
|
||||||
// .set(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, processedReplay.sliderend_release_median_adjusted)
|
|
||||||
// .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION, processedReplay.sliderend_release_standard_deviation)
|
|
||||||
// .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, processedReplay.sliderend_release_standard_deviation_adjusted)
|
|
||||||
// .set(SCORES.JUDGEMENTS, CompressJudgements.compress(processedReplay.judgements))
|
|
||||||
// .where(SCORES.REPLAY_ID.eq(score.replayId))
|
|
||||||
// .returningResult(SCORES.ID)
|
|
||||||
// .fetchOne()?.getValue(SCORES.ID)
|
|
||||||
//
|
|
||||||
// if (scoreId == null) {
|
|
||||||
// this.logger.debug("Weird, failed to insert score into scores table. At least, it did not return an ID.")
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// dslContext.update(SCORES)
|
|
||||||
// .set(SCORES.VERSION, CURRENT_VERSION)
|
|
||||||
// .where(SCORES.ID.eq(scoreId))
|
|
||||||
// .execute()
|
|
||||||
// }
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -69,7 +69,7 @@ class ImportScores(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val CURRENT_VERSION = 7
|
const val CURRENT_VERSION = 8
|
||||||
const val SLEEP_AFTER_API_CALL = 500L
|
const val SLEEP_AFTER_API_CALL = 500L
|
||||||
const val UPDATE_USER_EVERY_DAYS = 7L
|
const val UPDATE_USER_EVERY_DAYS = 7L
|
||||||
const val UPDATE_BANNED_USERS_EVERY_DAYS = 3L
|
const val UPDATE_BANNED_USERS_EVERY_DAYS = 3L
|
||||||
@ -536,7 +536,7 @@ class ImportScores(
|
|||||||
data class ReplayDto(
|
data class ReplayDto(
|
||||||
val replayId: Long,
|
val replayId: Long,
|
||||||
val replayMods: Int,
|
val replayMods: Int,
|
||||||
val replayData: String
|
val replayData: ByteArray
|
||||||
)
|
)
|
||||||
|
|
||||||
val sw = StopWatch()
|
val sw = StopWatch()
|
||||||
@ -569,7 +569,8 @@ class ImportScores(
|
|||||||
|
|
||||||
val konataResults: List<ReplaySetComparison> = try {
|
val konataResults: List<ReplaySetComparison> = try {
|
||||||
val replaysForKonata = allReplays.map {
|
val replaysForKonata = allReplays.map {
|
||||||
Replay(string = it.replayData, id = it.replayId, mods = it.replayMods)
|
val replayData = CompressReplay.decompressReplayToString(it.replayData)
|
||||||
|
Replay(string = replayData, id = it.replayId, mods = it.replayMods)
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
compareReplaySet(replaysForKonata)
|
compareReplaySet(replaysForKonata)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package com.nisemoe.nise.search
|
package com.nisemoe.nise.search.score
|
||||||
|
|
||||||
import jakarta.validation.Constraint
|
import jakarta.validation.Constraint
|
||||||
import jakarta.validation.ConstraintValidator
|
import jakarta.validation.ConstraintValidator
|
||||||
@ -15,17 +15,17 @@ annotation class ValidChildQueriesDepth(
|
|||||||
val payload: Array<KClass<out Any>> = []
|
val payload: Array<KClass<out Any>> = []
|
||||||
)
|
)
|
||||||
|
|
||||||
class ChildQueriesDepthValidator : ConstraintValidator<ValidChildQueriesDepth, List<SearchController.SearchQuery>> {
|
class ChildQueriesDepthValidator : ConstraintValidator<ValidChildQueriesDepth, List<ScoreSearchController.SearchQuery>> {
|
||||||
|
|
||||||
override fun initialize(constraintAnnotation: ValidChildQueriesDepth?) {
|
override fun initialize(constraintAnnotation: ValidChildQueriesDepth?) {
|
||||||
super.initialize(constraintAnnotation)
|
super.initialize(constraintAnnotation)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isValid(queries: List<SearchController.SearchQuery>?, context: ConstraintValidatorContext): Boolean {
|
override fun isValid(queries: List<ScoreSearchController.SearchQuery>?, context: ConstraintValidatorContext): Boolean {
|
||||||
return queries?.all { validateChildQueriesDepth(it, 1) } ?: true
|
return queries?.all { validateChildQueriesDepth(it, 1) } ?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateChildQueriesDepth(query: SearchController.SearchQuery, currentDepth: Int): Boolean {
|
private fun validateChildQueriesDepth(query: ScoreSearchController.SearchQuery, currentDepth: Int): Boolean {
|
||||||
if (currentDepth > 10) return false
|
if (currentDepth > 10) return false
|
||||||
query.childQueries?.forEach {
|
query.childQueries?.forEach {
|
||||||
if (!validateChildQueriesDepth(it, currentDepth + 1)) return false
|
if (!validateChildQueriesDepth(it, currentDepth + 1)) return false
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.nisemoe.nise.search
|
package com.nisemoe.nise.search.score
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude
|
import com.fasterxml.jackson.annotation.JsonInclude
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestHeader
|
|||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
class SearchController(
|
class ScoreSearchController(
|
||||||
private val searchService: SearchService
|
private val scoreSearchService: ScoreSearchService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(javaClass)
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
@ -28,7 +28,7 @@ class SearchController(
|
|||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
data class SearchResponse(
|
data class SearchResponse(
|
||||||
val scores: List<SearchResponseEntry>,
|
val results: List<SearchResponseEntry>,
|
||||||
val pagination: SearchResponsePagination
|
val pagination: SearchResponsePagination
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -61,7 +61,8 @@ class SearchController(
|
|||||||
val user_count_300: Long?,
|
val user_count_300: Long?,
|
||||||
val user_count_100: Long?,
|
val user_count_100: Long?,
|
||||||
val user_count_50: Long?,
|
val user_count_50: Long?,
|
||||||
val user_count_miss: Int?,
|
val user_count_miss: Long?,
|
||||||
|
val user_is_banned: Boolean?,
|
||||||
|
|
||||||
// Score fields
|
// Score fields
|
||||||
val id: Int?,
|
val id: Int?,
|
||||||
@ -150,7 +151,7 @@ class SearchController(
|
|||||||
val stopwatch = StopWatch()
|
val stopwatch = StopWatch()
|
||||||
stopwatch.start()
|
stopwatch.start()
|
||||||
try {
|
try {
|
||||||
val response = this.searchService.search(request)
|
val response = this.scoreSearchService.search(request)
|
||||||
return ResponseEntity.ok(response)
|
return ResponseEntity.ok(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
this.logger.error("Error while searching: {}", e.stackTraceToString())
|
this.logger.error("Error while searching: {}", e.stackTraceToString())
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.nisemoe.nise.search
|
package com.nisemoe.nise.search.score
|
||||||
|
|
||||||
import com.nisemoe.generated.tables.references.BEATMAPS
|
import com.nisemoe.generated.tables.references.BEATMAPS
|
||||||
import com.nisemoe.generated.tables.references.SCORES
|
import com.nisemoe.generated.tables.references.SCORES
|
||||||
@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping
|
|||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
class SearchSchemaController(
|
class ScoreSearchSchemaController(
|
||||||
private val authService: AuthService,
|
private val authService: AuthService,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@ -34,6 +34,7 @@ class SearchSchemaController(
|
|||||||
InternalSchemaField("user_count_100", "100s", Category.user, Type.number, false, "number of 100 hits", databaseField = USERS.COUNT_100),
|
InternalSchemaField("user_count_100", "100s", Category.user, Type.number, false, "number of 100 hits", databaseField = USERS.COUNT_100),
|
||||||
InternalSchemaField("user_count_50", "50s", Category.user, Type.number, false, "number of 50 hits", databaseField = USERS.COUNT_50),
|
InternalSchemaField("user_count_50", "50s", Category.user, Type.number, false, "number of 50 hits", databaseField = USERS.COUNT_50),
|
||||||
InternalSchemaField("user_count_miss", "Misses", Category.user, Type.number, false, "missed hits", databaseField = USERS.COUNT_MISS),
|
InternalSchemaField("user_count_miss", "Misses", Category.user, Type.number, false, "missed hits", databaseField = USERS.COUNT_MISS),
|
||||||
|
InternalSchemaField("user_is_banned", "Is Banned", Category.user, Type.boolean, false, "is the user banned?", databaseField = USERS.IS_BANNED),
|
||||||
|
|
||||||
// Score fields
|
// Score fields
|
||||||
InternalSchemaField("is_banned", "Banned", Category.score, Type.boolean, false, "has to score been deleted?", databaseField = SCORES.IS_BANNED),
|
InternalSchemaField("is_banned", "Banned", Category.score, Type.boolean, false, "has to score been deleted?", databaseField = SCORES.IS_BANNED),
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.nisemoe.nise.search
|
package com.nisemoe.nise.search.score
|
||||||
|
|
||||||
import com.nisemoe.generated.tables.references.BEATMAPS
|
import com.nisemoe.generated.tables.references.BEATMAPS
|
||||||
import com.nisemoe.generated.tables.references.SCORES
|
import com.nisemoe.generated.tables.references.SCORES
|
||||||
@ -11,12 +11,12 @@ import org.springframework.stereotype.Service
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class SearchService(
|
class ScoreSearchService(
|
||||||
private val dslContext: DSLContext,
|
private val dslContext: DSLContext,
|
||||||
private val authService: AuthService
|
private val authService: AuthService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun search(request: SearchController.SearchRequest): SearchController.SearchResponse {
|
fun search(request: ScoreSearchController.SearchRequest): ScoreSearchController.SearchResponse {
|
||||||
var baseQuery = DSL.noCondition()
|
var baseQuery = DSL.noCondition()
|
||||||
for (query in request.queries.filter { it.predicates.isNotEmpty() }) {
|
for (query in request.queries.filter { it.predicates.isNotEmpty() }) {
|
||||||
val condition = buildCondition(query)
|
val condition = buildCondition(query)
|
||||||
@ -44,6 +44,7 @@ class SearchService(
|
|||||||
USERS.COUNT_300,
|
USERS.COUNT_300,
|
||||||
USERS.COUNT_100,
|
USERS.COUNT_100,
|
||||||
USERS.COUNT_50,
|
USERS.COUNT_50,
|
||||||
|
USERS.COUNT_MISS,
|
||||||
|
|
||||||
// Scores fields
|
// Scores fields
|
||||||
SCORES.ID,
|
SCORES.ID,
|
||||||
@ -107,8 +108,8 @@ class SearchService(
|
|||||||
if (request.sorting.field.isNotBlank())
|
if (request.sorting.field.isNotBlank())
|
||||||
orderBy(buildSorting(request.sorting))
|
orderBy(buildSorting(request.sorting))
|
||||||
}
|
}
|
||||||
.offset((request.page - 1) * SearchController.RESULTS_PER_PAGE)
|
.offset((request.page - 1) * ScoreSearchController.RESULTS_PER_PAGE)
|
||||||
.limit(SearchController.RESULTS_PER_PAGE)
|
.limit(ScoreSearchController.RESULTS_PER_PAGE)
|
||||||
|
|
||||||
val results = query
|
val results = query
|
||||||
.fetch()
|
.fetch()
|
||||||
@ -121,19 +122,19 @@ class SearchService(
|
|||||||
.where(baseQuery)
|
.where(baseQuery)
|
||||||
.fetchOne(0, Int::class.java) ?: 0
|
.fetchOne(0, Int::class.java) ?: 0
|
||||||
|
|
||||||
return SearchController.SearchResponse(
|
return ScoreSearchController.SearchResponse(
|
||||||
scores = mapRecordToScores(results),
|
results = mapRecordToScores(results),
|
||||||
pagination = SearchController.SearchResponsePagination(
|
pagination = ScoreSearchController.SearchResponsePagination(
|
||||||
currentPage = request.page,
|
currentPage = request.page,
|
||||||
pageSize = SearchController.RESULTS_PER_PAGE,
|
pageSize = ScoreSearchController.RESULTS_PER_PAGE,
|
||||||
totalResults = totalResults
|
totalResults = totalResults
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapRecordToScores(results: Result<Record>): MutableList<SearchController.SearchResponseEntry> =
|
private fun mapRecordToScores(results: Result<Record>): MutableList<ScoreSearchController.SearchResponseEntry> =
|
||||||
results.map {
|
results.map {
|
||||||
SearchController.SearchResponseEntry(
|
ScoreSearchController.SearchResponseEntry(
|
||||||
// User fields
|
// User fields
|
||||||
user_id = it.get(SCORES.USER_ID),
|
user_id = it.get(SCORES.USER_ID),
|
||||||
user_username = it.get(USERS.USERNAME),
|
user_username = it.get(USERS.USERNAME),
|
||||||
@ -150,7 +151,8 @@ class SearchService(
|
|||||||
user_count_300 = it.get(USERS.COUNT_300),
|
user_count_300 = it.get(USERS.COUNT_300),
|
||||||
user_count_100 = it.get(USERS.COUNT_100),
|
user_count_100 = it.get(USERS.COUNT_100),
|
||||||
user_count_50 = it.get(USERS.COUNT_50),
|
user_count_50 = it.get(USERS.COUNT_50),
|
||||||
user_count_miss = it.get(SCORES.COUNT_MISS),
|
user_count_miss = it.get(USERS.COUNT_MISS),
|
||||||
|
user_is_banned = it.get(USERS.IS_BANNED),
|
||||||
|
|
||||||
// Score fields
|
// Score fields
|
||||||
id = it.get(SCORES.ID),
|
id = it.get(SCORES.ID),
|
||||||
@ -199,7 +201,7 @@ class SearchService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildCondition(query: SearchController.SearchQuery): Condition {
|
fun buildCondition(query: ScoreSearchController.SearchQuery): Condition {
|
||||||
// Handle base predicates
|
// Handle base predicates
|
||||||
var baseCondition = buildPredicateCondition(query.predicates.first())
|
var baseCondition = buildPredicateCondition(query.predicates.first())
|
||||||
query.predicates.drop(1).forEach { predicate ->
|
query.predicates.drop(1).forEach { predicate ->
|
||||||
@ -226,7 +228,7 @@ class SearchService(
|
|||||||
return baseCondition
|
return baseCondition
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSorting(sorting: SearchController.SearchSorting): OrderField<*> {
|
private fun buildSorting(sorting: ScoreSearchController.SearchSorting): OrderField<*> {
|
||||||
val field = mapPredicateFieldToDatabaseField(sorting.field)
|
val field = mapPredicateFieldToDatabaseField(sorting.field)
|
||||||
return when (sorting.order.lowercase()) {
|
return when (sorting.order.lowercase()) {
|
||||||
"asc" -> field.asc()
|
"asc" -> field.asc()
|
||||||
@ -235,7 +237,7 @@ class SearchService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildPredicateCondition(predicate: SearchController.SearchPredicate): Condition {
|
private fun buildPredicateCondition(predicate: ScoreSearchController.SearchPredicate): Condition {
|
||||||
val field = mapPredicateFieldToDatabaseField(predicate.field.name)
|
val field = mapPredicateFieldToDatabaseField(predicate.field.name)
|
||||||
return when (predicate.field.type.lowercase()) {
|
return when (predicate.field.type.lowercase()) {
|
||||||
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator.operatorType, predicate.value.toDouble())
|
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator.operatorType, predicate.value.toDouble())
|
||||||
@ -250,7 +252,7 @@ class SearchService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> {
|
private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> {
|
||||||
val databaseField = SearchSchemaController.internalFields.first {
|
val databaseField = ScoreSearchSchemaController.internalFields.first {
|
||||||
it.name == predicateName && it.databaseField != null
|
it.name == predicateName && it.databaseField != null
|
||||||
}
|
}
|
||||||
return databaseField.databaseField!!
|
return databaseField.databaseField!!
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.nisemoe.nise.search.user
|
||||||
|
|
||||||
|
import jakarta.validation.Constraint
|
||||||
|
import jakarta.validation.ConstraintValidator
|
||||||
|
import jakarta.validation.ConstraintValidatorContext
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
@MustBeDocumented
|
||||||
|
@Constraint(validatedBy = [UserChildQueriesDepthValidator::class])
|
||||||
|
@Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FIELD])
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
annotation class ValidChildQueriesDepth(
|
||||||
|
val message: String = "Exceeds maximum depth of child queries",
|
||||||
|
val groups: Array<KClass<*>> = [],
|
||||||
|
val payload: Array<KClass<out Any>> = []
|
||||||
|
)
|
||||||
|
|
||||||
|
class UserChildQueriesDepthValidator : ConstraintValidator<ValidChildQueriesDepth, List<UserSearchController.SearchQuery>> {
|
||||||
|
|
||||||
|
override fun initialize(constraintAnnotation: ValidChildQueriesDepth?) {
|
||||||
|
super.initialize(constraintAnnotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isValid(queries: List<UserSearchController.SearchQuery>?, context: ConstraintValidatorContext): Boolean {
|
||||||
|
return queries?.all { validateChildQueriesDepth(it, 1) } ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateChildQueriesDepth(query: UserSearchController.SearchQuery, currentDepth: Int): Boolean {
|
||||||
|
if (currentDepth > 10) return false
|
||||||
|
query.childQueries?.forEach {
|
||||||
|
if (!validateChildQueriesDepth(it, currentDepth + 1)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
package com.nisemoe.nise.search.user
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import jakarta.validation.constraints.Min
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import jakarta.validation.constraints.Size
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.util.StopWatch
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class UserSearchController(
|
||||||
|
private val userSearchService: UserSearchService
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val RESULTS_PER_PAGE = 50
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
data class SearchResponse(
|
||||||
|
val results: List<SearchResponseEntry>,
|
||||||
|
val pagination: SearchResponsePagination
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchResponsePagination(
|
||||||
|
val currentPage: Int,
|
||||||
|
val pageSize: Int,
|
||||||
|
val totalResults: Int
|
||||||
|
) {
|
||||||
|
|
||||||
|
val totalPages: Int
|
||||||
|
get() = if (totalResults % pageSize == 0) totalResults / pageSize else totalResults / pageSize + 1
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
data class SearchResponseEntry(
|
||||||
|
val user_id: Long?,
|
||||||
|
val username: String?,
|
||||||
|
val join_date: String?,
|
||||||
|
val country: String?,
|
||||||
|
val country_rank: Long?,
|
||||||
|
val rank: Long?,
|
||||||
|
val pp_raw: Double?,
|
||||||
|
val accuracy: Double?,
|
||||||
|
val playcount: Long?,
|
||||||
|
val total_score: Long?,
|
||||||
|
val ranked_score: Long?,
|
||||||
|
val seconds_played: Long?,
|
||||||
|
val count_300: Long?,
|
||||||
|
val count_100: Long?,
|
||||||
|
val count_50: Long?,
|
||||||
|
val count_miss: Long?,
|
||||||
|
val is_banned: Boolean?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchRequest(
|
||||||
|
@Valid @field:ValidChildQueriesDepth @field:Size(max = 10) val queries: List<SearchQuery>,
|
||||||
|
@Valid val sorting: SearchSorting,
|
||||||
|
@field:Min(1) val page: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchSorting(
|
||||||
|
@field:NotBlank @field:Size(max = 300) val field: String,
|
||||||
|
@field:NotBlank @field:Size(max = 300) val order: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchQuery(
|
||||||
|
@field:NotBlank @field:Size(max = 300) val logicalOperator: String,
|
||||||
|
@Valid @field:Size(max = 10) val predicates: List<SearchPredicate>,
|
||||||
|
@Valid @field:ValidChildQueriesDepth @field:Size(max = 10) val childQueries: List<SearchQuery>?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchPredicate(
|
||||||
|
@Valid val field: SearchField,
|
||||||
|
@Valid val operator: SearchPredicateOperator,
|
||||||
|
@field:NotBlank @field:Size(max = 300) val value: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchPredicateOperator(
|
||||||
|
@field:NotBlank @field:Size(max = 300) val operatorType: String,
|
||||||
|
@field:NotBlank @field:Size(max = 300) val acceptsValues: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchField(
|
||||||
|
@field:NotBlank @field:Size(max = 300) val name: String,
|
||||||
|
@field:NotBlank @field:Size(max = 300) val type: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@PostMapping("search-user")
|
||||||
|
fun doSearch(@RequestBody @Valid request: SearchRequest, @RequestHeader("X-NISE-API") apiVersion: String): ResponseEntity<SearchResponse> {
|
||||||
|
if (apiVersion.isBlank())
|
||||||
|
return ResponseEntity.badRequest().build()
|
||||||
|
|
||||||
|
// TODO: CSRF
|
||||||
|
|
||||||
|
val stopwatch = StopWatch()
|
||||||
|
stopwatch.start()
|
||||||
|
try {
|
||||||
|
val response = this.userSearchService.search(request)
|
||||||
|
return ResponseEntity.ok(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
this.logger.error("Error while searching: {}", e.stackTraceToString())
|
||||||
|
return ResponseEntity.status(500).build()
|
||||||
|
} finally {
|
||||||
|
stopwatch.stop()
|
||||||
|
this.logger.info("Search took {} seconds", String.format("%.2f", stopwatch.totalTimeSeconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package com.nisemoe.nise.search.user
|
||||||
|
|
||||||
|
import com.nisemoe.generated.tables.references.USERS
|
||||||
|
import com.nisemoe.nise.service.AuthService
|
||||||
|
import org.jooq.Field
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class UserSearchSchemaController(
|
||||||
|
private val authService: AuthService,
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val internalFields = listOf(
|
||||||
|
// User fields
|
||||||
|
InternalSchemaField("user_id", "ID", Category.user, Type.number, false, "unique identifier for a user", databaseField = USERS.USER_ID),
|
||||||
|
InternalSchemaField("username", "Username", Category.user, Type.string, true, "user's name", databaseField = USERS.USERNAME),
|
||||||
|
InternalSchemaField("join_date", "Join Date", Category.user, Type.datetime, false, "when the user joined", databaseField = USERS.JOIN_DATE),
|
||||||
|
InternalSchemaField("country", "Country", Category.user, Type.flag, true, "user's country flag", databaseField = USERS.COUNTRY),
|
||||||
|
InternalSchemaField("country_rank", "Country Rank", Category.user, Type.number, false, "ranking within user's country", databaseField = USERS.COUNTRY_RANK),
|
||||||
|
InternalSchemaField("rank", "Rank", Category.user, Type.number, false, "global ranking", databaseField = USERS.RANK),
|
||||||
|
InternalSchemaField("pp_raw", "User PP", Category.user, Type.number, true, "performance points", databaseField = USERS.PP_RAW),
|
||||||
|
InternalSchemaField("accuracy", "User Accuracy", Category.user, Type.number, false, "hit accuracy percentage", databaseField = USERS.ACCURACY),
|
||||||
|
InternalSchemaField("playcount", "Playcount", Category.user, Type.number, false, "total plays", databaseField = USERS.PLAYCOUNT),
|
||||||
|
InternalSchemaField("total_score", "Total Score", Category.user, Type.number, false, "cumulative score", databaseField = USERS.TOTAL_SCORE),
|
||||||
|
InternalSchemaField("ranked_score", "Ranked Score", Category.user, Type.number, false, "score from ranked maps", databaseField = USERS.RANKED_SCORE),
|
||||||
|
InternalSchemaField("seconds_played", "Play Time", Category.user, Type.playtime, true, "total play time in seconds", databaseField = USERS.SECONDS_PLAYED),
|
||||||
|
InternalSchemaField("count_300", "300s", Category.user, Type.number, false, "number of 300 hits", databaseField = USERS.COUNT_300),
|
||||||
|
InternalSchemaField("count_100", "100s", Category.user, Type.number, false, "number of 100 hits", databaseField = USERS.COUNT_100),
|
||||||
|
InternalSchemaField("count_50", "50s", Category.user, Type.number, false, "number of 50 hits", databaseField = USERS.COUNT_50),
|
||||||
|
InternalSchemaField("count_miss", "Misses", Category.user, Type.number, false, "missed hits", databaseField = USERS.COUNT_MISS),
|
||||||
|
InternalSchemaField("is_banned", "Is Banned", Category.user, Type.boolean, false, "is the user banned?", databaseField = USERS.IS_BANNED),
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
data class InternalSchemaField(
|
||||||
|
val name: String,
|
||||||
|
val shortName: String,
|
||||||
|
val category: Category,
|
||||||
|
val type: Type,
|
||||||
|
val active: Boolean,
|
||||||
|
val description: String,
|
||||||
|
|
||||||
|
val isPrivileged: Boolean = false,
|
||||||
|
val databaseField: Field<*>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SchemaField(
|
||||||
|
val name: String,
|
||||||
|
val shortName: String,
|
||||||
|
val category: Category,
|
||||||
|
val type: Type,
|
||||||
|
val active: Boolean,
|
||||||
|
val description: String
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class Category {
|
||||||
|
user, score, beatmap
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Type {
|
||||||
|
number, string, flag, grade, boolean, datetime, playtime
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SearchSchema(
|
||||||
|
val fields: List<SchemaField>
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("search-user/schema")
|
||||||
|
fun getSearchSchema(): ResponseEntity<SearchSchema> {
|
||||||
|
// Map to SchemaField
|
||||||
|
val isUserAdmin = authService.isAdmin()
|
||||||
|
val fields = internalFields
|
||||||
|
.filter {
|
||||||
|
return@filter !(it.isPrivileged && !isUserAdmin)
|
||||||
|
}
|
||||||
|
.map {
|
||||||
|
SchemaField(
|
||||||
|
name = it.name,
|
||||||
|
shortName = it.shortName,
|
||||||
|
category = it.category,
|
||||||
|
type = it.type,
|
||||||
|
active = it.active,
|
||||||
|
description = it.description
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val schema = SearchSchema(fields)
|
||||||
|
return ResponseEntity.ok(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
package com.nisemoe.nise.search.user
|
||||||
|
|
||||||
|
import com.nisemoe.generated.tables.references.SCORES
|
||||||
|
import com.nisemoe.generated.tables.references.USERS
|
||||||
|
import com.nisemoe.nise.Format
|
||||||
|
import org.jooq.*
|
||||||
|
import org.jooq.impl.DSL
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class UserSearchService(
|
||||||
|
private val dslContext: DSLContext
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun search(request: UserSearchController.SearchRequest): UserSearchController.SearchResponse {
|
||||||
|
var baseQuery = DSL.noCondition()
|
||||||
|
for (query in request.queries.filter { it.predicates.isNotEmpty() }) {
|
||||||
|
val condition = buildCondition(query)
|
||||||
|
baseQuery = when (query.logicalOperator.lowercase()) {
|
||||||
|
"and" -> baseQuery.and(condition)
|
||||||
|
"or" -> baseQuery.or(condition)
|
||||||
|
else -> throw IllegalArgumentException("Invalid logical operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectFields = mutableListOf<Field<*>>(
|
||||||
|
USERS.USERNAME,
|
||||||
|
USERS.USER_ID,
|
||||||
|
USERS.JOIN_DATE,
|
||||||
|
USERS.COUNTRY,
|
||||||
|
USERS.COUNTRY_RANK,
|
||||||
|
USERS.RANK,
|
||||||
|
USERS.PP_RAW,
|
||||||
|
USERS.ACCURACY,
|
||||||
|
USERS.PLAYCOUNT,
|
||||||
|
USERS.TOTAL_SCORE,
|
||||||
|
USERS.RANKED_SCORE,
|
||||||
|
USERS.SECONDS_PLAYED,
|
||||||
|
USERS.COUNT_300,
|
||||||
|
USERS.COUNT_100,
|
||||||
|
USERS.COUNT_50,
|
||||||
|
USERS.COUNT_MISS,
|
||||||
|
USERS.IS_BANNED
|
||||||
|
)
|
||||||
|
|
||||||
|
val query = dslContext
|
||||||
|
.select(selectFields)
|
||||||
|
.from(USERS)
|
||||||
|
.where(baseQuery)
|
||||||
|
.apply {
|
||||||
|
if (request.sorting.field.isNotBlank())
|
||||||
|
orderBy(buildSorting(request.sorting))
|
||||||
|
}
|
||||||
|
.offset((request.page - 1) * UserSearchController.RESULTS_PER_PAGE)
|
||||||
|
.limit(UserSearchController.RESULTS_PER_PAGE)
|
||||||
|
|
||||||
|
val results = query
|
||||||
|
.fetch()
|
||||||
|
|
||||||
|
// Get total results
|
||||||
|
val totalResults = dslContext.selectCount()
|
||||||
|
.from(USERS)
|
||||||
|
.where(baseQuery)
|
||||||
|
.fetchOne(0, Int::class.java) ?: 0
|
||||||
|
|
||||||
|
return UserSearchController.SearchResponse(
|
||||||
|
results = mapRecordToScores(results),
|
||||||
|
pagination = UserSearchController.SearchResponsePagination(
|
||||||
|
currentPage = request.page,
|
||||||
|
pageSize = UserSearchController.RESULTS_PER_PAGE,
|
||||||
|
totalResults = totalResults
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapRecordToScores(results: Result<Record>): MutableList<UserSearchController.SearchResponseEntry> =
|
||||||
|
results.map {
|
||||||
|
UserSearchController.SearchResponseEntry(
|
||||||
|
user_id = it.get(SCORES.USER_ID),
|
||||||
|
username = it.get(USERS.USERNAME),
|
||||||
|
join_date = it.get(USERS.JOIN_DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
|
||||||
|
country = it.get(USERS.COUNTRY),
|
||||||
|
country_rank = it.get(USERS.COUNTRY_RANK),
|
||||||
|
rank = it.get(USERS.RANK),
|
||||||
|
pp_raw = it.get(USERS.PP_RAW)?.roundToInt()?.toDouble(),
|
||||||
|
accuracy = it.get(USERS.ACCURACY),
|
||||||
|
playcount = it.get(USERS.PLAYCOUNT),
|
||||||
|
total_score = it.get(USERS.TOTAL_SCORE),
|
||||||
|
ranked_score = it.get(USERS.RANKED_SCORE),
|
||||||
|
seconds_played = it.get(USERS.SECONDS_PLAYED),
|
||||||
|
count_300 = it.get(USERS.COUNT_300),
|
||||||
|
count_100 = it.get(USERS.COUNT_100),
|
||||||
|
count_50 = it.get(USERS.COUNT_50),
|
||||||
|
count_miss = it.get(USERS.COUNT_MISS),
|
||||||
|
is_banned = it.get(USERS.IS_BANNED)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildCondition(query: UserSearchController.SearchQuery): Condition {
|
||||||
|
// Handle base predicates
|
||||||
|
var baseCondition = buildPredicateCondition(query.predicates.first())
|
||||||
|
query.predicates.drop(1).forEach { predicate ->
|
||||||
|
baseCondition = when (query.logicalOperator.lowercase()) {
|
||||||
|
"and" -> baseCondition.and(buildPredicateCondition(predicate))
|
||||||
|
"or" -> baseCondition.or(buildPredicateCondition(predicate))
|
||||||
|
else -> throw IllegalArgumentException("Invalid logical operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle child queries
|
||||||
|
if(query.childQueries.isNullOrEmpty())
|
||||||
|
return baseCondition
|
||||||
|
|
||||||
|
query.childQueries.forEach { childQuery ->
|
||||||
|
val childCondition = buildCondition(childQuery) // Recursively build condition for child queries
|
||||||
|
baseCondition = when (childQuery.logicalOperator.lowercase()) {
|
||||||
|
"and" -> baseCondition.and(childCondition)
|
||||||
|
"or" -> baseCondition.or(childCondition)
|
||||||
|
else -> throw IllegalArgumentException("Invalid logical operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseCondition
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSorting(sorting: UserSearchController.SearchSorting): OrderField<*> {
|
||||||
|
val field = mapPredicateFieldToDatabaseField(sorting.field)
|
||||||
|
return when (sorting.order.lowercase()) {
|
||||||
|
"asc" -> field.asc()
|
||||||
|
"desc" -> field.desc()
|
||||||
|
else -> throw IllegalArgumentException("Invalid sorting order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPredicateCondition(predicate: UserSearchController.SearchPredicate): Condition {
|
||||||
|
val field = mapPredicateFieldToDatabaseField(predicate.field.name)
|
||||||
|
return when (predicate.field.type.lowercase()) {
|
||||||
|
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator.operatorType, predicate.value.toDouble())
|
||||||
|
"string" -> buildStringCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
|
||||||
|
"boolean" -> buildBooleanCondition(field as Field<Boolean>, predicate.operator.operatorType, predicate.value.toBoolean())
|
||||||
|
"flag" -> buildStringCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
|
||||||
|
"grade" -> buildGradeCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
|
||||||
|
"datetime" -> buildDatetimeCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
|
||||||
|
"playtime" -> buildNumberCondition(field as Field<Double>, predicate.operator.operatorType, predicate.value.toDouble())
|
||||||
|
else -> throw IllegalArgumentException("Invalid field type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> {
|
||||||
|
val databaseField = UserSearchSchemaController.internalFields.first {
|
||||||
|
it.name == predicateName && it.databaseField != null
|
||||||
|
}
|
||||||
|
return databaseField.databaseField!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildBooleanCondition(field: Field<Boolean>, operator: String, value: Boolean): Condition {
|
||||||
|
return when (operator) {
|
||||||
|
"=" -> field.eq(value)
|
||||||
|
"!=" -> field.ne(value)
|
||||||
|
else -> throw IllegalArgumentException("Invalid operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGradeCondition(field: Field<String>, operator: String, value: String): Condition {
|
||||||
|
return when (value) {
|
||||||
|
"SS", "S", "A", "B", "C", "D" -> {
|
||||||
|
val valuesToMatch = when (value) {
|
||||||
|
"SS" -> listOf("Grade.SS", "Grade.SSH")
|
||||||
|
"S" -> listOf("Grade.S", "Grade.SH")
|
||||||
|
else -> listOf("Grade.$value")
|
||||||
|
}
|
||||||
|
when (operator) {
|
||||||
|
"=" -> field.`in`(valuesToMatch)
|
||||||
|
"!=" -> field.notIn(valuesToMatch)
|
||||||
|
else -> throw IllegalArgumentException("Invalid operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> throw IllegalArgumentException("Invalid grade value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNumberCondition(field: Field<Double>, operator: String, value: Double): Condition {
|
||||||
|
return when (operator) {
|
||||||
|
"=" -> field.eq(value)
|
||||||
|
">" -> field.gt(value)
|
||||||
|
"<" -> field.lt(value)
|
||||||
|
">=" -> field.ge(value)
|
||||||
|
"<=" -> field.le(value)
|
||||||
|
"!=" -> field.ne(value)
|
||||||
|
else -> throw IllegalArgumentException("Invalid operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStringCondition(field: Field<String>, operator: String, value: String): Condition {
|
||||||
|
return when (operator.lowercase()) {
|
||||||
|
"=" -> field.eq(value)
|
||||||
|
"contains" -> field.containsIgnoreCase(value)
|
||||||
|
"like" -> field.likeIgnoreCase(
|
||||||
|
// Escape special characters for LIKE if needed
|
||||||
|
value.replace("%", "\\%").replace("_", "\\_")
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Invalid operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDatetimeCondition(field: Field<String>, operator: String, value: String): Condition {
|
||||||
|
return when (operator.lowercase()) {
|
||||||
|
"before" -> field.lessThan(value)
|
||||||
|
"after" -> field.greaterThan(value)
|
||||||
|
else -> throw IllegalArgumentException("Invalid operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
package com.nisemoe.nise.service
|
package com.nisemoe.nise.service
|
||||||
|
|
||||||
import com.aayushatharva.brotli4j.Brotli4jLoader
|
|
||||||
import com.aayushatharva.brotli4j.decoder.Decoder
|
|
||||||
import com.aayushatharva.brotli4j.encoder.Encoder
|
import com.aayushatharva.brotli4j.encoder.Encoder
|
||||||
import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream
|
import com.aayushatharva.brotli4j.decoder.Decoder
|
||||||
import java.util.*
|
import com.aayushatharva.brotli4j.Brotli4jLoader
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
data class ReplayStruct(val version: Int, val replayData: ByteArray)
|
||||||
|
|
||||||
class CompressReplay {
|
class CompressReplay {
|
||||||
|
|
||||||
@ -14,6 +16,8 @@ class CompressReplay {
|
|||||||
Brotli4jLoader.ensureAvailability()
|
Brotli4jLoader.ensureAvailability()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val CURRENT_VERSION = 2
|
||||||
|
|
||||||
private val brotliParameters: Encoder.Parameters = Encoder.Parameters()
|
private val brotliParameters: Encoder.Parameters = Encoder.Parameters()
|
||||||
.setQuality(11)
|
.setQuality(11)
|
||||||
|
|
||||||
@ -22,15 +26,57 @@ class CompressReplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun compressReplay(replay: ByteArray): ByteArray {
|
fun compressReplay(replay: ByteArray): ByteArray {
|
||||||
// val replayData = Base64.getDecoder().decode(replay).inputStream().use { byteStream ->
|
val existingStruct = readReplayStruct(replay)
|
||||||
// LZMACompressorInputStream(byteStream).readBytes()
|
return if (existingStruct != null && existingStruct.version <= CURRENT_VERSION) {
|
||||||
// }
|
replay
|
||||||
|
} else {
|
||||||
|
val compressedData = Encoder.compress(replay, brotliParameters)
|
||||||
|
val newStruct = ReplayStruct(CURRENT_VERSION, compressedData)
|
||||||
|
serializeReplayStruct(newStruct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Encoder.compress(replay, brotliParameters)
|
fun decompressReplayToString(replay: ByteArray): String {
|
||||||
|
return String(decompressReplay(replay), Charsets.UTF_8).trimEnd(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decompressReplay(replay: ByteArray): ByteArray {
|
fun decompressReplay(replay: ByteArray): ByteArray {
|
||||||
return Decoder.decompress(replay).decompressedData
|
val replayStruct = readReplayStruct(replay)
|
||||||
|
return if (replayStruct != null) {
|
||||||
|
val decompressedResult = Decoder.decompress(replayStruct.replayData)
|
||||||
|
if (decompressedResult != null && decompressedResult.decompressedData != null) {
|
||||||
|
decompressedResult.decompressedData
|
||||||
|
} else {
|
||||||
|
replayStruct.replayData
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
replay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAGIC_NUMBER = 0x12345678 // a unique signature to identify our format
|
||||||
|
|
||||||
|
private fun readReplayStruct(data: ByteArray): ReplayStruct? {
|
||||||
|
if (data.size > 8) { // 4 bytes for the magic number + 4 bytes for the version
|
||||||
|
val buffer = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
val magic = buffer.int
|
||||||
|
if (magic == MAGIC_NUMBER) {
|
||||||
|
val version = buffer.int
|
||||||
|
val replayData = ByteArray(data.size - 8)
|
||||||
|
buffer.get(replayData)
|
||||||
|
return ReplayStruct(version, replayData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeReplayStruct(struct: ReplayStruct): ByteArray {
|
||||||
|
val buffer = ByteBuffer.allocate(8 + struct.replayData.size).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
buffer.putInt(MAGIC_NUMBER)
|
||||||
|
buffer.putInt(struct.version)
|
||||||
|
buffer.put(struct.replayData)
|
||||||
|
val byteArray = buffer.array()
|
||||||
|
return byteArray
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -18,7 +18,35 @@
|
|||||||
<section>
|
<section>
|
||||||
<h1 class="mt-4">## scores search</h1>
|
<h1 class="mt-4">## scores search</h1>
|
||||||
<p>score search is based on predicates. a predicate is a list of specifications/conditions to match results. predicates can be combined with operators such as <code>AND</code> and <code>OR</code></p>
|
<p>score search is based on predicates. a predicate is a list of specifications/conditions to match results. predicates can be combined with operators such as <code>AND</code> and <code>OR</code></p>
|
||||||
<p style="font-weight: bold; color: orange">COMING SOON; the route exists but its cancer for api users to form the requests.</p>
|
<ul>
|
||||||
|
<li><strong>ENDPOINT:</strong> <code>/api/search</code></li>
|
||||||
|
<li><strong>METHOD:</strong> POST</li>
|
||||||
|
<li><strong>POST FORMAT:</strong> JSON only</li>
|
||||||
|
<li><strong>POST BODY:</strong> use the <a [routerLink]="['/search']">score search</a> page and click on <code>export POST for /api/</code> to fabricate a request. its easier.</li>
|
||||||
|
</ul>
|
||||||
|
<p style="font-weight: bold; color: orange"><span style="font-size: 20px">[!]</span> set your request timeout to high values since some complex queries can take a while.</p>
|
||||||
|
Example:
|
||||||
|
<br>
|
||||||
|
<app-code-with-copy-button>
|
||||||
|
curl -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"queries":[{"predicates":[{"field":{"name":"user_rank","type":"number"},"operator":{"operatorType":"<","acceptsValues":"any"},"value":"50"},{"field":{"name":"ur","type":"number"},"operator":{"operatorType":"<","acceptsValues":"any"},"value":"120"}],"logicalOperator":"AND"}],"sorting":{"field":"user_id","order":"ASC"},"page":1}' https://nise.moe/api/search
|
||||||
|
</app-code-with-copy-button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h1 class="mt-4">## users search</h1>
|
||||||
|
<p>exactly like the <i>scores search</i>, but for users.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>ENDPOINT:</strong> <code>/api/search-user</code></li>
|
||||||
|
<li><strong>METHOD:</strong> POST</li>
|
||||||
|
<li><strong>POST FORMAT:</strong> JSON only</li>
|
||||||
|
<li><strong>POST BODY:</strong> use the <a [routerLink]="['/user-search']">user search</a> page and click on <code>export POST for /api/</code> to fabricate a request. its easier.</li>
|
||||||
|
</ul>
|
||||||
|
<p style="font-weight: bold; color: orange"><span style="font-size: 20px">[!]</span> set your request timeout to high values since some complex queries can take a while.</p>
|
||||||
|
Example:
|
||||||
|
<br>
|
||||||
|
<app-code-with-copy-button>
|
||||||
|
curl -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"queries":[{"predicates":[{"field":{"name":"rank","type":"number"},"operator":{"operatorType":">","acceptsValues":"any"},"value":"10"},{"field":{"name":"username","type":"string"},"operator":{"operatorType":"=","acceptsValues":"any"},"value":"degenerate"}],"logicalOperator":"AND"}],"sorting":{"field":"user_id","order":"ASC"},"page":1}' https://nise.moe/api/search-user
|
||||||
|
</app-code-with-copy-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@ -59,18 +87,22 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h1 class="mt-4">## get user details</h1>
|
<h1 class="mt-4">## get user details</h1>
|
||||||
<p>if you have an <code>userId</code>, you can retrieve everything we know 'bout that user.
|
<p>if you have an <code>userId</code> or <code>username</code>, you can retrieve everything we know 'bout that user.
|
||||||
<p style="font-weight: bold; color: #fa5c5c">>> <strong>userId</strong> is the username. sry.</p>
|
<p style="font-weight: bold; color: #fa5c5c">>> only pass EITHER of [userId, username], not both.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>ENDPOINT:</strong> <code>/api/user-details</code></li>
|
<li><strong>ENDPOINT:</strong> <code>/api/user-details</code></li>
|
||||||
<li><strong>METHOD:</strong> POST</li>
|
<li><strong>METHOD:</strong> POST</li>
|
||||||
<li><strong>POST FIELDS:</strong> userId: str (*required. case insensitive)</li>
|
<li><strong>POST FIELDS:</strong> userId: long〖OR〗username: str (case insensitive)</li>
|
||||||
<li><strong>POST FORMAT:</strong> JSON only</li>
|
<li><strong>POST FORMAT:</strong> JSON only</li>
|
||||||
</ul>
|
</ul>
|
||||||
Example:
|
Example:
|
||||||
<br>
|
<br>
|
||||||
<app-code-with-copy-button>
|
<app-code-with-copy-button>
|
||||||
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"userId": "degenerate"}' https://nise.moe/api/user-details
|
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"username": "degenerate"}' https://nise.moe/api/user-details
|
||||||
|
</app-code-with-copy-button>
|
||||||
|
<br><br>
|
||||||
|
<app-code-with-copy-button>
|
||||||
|
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"userId": 8184689}' https://nise.moe/api/user-details
|
||||||
</app-code-with-copy-button>
|
</app-code-with-copy-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -150,11 +182,11 @@
|
|||||||
<app-code-with-copy-button>
|
<app-code-with-copy-button>
|
||||||
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -F "replay=@replay1.osr" https://nise.moe/api/analyze
|
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -F "replay=@replay1.osr" https://nise.moe/api/analyze
|
||||||
</app-code-with-copy-button>
|
</app-code-with-copy-button>
|
||||||
<p>the response will include an <code>id</code> parameter, which identifies the replay you've uploaded.</p>
|
<p>the response will include an <code>id</code> parameter (str), which identifies the replay you've uploaded.</p>
|
||||||
<p>that id will be subsequently available at:</p>
|
<p>that id will be subsequently available at:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>WEB INTERFACE</strong>: https://nise.moe/c/{id}</li>
|
<li><strong>WEB INTERFACE</strong>: https://nise.moe/c/{id}</li>
|
||||||
<li><strong>API:</strong> https://nise.moe/api/user-scores/{id}</li>
|
<li><strong>API:</strong> https://nise.moe/api/user-scores/{id} <code>GET</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {DatePipe, DecimalPipe, NgForOf, NgIf} from "@angular/common";
|
|||||||
import {
|
import {
|
||||||
CodeWithCopyButtonComponent
|
CodeWithCopyButtonComponent
|
||||||
} from "../../corelib/components/code-with-copy-button/code-with-copy-button.component";
|
} from "../../corelib/components/code-with-copy-button/code-with-copy-button.component";
|
||||||
|
import {RouterLink} from "@angular/router";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-api',
|
selector: 'app-api',
|
||||||
@ -16,7 +17,8 @@ import {
|
|||||||
DecimalPipe,
|
DecimalPipe,
|
||||||
NgForOf,
|
NgForOf,
|
||||||
NgIf,
|
NgIf,
|
||||||
CodeWithCopyButtonComponent
|
CodeWithCopyButtonComponent,
|
||||||
|
RouterLink
|
||||||
],
|
],
|
||||||
templateUrl: './api.component.html',
|
templateUrl: './api.component.html',
|
||||||
styleUrl: './api.component.css'
|
styleUrl: './api.component.css'
|
||||||
|
|||||||
@ -22,7 +22,9 @@ const routes: Routes = [
|
|||||||
{path: 'u/:userId', component: ViewUserComponent},
|
{path: 'u/:userId', component: ViewUserComponent},
|
||||||
{path: 's/:replayId', component: ViewScoreComponent},
|
{path: 's/:replayId', component: ViewScoreComponent},
|
||||||
{path: 'c/:userReplayId', component: ViewScoreComponent},
|
{path: 'c/:userReplayId', component: ViewScoreComponent},
|
||||||
{path: 'search', component: SearchComponent},
|
|
||||||
|
{path: 'search', component: SearchComponent, data: { searchType: 'score' }},
|
||||||
|
{path: 'user-search', component: SearchComponent, data: { searchType: 'user' }},
|
||||||
|
|
||||||
{path: 'p/:replay1Id/:replay2Id', component: ViewReplayPairComponent},
|
{path: 'p/:replay1Id/:replay2Id', component: ViewReplayPairComponent},
|
||||||
|
|
||||||
|
|||||||
@ -19,3 +19,18 @@
|
|||||||
.score-entry:hover {
|
.score-entry:hover {
|
||||||
background-color: rgba(179, 184, 195, 0.15);
|
background-color: rgba(179, 184, 195, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-list a {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-list a:hover {
|
||||||
|
color: white;
|
||||||
|
background-color: rgba(47, 47, 47, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: rgba(179, 184, 195, 0.15);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,180 +1,201 @@
|
|||||||
<div class="main term">
|
<div class="main term" style="padding: 0; width: 882px !important;">
|
||||||
<h1><span class="board">/k/</span> - Advanced Search</h1>
|
|
||||||
|
|
||||||
<ng-container *ngIf="this.isLoadingSchema; else searchPanel">
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px" class="link-list">
|
||||||
<div class="text-center">
|
|
||||||
<p>Loading schema...</p>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #searchPanel>
|
|
||||||
<fieldset>
|
|
||||||
<legend>Table columns</legend>
|
|
||||||
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
|
|
||||||
<fieldset class="mb-2">
|
|
||||||
<legend>{{ category }} <button (click)="this.selectEntireFieldCategory(category)">Select all</button> <button (click)="this.deselectEntireFieldCategory(category)">Deselect all</button></legend>
|
|
||||||
<ng-container *ngFor="let field of fields">
|
|
||||||
<div *ngIf="field.category === category">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" [(ngModel)]="field.active" (change)="this.saveSettingsToLocalStorage()"/>
|
|
||||||
{{ field.name }} <span class="text-muted" style="margin-left: 6px">{{ field.description }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
</fieldset>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
</fieldset>
|
<a style="flex: 1; font-size: 18px; text-decoration-color: rgba(255,255,255,0.32)" class="text-center" [routerLink]="['/search']" [class.disabled]="this.searchType == 'score'">
|
||||||
|
<span class="board" style="font-size: 18px; letter-spacing: 2px;">/k/</span> - score search
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="search-container mt-2" *ngIf="this.queries">
|
<a style="flex: 1; font-size: 18px; text-decoration-color: rgba(255,255,255,0.32)" class="text-center" [routerLink]="['/user-search']" [class.disabled]="this.searchType == 'user'">
|
||||||
<app-query-builder [queries]="this.queries" [fields]="fields"></app-query-builder>
|
<span class="board" style="font-size: 18px; letter-spacing: 2px">/m/</span> - user search
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<fieldset class="mt-2" *ngIf="this.sortingOrder">
|
</div>
|
||||||
<legend>sorting</legend>
|
|
||||||
|
|
||||||
<select (change)="onSortingFieldChange($event)">
|
<div style="padding: 16px">
|
||||||
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
|
<ng-container *ngIf="this.isLoadingSchema; else searchPanel">
|
||||||
<optgroup label="{{ category }}">
|
|
||||||
<ng-container *ngFor="let field of fields">
|
|
||||||
<ng-container *ngIf="field.category === category">
|
|
||||||
<option
|
|
||||||
[value]="field.name"
|
|
||||||
[selected]="field.name === sortingOrder.field">
|
|
||||||
{{ field.name }}
|
|
||||||
</option>
|
|
||||||
</ng-container>
|
|
||||||
</ng-container>
|
|
||||||
</optgroup>
|
|
||||||
</ng-container>
|
|
||||||
</select>
|
|
||||||
<label style="margin-left: 8px">
|
|
||||||
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
|
|
||||||
↑ ASC
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="DESC" />
|
|
||||||
↓ DESC
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div class="text-center mt-2">
|
|
||||||
<button (click)="exportSettings()">Export settings</button>
|
|
||||||
<button (click)="fileInput.click()" style="margin-left: 5px">Import settings</button>
|
|
||||||
<input type="file" #fileInput style="display: none" (change)="uploadSettingsFile($event)" accept=".json">
|
|
||||||
</div>
|
|
||||||
<div class="text-center mt-1">
|
|
||||||
<button (click)="search()" [disabled]="this.isLoading" class="mb-2" style="font-size: 18px">Search</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ng-container *ngIf="this.isLoading">
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p>Loading <app-cute-loading></app-cute-loading></p>
|
<p>Loading schema...</p>
|
||||||
<p>please be patient - the database is working hard!</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<ng-template #searchPanel>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Table columns</legend>
|
||||||
|
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
|
||||||
|
<ng-container *ngIf="hasFieldsInCategory(category)">
|
||||||
|
<fieldset class="mb-2">
|
||||||
|
<legend>{{ category }}
|
||||||
|
<button (click)="this.selectEntireFieldCategory(category)">Select all</button>
|
||||||
|
<button (click)="this.deselectEntireFieldCategory(category)">Deselect all</button>
|
||||||
|
</legend>
|
||||||
|
<ng-container *ngFor="let field of fields">
|
||||||
|
<div *ngIf="field.category === category">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" [(ngModel)]="field.active" (change)="this.saveSettingsToLocalStorage()"/>
|
||||||
|
{{ field.name }} <span class="text-muted" style="margin-left: 6px">{{ field.description }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</fieldset>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<div class="text-center alert-error" *ngIf="this.isError">
|
</fieldset>
|
||||||
<p>Looks like something went wrong... :(</p>
|
|
||||||
<p>I'll look into what caused the error - but feel free to get in touch.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ng-container *ngIf="response">
|
<div class="search-container mt-2" *ngIf="this.queries">
|
||||||
<ng-container *ngIf="response.scores.length <= 0">
|
<app-query-builder [queries]="this.queries" [fields]="fields"></app-query-builder>
|
||||||
<div class="text-center alert-error">
|
</div>
|
||||||
<p>No results for your query - try different parameters.</p>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="response.scores.length > 0">
|
<fieldset class="mt-2" *ngIf="this.sortingOrder">
|
||||||
<fieldset class="mb-2">
|
<legend>sorting</legend>
|
||||||
<legend>tools</legend>
|
|
||||||
<div class="text-center">
|
|
||||||
<button (click)="this.downloadFilesService.downloadCSV(response.scores, getColumns(), 'nise-search')">Download .csv</button>
|
|
||||||
<button (click)="this.downloadFilesService.downloadJSON(response.scores, 'nise-search')">Download .json</button>
|
|
||||||
<button (click)="this.downloadFilesService.downloadXLSX(response.scores, 'nise-search')">Download .xlsx</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<div class="scrollable-table">
|
|
||||||
<table class="table-border">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th *ngFor="let column of fields" [hidden]="!column.active" class="text-center">
|
|
||||||
{{ column.shortName }}
|
|
||||||
</th>
|
|
||||||
<th>Links</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let entry of response.scores" class="score-entry">
|
|
||||||
<td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px">
|
|
||||||
<ng-container *ngIf="getValue(entry, column.name) !== null; else nullDisplay">
|
|
||||||
<ng-container *ngIf="column.type == 'number'">
|
|
||||||
{{ getValue(entry, column.name) | number }}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="column.type == 'flag'">
|
|
||||||
<span class="flag" [title]="getValue(entry, column.name)">{{ countryCodeToFlag(getValue(entry, column.name)) }}</span>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="column.type == 'grade'">
|
|
||||||
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="column.type == 'datetime'">
|
|
||||||
{{ getValue(entry, column.name) }}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="column.type == 'boolean'">
|
|
||||||
<ng-container *ngIf="getValue(entry, column.name) == true">
|
|
||||||
✓
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="getValue(entry, column.name) == false">
|
|
||||||
✗
|
|
||||||
</ng-container>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="column.type == 'playtime'">
|
|
||||||
{{ formatDuration(getValue(entry, column.name)) }}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="column.type == 'string'">
|
|
||||||
|
|
||||||
<ng-container *ngIf="column.name == 'user_username'; else stringField">
|
|
||||||
<a [href]="'/u/' + getValue(entry, column.name)" target="_blank">{{ getValue(entry, column.name) }}</a>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #stringField>
|
|
||||||
{{ getValue(entry, column.name) }}
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
|
<select (change)="onSortingFieldChange($event)">
|
||||||
|
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
|
||||||
|
<ng-container *ngIf="hasFieldsInCategory(category)">
|
||||||
|
<optgroup label="{{ category }}">
|
||||||
|
<ng-container *ngFor="let field of fields">
|
||||||
|
<ng-container *ngIf="field.category === category">
|
||||||
|
<option
|
||||||
|
[value]="field.name"
|
||||||
|
[selected]="field.name === sortingOrder.field">
|
||||||
|
{{ field.name }}
|
||||||
|
</option>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template #nullDisplay><code>null</code></ng-template>
|
</optgroup>
|
||||||
</td>
|
</ng-container>
|
||||||
<td class="text-center" style="line-height: 32px">
|
</ng-container>
|
||||||
<a [href]="'/s/' + this.getId(entry)" target="_blank">
|
</select>
|
||||||
details
|
<label style="margin-left: 8px">
|
||||||
</a>
|
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
|
||||||
</td>
|
↑ ASC
|
||||||
</tr>
|
</label>
|
||||||
</tbody>
|
<label>
|
||||||
</table>
|
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="DESC" />
|
||||||
</div>
|
↓ DESC
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="text-center mt-2">
|
<div class="text-center mt-2">
|
||||||
<p>Total results: {{ response.pagination.totalResults | number }}</p>
|
<button (click)="exportForApi()">Export POST for /api/</button>
|
||||||
<p>Page: {{ response.pagination.currentPage | number }} / {{ response.pagination.totalPages | number }}</p>
|
<button (click)="exportSettings()" style="margin-left: 5px">Export settings</button>
|
||||||
<div class="mb-2">
|
<button (click)="fileInput.click()" style="margin-left: 5px">Import settings</button>
|
||||||
<button *ngIf="response.pagination.currentPage > 5" (click)="this.search(1)" style="margin-right: 5px">1</button>
|
<input type="file" #fileInput style="display: none" (change)="uploadSettingsFile($event)" accept=".json">
|
||||||
<span *ngIf="response.pagination.currentPage > 6">... </span>
|
</div>
|
||||||
<button *ngFor="let page of [].constructor(Math.min(response.pagination.totalPages, 10)) | calculatePageRange:response.pagination.currentPage:response.pagination.totalPages; let i = index"
|
<div class="text-center mt-1">
|
||||||
(click)="this.search(page)"
|
<button (click)="search()" [disabled]="this.isLoading" class="mb-2" style="font-size: 18px">Search</button>
|
||||||
[disabled]="page == response.pagination.currentPage"
|
</div>
|
||||||
style="margin-right: 5px">
|
|
||||||
{{ page }}
|
<ng-container *ngIf="this.isLoading">
|
||||||
</button>
|
<div class="text-center">
|
||||||
<span *ngIf="response.pagination.currentPage < response.pagination.totalPages - 5">... </span>
|
<p>Loading <app-cute-loading></app-cute-loading></p>
|
||||||
<button *ngIf="response.pagination.currentPage < response.pagination.totalPages - 4" (click)="this.search(response.pagination.totalPages)" style="margin-right: 5px">{{ response.pagination.totalPages }}</button>
|
<p>please be patient - the database is working hard!</p>
|
||||||
</div>
|
|
||||||
<button (click)="this.search(response.pagination.currentPage - 1)" [disabled]="response.pagination.currentPage == 1">← Previous</button>
|
|
||||||
<button (click)="this.search(response.pagination.currentPage + 1)" [disabled]="response.pagination.currentPage == response.pagination.totalPages" style="margin-left: 5px">Next →</button>
|
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
</ng-container>
|
<div class="text-center alert-error" *ngIf="this.isError">
|
||||||
</ng-template>
|
<p>Looks like something went wrong... :(</p>
|
||||||
|
<p>I'll look into what caused the error - but feel free to get in touch.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="response">
|
||||||
|
<ng-container *ngIf="response.results.length <= 0">
|
||||||
|
<div class="text-center alert-error">
|
||||||
|
<p>No results for your query - try different parameters.</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="response.results.length > 0">
|
||||||
|
<fieldset class="mb-2">
|
||||||
|
<legend>tools</legend>
|
||||||
|
<div class="text-center">
|
||||||
|
<button (click)="this.downloadFilesService.downloadCSV(response.results, getColumns(), 'nise-search')">Download .csv</button>
|
||||||
|
<button (click)="this.downloadFilesService.downloadJSON(response.results, 'nise-search')">Download .json</button>
|
||||||
|
<button (click)="this.downloadFilesService.downloadXLSX(response.results, 'nise-search')">Download .xlsx</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="scrollable-table">
|
||||||
|
<table class="table-border">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th *ngFor="let column of fields" [hidden]="!column.active" class="text-center">
|
||||||
|
{{ column.shortName }}
|
||||||
|
</th>
|
||||||
|
<th>Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let entry of response.results" class="score-entry">
|
||||||
|
<td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px">
|
||||||
|
<ng-container *ngIf="getValue(entry, column.name) !== null; else nullDisplay">
|
||||||
|
<ng-container *ngIf="column.type == 'number'">
|
||||||
|
{{ getValue(entry, column.name) | number }}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="column.type == 'flag'">
|
||||||
|
<span class="flag" [title]="getValue(entry, column.name)">{{ countryCodeToFlag(getValue(entry, column.name)) }}</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="column.type == 'grade'">
|
||||||
|
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="column.type == 'datetime'">
|
||||||
|
{{ getValue(entry, column.name) }}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="column.type == 'boolean'">
|
||||||
|
<ng-container *ngIf="getValue(entry, column.name) == true">
|
||||||
|
✓
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="getValue(entry, column.name) == false">
|
||||||
|
✗
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="column.type == 'playtime'">
|
||||||
|
{{ formatDuration(getValue(entry, column.name)) }}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="column.type == 'string'">
|
||||||
|
|
||||||
|
<ng-container *ngIf="column.name == 'user_username'; else stringField">
|
||||||
|
<a [href]="'/u/' + getValue(entry, column.name)" target="_blank">{{ getValue(entry, column.name) }}</a>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #stringField>
|
||||||
|
{{ getValue(entry, column.name) }}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #nullDisplay><code>null</code></ng-template>
|
||||||
|
</td>
|
||||||
|
<td class="text-center" style="line-height: 32px">
|
||||||
|
<a [href]="this.getLink(entry)" target="_blank">
|
||||||
|
details
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-2">
|
||||||
|
<p>Total results: {{ response.pagination.totalResults | number }}</p>
|
||||||
|
<p>Page: {{ response.pagination.currentPage | number }} / {{ response.pagination.totalPages | number }}</p>
|
||||||
|
<div class="mb-2">
|
||||||
|
<button *ngIf="response.pagination.currentPage > 5" (click)="this.search(1)" style="margin-right: 5px">1</button>
|
||||||
|
<span *ngIf="response.pagination.currentPage > 6">... </span>
|
||||||
|
<button *ngFor="let page of [].constructor(Math.min(response.pagination.totalPages, 10)) | calculatePageRange:response.pagination.currentPage:response.pagination.totalPages; let i = index"
|
||||||
|
(click)="this.search(page)"
|
||||||
|
[disabled]="page == response.pagination.currentPage"
|
||||||
|
style="margin-right: 5px">
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
<span *ngIf="response.pagination.currentPage < response.pagination.totalPages - 5">... </span>
|
||||||
|
<button *ngIf="response.pagination.currentPage < response.pagination.totalPages - 4" (click)="this.search(response.pagination.totalPages)" style="margin-right: 5px">{{ response.pagination.totalPages }}</button>
|
||||||
|
</div>
|
||||||
|
<button (click)="this.search(response.pagination.currentPage - 1)" [disabled]="response.pagination.currentPage == 1">← Previous</button>
|
||||||
|
<button (click)="this.search(response.pagination.currentPage + 1)" [disabled]="response.pagination.currentPage == response.pagination.totalPages" style="margin-left: 5px">Next →</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</ng-container>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
QueryBuilderComponent
|
QueryBuilderComponent
|
||||||
} from "../../corelib/components/query-builder/query-builder.component";
|
} from "../../corelib/components/query-builder/query-builder.component";
|
||||||
import {RouterLink} from "@angular/router";
|
import {ActivatedRoute, RouterLink} from "@angular/router";
|
||||||
import {CalculatePageRangePipe} from "../../corelib/calculate-page-range.pipe";
|
import {CalculatePageRangePipe} from "../../corelib/calculate-page-range.pipe";
|
||||||
import {DownloadFilesService} from "../../corelib/service/download-files.service";
|
import {DownloadFilesService} from "../../corelib/service/download-files.service";
|
||||||
import {CuteLoadingComponent} from "../../corelib/components/cute-loading/cute-loading.component";
|
import {CuteLoadingComponent} from "../../corelib/components/cute-loading/cute-loading.component";
|
||||||
@ -32,7 +32,7 @@ interface SchemaResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SearchResponse {
|
interface SearchResponse {
|
||||||
scores: any[];
|
results: any[];
|
||||||
pagination: SearchPagination;
|
pagination: SearchPagination;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,8 +69,15 @@ interface Sorting {
|
|||||||
})
|
})
|
||||||
export class SearchComponent implements OnInit {
|
export class SearchComponent implements OnInit {
|
||||||
|
|
||||||
|
schemaUrl!: string;
|
||||||
|
searchUrl!: string;
|
||||||
|
pageTitle!: string;
|
||||||
|
localStorageKey!: string;
|
||||||
|
searchType!: string;
|
||||||
|
|
||||||
constructor(private httpClient: HttpClient,
|
constructor(private httpClient: HttpClient,
|
||||||
private title: Title,
|
private title: Title,
|
||||||
|
private route: ActivatedRoute,
|
||||||
public downloadFilesService: DownloadFilesService) { }
|
public downloadFilesService: DownloadFilesService) { }
|
||||||
|
|
||||||
currentSchemaVersion = 2
|
currentSchemaVersion = 2
|
||||||
@ -86,23 +93,44 @@ export class SearchComponent implements OnInit {
|
|||||||
queries: Query[] | null = null;
|
queries: Query[] | null = null;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.title.setTitle("/k/ - advanced search");
|
this.route.data.subscribe(data => {
|
||||||
this.isLoadingSchema = true;
|
const searchType = data['searchType'];
|
||||||
this.httpClient.get<SchemaResponse>(`${environment.apiUrl}/search/schema`,).subscribe({
|
this.searchType = searchType;
|
||||||
next: (response) => {
|
|
||||||
this.fields = response.fields;
|
if (searchType === 'user') {
|
||||||
this.fields.forEach(field => {
|
this.schemaUrl = `${environment.apiUrl}/search-user/schema`;
|
||||||
field.validOperators = this.getOperators(field.type);
|
this.searchUrl = `${environment.apiUrl}/search-user`;
|
||||||
})
|
this.pageTitle = '/m/ - user search';
|
||||||
this.loadPreviousFromLocalStorage();
|
this.localStorageKey = 'user_search_settings';
|
||||||
this.isLoadingSchema = false;
|
} else {
|
||||||
},
|
this.schemaUrl = `${environment.apiUrl}/search/schema`;
|
||||||
error: () => {
|
this.searchUrl = `${environment.apiUrl}/search`;
|
||||||
alert('Error fetching schema');
|
this.pageTitle = '/k/ - score search';
|
||||||
|
this.localStorageKey = 'search_settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.title.setTitle(this.pageTitle);
|
||||||
|
this.isLoadingSchema = true;
|
||||||
|
this.httpClient.get<SchemaResponse>(this.schemaUrl).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.fields = response.fields;
|
||||||
|
this.fields.forEach(field => {
|
||||||
|
field.validOperators = this.getOperators(field.type);
|
||||||
|
})
|
||||||
|
this.loadPreviousFromLocalStorage();
|
||||||
|
this.isLoadingSchema = false;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
alert('Error fetching schema');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasFieldsInCategory(category: string): boolean {
|
||||||
|
return this.fields.some(field => field.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
getOperators(fieldType: FieldType | undefined): Operator[] {
|
getOperators(fieldType: FieldType | undefined): Operator[] {
|
||||||
switch (fieldType) {
|
switch (fieldType) {
|
||||||
case 'number':
|
case 'number':
|
||||||
@ -132,7 +160,7 @@ export class SearchComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadPreviousFromLocalStorage(): void {
|
private loadPreviousFromLocalStorage(): void {
|
||||||
const storedQueries = localStorage.getItem('search_settings');
|
const storedQueries = localStorage.getItem(this.localStorageKey);
|
||||||
let parsedQueries = storedQueries ? JSON.parse(storedQueries) : null;
|
let parsedQueries = storedQueries ? JSON.parse(storedQueries) : null;
|
||||||
|
|
||||||
if (parsedQueries && this.verifySchema(parsedQueries)) {
|
if (parsedQueries && this.verifySchema(parsedQueries)) {
|
||||||
@ -142,7 +170,7 @@ export class SearchComponent implements OnInit {
|
|||||||
field.active = parsedQueries.columns[field.name] ?? field.active;
|
field.active = parsedQueries.columns[field.name] ?? field.active;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('search_settings');
|
localStorage.removeItem(this.localStorageKey);
|
||||||
this.queries = [];
|
this.queries = [];
|
||||||
this.sortingOrder = {
|
this.sortingOrder = {
|
||||||
field: 'user_id',
|
field: 'user_id',
|
||||||
@ -203,7 +231,36 @@ export class SearchComponent implements OnInit {
|
|||||||
|
|
||||||
saveSettingsToLocalStorage(): void {
|
saveSettingsToLocalStorage(): void {
|
||||||
const settings = this.serializeSettings();
|
const settings = this.serializeSettings();
|
||||||
localStorage.setItem('search_settings', JSON.stringify(settings));
|
localStorage.setItem(this.localStorageKey, JSON.stringify(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
exportForApi(): void {
|
||||||
|
if(!this.queries) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
queries: this.queries.map(query => ({
|
||||||
|
...query,
|
||||||
|
predicates: query.predicates.map(predicate => ({
|
||||||
|
field: {
|
||||||
|
name: predicate.field!!.name,
|
||||||
|
type: predicate.field!!.type
|
||||||
|
},
|
||||||
|
operator: predicate.operator,
|
||||||
|
value: predicate.value
|
||||||
|
}))
|
||||||
|
})),
|
||||||
|
sorting: this.sortingOrder,
|
||||||
|
page: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy to cliboard
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(body)).then(() => {
|
||||||
|
alert('Copied to clipboard');
|
||||||
|
}, () => {
|
||||||
|
alert('Error copying to clipboard');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
exportSettings(): void {
|
exportSettings(): void {
|
||||||
@ -262,7 +319,7 @@ export class SearchComponent implements OnInit {
|
|||||||
sorting: this.sortingOrder,
|
sorting: this.sortingOrder,
|
||||||
page: pageNumber
|
page: pageNumber
|
||||||
}
|
}
|
||||||
this.httpClient.post<SearchResponse>(`${environment.apiUrl}/search`, body)
|
this.httpClient.post<SearchResponse>(this.searchUrl, body)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
this.response = response;
|
this.response = response;
|
||||||
@ -284,8 +341,12 @@ export class SearchComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getId(entry: any): any {
|
getLink(entry: any): any {
|
||||||
return this.getValue(entry, 'replay_id');
|
if(this.searchType === 'user') {
|
||||||
|
return "/u/" + this.getValue(entry, 'username');
|
||||||
|
} else {
|
||||||
|
return "/s/" + this.getValue(entry, 'replay_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly countryCodeToFlag = countryCodeToFlag;
|
protected readonly countryCodeToFlag = countryCodeToFlag;
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export class ViewUserComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|
|
||||||
getUserInfo(): Observable<UserInfo> {
|
getUserInfo(): Observable<UserInfo> {
|
||||||
const body = {
|
const body = {
|
||||||
userId: this.userId
|
username: this.userId
|
||||||
}
|
}
|
||||||
return this.httpClient.post<UserInfo>(`${environment.apiUrl}/user-details`, body);
|
return this.httpClient.post<UserInfo>(`${environment.apiUrl}/user-details`, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,15 +23,17 @@
|
|||||||
<select style="max-width: 60%" (change)="onFieldChange(predicate, $event)">
|
<select style="max-width: 60%" (change)="onFieldChange(predicate, $event)">
|
||||||
<option value="" disabled selected>---</option>
|
<option value="" disabled selected>---</option>
|
||||||
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
|
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
|
||||||
<optgroup label="{{ category }}">
|
<ng-container *ngIf="hasFieldsInCategory(category)">
|
||||||
<ng-container *ngFor="let field of fields">
|
<optgroup label="{{ category }}">
|
||||||
<ng-container *ngIf="field.category === category">
|
<ng-container *ngFor="let field of fields">
|
||||||
<option [value]="field.name" [selected]="field.name === predicate.field?.name">
|
<ng-container *ngIf="field.category === category">
|
||||||
{{ field.name }}
|
<option [value]="field.name" [selected]="field.name === predicate.field?.name">
|
||||||
</option>
|
{{ field.name }}
|
||||||
|
</option>
|
||||||
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
</optgroup>
|
||||||
</optgroup>
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,10 @@ export class QueryComponent {
|
|||||||
predicate.operator = selectedField.validOperators[0];
|
predicate.operator = selectedField.validOperators[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasFieldsInCategory(category: string): boolean {
|
||||||
|
return this.fields.some(field => field.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
onOperatorChange(predicate: Predicate, event: Event): void {
|
onOperatorChange(predicate: Predicate, event: Event): void {
|
||||||
const selectElement = event.target as HTMLSelectElement;
|
const selectElement = event.target as HTMLSelectElement;
|
||||||
const selectedOperatorType = selectElement.value;
|
const selectedOperatorType = selectElement.value;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user