Improvements in advanced search

This commit is contained in:
nise.moe 2024-02-24 14:59:17 +01:00
parent 9e15e6bcc0
commit 7014dfdfb5
17 changed files with 610 additions and 196 deletions

View File

@ -4,10 +4,13 @@ import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.generated.tables.references.USERS
import com.nisemoe.nise.Format
import com.nisemoe.nise.service.AuthService
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Field
import org.jooq.OrderField
import org.jooq.Record
import org.jooq.Result
import org.jooq.impl.DSL
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
@ -18,15 +21,32 @@ import kotlin.math.roundToInt
@RestController
class SearchController(
private val dslContext: DSLContext
private val dslContext: DSLContext,
private val authService: AuthService,
) {
companion object {
const val RESULTS_PER_PAGE = 50
}
data class SearchResponse(
val scores: List<SearchResponseEntry>,
val totalResults: Int? = null
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
}
data class SearchResponseEntry(
// User fields
val user_id: Long?,
@ -92,7 +112,8 @@ class SearchController(
data class SearchRequest(
val queries: List<SearchQuery>,
val sorting: SearchSorting
val sorting: SearchSorting,
val page: Int
)
data class SearchSorting(
@ -102,7 +123,8 @@ class SearchController(
data class SearchQuery(
val logicalOperator: String,
val predicates: List<SearchPredicate>
val predicates: List<SearchPredicate>,
val childQueries: List<SearchQuery>?
)
data class SearchPredicate(
@ -117,24 +139,18 @@ class SearchController(
)
@PostMapping("search")
fun doSearch(
@RequestBody request: SearchRequest,
@RequestHeader("X-NISE-API") apiVersion: String
): ResponseEntity<SearchResponse> {
fun doSearch(@RequestBody request: SearchRequest, @RequestHeader("X-NISE-API") apiVersion: String): ResponseEntity<SearchResponse> {
if(!authService.isAdmin())
return ResponseEntity.status(401).build()
if (apiVersion.isBlank())
return ResponseEntity.badRequest().build()
// TODO: Validation
var baseQuery = DSL.noCondition()
for (query in request.queries.filter { it.predicates.isNotEmpty() }) {
var condition = buildCondition(query.predicates[0]) // Start with the first predicate
for (i in 1 until query.predicates.size) {
val nextPredicate = query.predicates[i]
condition = when (query.logicalOperator.lowercase()) {
"and" -> condition.and(buildCondition(nextPredicate))
"or" -> condition.or(buildCondition(nextPredicate))
else -> throw IllegalArgumentException("Invalid logical operator")
}
}
val condition = buildCondition(query)
baseQuery = baseQuery.and(condition)
}
@ -207,79 +223,123 @@ class SearchController(
if (request.sorting.field.isNotBlank())
orderBy(buildSorting(request.sorting))
}
.limit(50)
.offset((request.page - 1) * RESULTS_PER_PAGE)
.limit(RESULTS_PER_PAGE)
.fetch()
// Get total results
val totalResults = dslContext.selectCount()
.from(SCORES)
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
.join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID))
.where(baseQuery)
.fetchOne(0, Int::class.java) ?: 0
val response = SearchResponse(
scores = results.map {
SearchResponseEntry(
// User fields
user_id = it.get(SCORES.USER_ID),
user_username = it.get(USERS.USERNAME),
user_join_date = it.get(USERS.JOIN_DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
user_country = it.get(USERS.COUNTRY),
user_country_rank = it.get(USERS.COUNTRY_RANK),
user_rank = it.get(USERS.RANK),
user_pp_raw = it.get(USERS.PP_RAW)?.roundToInt()?.toDouble(),
user_accuracy = it.get(USERS.ACCURACY),
user_playcount = it.get(USERS.PLAYCOUNT),
user_total_score = it.get(USERS.TOTAL_SCORE),
user_ranked_score = it.get(USERS.RANKED_SCORE),
user_seconds_played = it.get(USERS.SECONDS_PLAYED),
user_count_300 = it.get(USERS.COUNT_300),
user_count_100 = it.get(USERS.COUNT_100),
user_count_50 = it.get(USERS.COUNT_50),
user_count_miss = it.get(SCORES.COUNT_MISS),
// Score fields
id = it.get(SCORES.ID),
beatmap_id = it.get(SCORES.BEATMAP_ID),
count_300 = it.get(SCORES.COUNT_300),
count_100 = it.get(SCORES.COUNT_100),
count_50 = it.get(SCORES.COUNT_50),
count_miss = it.get(SCORES.COUNT_MISS),
date = it.get(SCORES.DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
max_combo = it.get(SCORES.MAX_COMBO),
mods = it.get(SCORES.MODS),
perfect = it.get(SCORES.PERFECT),
pp = it.get(SCORES.PP)?.roundToInt()?.toDouble(),
rank = it.get(SCORES.RANK),
replay_id = it.get(SCORES.REPLAY_ID),
score = it.get(SCORES.SCORE),
ur = it.get(SCORES.UR),
frametime = it.get(SCORES.FRAMETIME),
edge_hits = it.get(SCORES.EDGE_HITS),
snaps = it.get(SCORES.SNAPS),
adjusted_ur = it.get(SCORES.ADJUSTED_UR),
mean_error = it.get(SCORES.MEAN_ERROR),
error_variance = it.get(SCORES.ERROR_VARIANCE),
error_standard_deviation = it.get(SCORES.ERROR_STANDARD_DEVIATION),
minimum_error = it.get(SCORES.MINIMUM_ERROR),
maximum_error = it.get(SCORES.MAXIMUM_ERROR),
error_range = it.get(SCORES.ERROR_RANGE),
error_coefficient_of_variation = it.get(SCORES.ERROR_COEFFICIENT_OF_VARIATION),
error_kurtosis = it.get(SCORES.ERROR_KURTOSIS),
error_skewness = it.get(SCORES.ERROR_SKEWNESS),
keypresses_median_adjusted = it.get(SCORES.KEYPRESSES_MEDIAN_ADJUSTED),
keypresses_standard_deviation_adjusted = it.get(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED),
sliderend_release_median_adjusted = it.get(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED),
sliderend_release_standard_deviation_adjusted = it.get(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED),
// Beatmap fields
beatmap_artist = it.get(BEATMAPS.ARTIST),
beatmap_beatmapset_id = it.get(BEATMAPS.BEATMAPSET_ID),
beatmap_creator = it.get(BEATMAPS.CREATOR),
beatmap_source = it.get(BEATMAPS.SOURCE),
beatmap_star_rating = it.get(BEATMAPS.STAR_RATING),
beatmap_title = it.get(BEATMAPS.TITLE),
beatmap_version = it.get(BEATMAPS.VERSION)
)
}
scores = mapRecordToScores(results),
pagination = SearchResponsePagination(
currentPage = request.page,
pageSize = RESULTS_PER_PAGE,
totalResults = totalResults
)
)
return ResponseEntity.ok(response)
}
private fun mapRecordToScores(results: Result<Record>): MutableList<SearchResponseEntry> =
results.map {
SearchResponseEntry(
// User fields
user_id = it.get(SCORES.USER_ID),
user_username = it.get(USERS.USERNAME),
user_join_date = it.get(USERS.JOIN_DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
user_country = it.get(USERS.COUNTRY),
user_country_rank = it.get(USERS.COUNTRY_RANK),
user_rank = it.get(USERS.RANK),
user_pp_raw = it.get(USERS.PP_RAW)?.roundToInt()?.toDouble(),
user_accuracy = it.get(USERS.ACCURACY),
user_playcount = it.get(USERS.PLAYCOUNT),
user_total_score = it.get(USERS.TOTAL_SCORE),
user_ranked_score = it.get(USERS.RANKED_SCORE),
user_seconds_played = it.get(USERS.SECONDS_PLAYED),
user_count_300 = it.get(USERS.COUNT_300),
user_count_100 = it.get(USERS.COUNT_100),
user_count_50 = it.get(USERS.COUNT_50),
user_count_miss = it.get(SCORES.COUNT_MISS),
// Score fields
id = it.get(SCORES.ID),
beatmap_id = it.get(SCORES.BEATMAP_ID),
count_300 = it.get(SCORES.COUNT_300),
count_100 = it.get(SCORES.COUNT_100),
count_50 = it.get(SCORES.COUNT_50),
count_miss = it.get(SCORES.COUNT_MISS),
date = it.get(SCORES.DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
max_combo = it.get(SCORES.MAX_COMBO),
mods = it.get(SCORES.MODS),
perfect = it.get(SCORES.PERFECT),
pp = it.get(SCORES.PP)?.roundToInt()?.toDouble(),
rank = it.get(SCORES.RANK),
replay_id = it.get(SCORES.REPLAY_ID),
score = it.get(SCORES.SCORE),
ur = it.get(SCORES.UR),
frametime = it.get(SCORES.FRAMETIME),
edge_hits = it.get(SCORES.EDGE_HITS),
snaps = it.get(SCORES.SNAPS),
adjusted_ur = it.get(SCORES.ADJUSTED_UR),
mean_error = it.get(SCORES.MEAN_ERROR),
error_variance = it.get(SCORES.ERROR_VARIANCE),
error_standard_deviation = it.get(SCORES.ERROR_STANDARD_DEVIATION),
minimum_error = it.get(SCORES.MINIMUM_ERROR),
maximum_error = it.get(SCORES.MAXIMUM_ERROR),
error_range = it.get(SCORES.ERROR_RANGE),
error_coefficient_of_variation = it.get(SCORES.ERROR_COEFFICIENT_OF_VARIATION),
error_kurtosis = it.get(SCORES.ERROR_KURTOSIS),
error_skewness = it.get(SCORES.ERROR_SKEWNESS),
keypresses_median_adjusted = it.get(SCORES.KEYPRESSES_MEDIAN_ADJUSTED),
keypresses_standard_deviation_adjusted = it.get(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED),
sliderend_release_median_adjusted = it.get(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED),
sliderend_release_standard_deviation_adjusted = it.get(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED),
// Beatmap fields
beatmap_artist = it.get(BEATMAPS.ARTIST),
beatmap_beatmapset_id = it.get(BEATMAPS.BEATMAPSET_ID),
beatmap_creator = it.get(BEATMAPS.CREATOR),
beatmap_source = it.get(BEATMAPS.SOURCE),
beatmap_star_rating = it.get(BEATMAPS.STAR_RATING),
beatmap_title = it.get(BEATMAPS.TITLE),
beatmap_version = it.get(BEATMAPS.VERSION)
)
}
fun buildCondition(query: 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 (query.logicalOperator.lowercase()) {
"and" -> baseCondition.and(childCondition)
"or" -> baseCondition.or(childCondition)
else -> throw IllegalArgumentException("Invalid logical operator")
}
}
return baseCondition
}
private fun buildSorting(sorting: SearchSorting): OrderField<*> {
val field = mapPredicateFieldToDatabaseField(sorting.field)
return when (sorting.order.lowercase()) {
@ -289,7 +349,7 @@ class SearchController(
}
}
private fun buildCondition(predicate: SearchPredicate): Condition {
private fun buildPredicateCondition(predicate: SearchPredicate): Condition {
val field = mapPredicateFieldToDatabaseField(predicate.field.name)
return when (predicate.field.type.lowercase()) {
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator, predicate.value.toDouble())

View File

@ -1,8 +1,10 @@
package com.nisemoe.nise.database
import com.nisemoe.generated.tables.records.ScoresJudgementsRecord
import com.nisemoe.generated.tables.records.ScoresRecord
import com.nisemoe.generated.tables.references.*
import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.generated.tables.references.SCORES_SIMILARITY
import com.nisemoe.generated.tables.references.USERS
import com.nisemoe.nise.*
import com.nisemoe.nise.osu.Mod
import com.nisemoe.nise.service.AuthService

View File

@ -38,10 +38,10 @@ class JudgementCompressionTest {
@Test
fun compressionIntegrityTest() {
val scores = dslContext.select(SCORES.REPLAY, SCORES.BEATMAP_ID, SCORES.MODS)
val scores = dslContext.select(SCORES.REPLAY, SCORES.BEATMAP_ID, SCORES.MODS, SCORES.REPLAY_ID)
.from(SCORES)
.where(SCORES.REPLAY.isNotNull)
.limit(100)
.limit(500)
.fetchInto(ScoresRecord::class.java)
for(score in scores) {
@ -60,6 +60,7 @@ class JudgementCompressionTest {
val compressedData = compressJudgements.serialize(result.judgements)
this.logger.info("replay_id = {}", score.replayId)
this.logger.info("JSON size: {} bytes", String.format("%,d", Json.encodeToString(CircleguardService.ReplayResponse.serializer(), result).length))
this.logger.info("Compressed (Brotli) size: {} bytes", String.format("%,d", compressedData.size))

View File

@ -24,6 +24,8 @@
"ng2-charts": "^5.0.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"uuid": "^9.0.1",
"xlsx": "^0.18.5",
"zone.js": "^0.14.3"
},
"devDependencies": {
@ -32,6 +34,7 @@
"@angular/compiler-cli": "^17.0.9",
"@angular/localize": "^17.1.1",
"@types/jasmine": "~4.3.0",
"@types/uuid": "^9.0.8",
"jasmine-core": "~4.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
@ -3845,6 +3848,12 @@
"@types/node": "*"
}
},
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
"dev": true
},
"node_modules/@types/ws": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
@ -4100,6 +4109,14 @@
"node": ">=8.9.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
@ -4758,6 +4775,18 @@
}
]
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@ -4963,6 +4992,14 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -5267,6 +5304,17 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/critters": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/critters/-/critters-0.0.20.tgz",
@ -6405,6 +6453,14 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -7141,9 +7197,9 @@
}
},
"node_modules/ip": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
"dev": true
},
"node_modules/ipaddr.js": {
@ -10943,6 +10999,17 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/ssri": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
@ -11586,7 +11653,6 @@
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"peer": true,
"bin": {
"uuid": "dist/bin/uuid"
}
@ -12035,6 +12101,22 @@
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
"dev": true
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@ -12160,6 +12242,26 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -26,6 +26,8 @@
"ng2-charts": "^5.0.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"uuid": "^9.0.1",
"xlsx": "^0.18.5",
"zone.js": "^0.14.3"
},
"devDependencies": {
@ -34,6 +36,7 @@
"@angular/compiler-cli": "^17.0.9",
"@angular/localize": "^17.1.1",
"@types/jasmine": "~4.3.0",
"@types/uuid": "^9.0.8",
"jasmine-core": "~4.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",

View File

@ -15,3 +15,12 @@
padding: 2px;
border: 1px solid rgba(179, 184, 195, 0.1);
}
.score-entry {
cursor: pointer;
}
.score-entry:hover {
background-color: rgba(179, 184, 195, 0.15);
}

View File

@ -1,8 +1,6 @@
<div class="main term">
<h1><span class="board">/k/</span> - Advanced Search</h1>
<!-- <pre>{{ queries | json }}</pre>-->
<fieldset>
<legend>Table columns</legend>
<ng-container *ngFor="let category of ['user', 'beatmap', 'score']">
@ -29,7 +27,11 @@
<legend>sorting</legend>
<select>
<option *ngFor="let field of fields" [value]="field.name" (click)="this.sortingOrder.field = field.name">{{ field.name }}</option>
<option *ngFor="let field of fields"
[value]="field.name"
(click)="this.sortingOrder.field = field.name"
[selected]="field.name == this.sortingOrder.field"
>{{ field.name }}</option>
</select>
<label>
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
@ -42,10 +44,31 @@
</fieldset>
<div class="text-center">
<button (click)="search()" class="mb-2 mt-2">Search</button>
<div class="text-center mb-2 mt-2">
<button (click)="exportSettings()">Export Query</button>
<button (click)="fileInput.click()" style="margin-left: 5px">Import Query</button>
<input type="file" #fileInput style="display: none" (change)="importSettings($event)" accept=".json">
</div>
<button (click)="search()" class="mb-2" style="font-size: 18px">Search</button>
</div>
<ng-container *ngIf="this.isLoading">
<div class="text-center">
<p>Loading...</p>
</div>
</ng-container>
<ng-container *ngIf="response">
<fieldset class="mb-2">
<legend>tools</legend>
<div class="text-center">
<button (click)="this.downloadFilesService.downloadCSV(response.scores)">Download .csv</button>
<button (click)="this.downloadFilesService.downloadJSON(response.scores)">Download .json</button>
<button (click)="this.downloadFilesService.downloadXLSX(response.scores)">Download .xslx</button>
</div>
</fieldset>
<div class="scrollable-table">
<table class="table-border">
<thead>
@ -56,7 +79,7 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let entry of response.scores">
<tr *ngFor="let entry of response.scores" class="score-entry" [routerLink]="['/s/' + entry.replay_id]">
<td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px">
<ng-container *ngIf="column.type == 'number'">
{{ getValue(entry, column.name) | number }}
@ -67,6 +90,14 @@
<ng-container *ngIf="column.type == 'grade'">
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
</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 == 'string'">
{{ getValue(entry, column.name) }}
</ng-container>
@ -75,6 +106,26 @@
</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>
</div>

View File

@ -6,6 +6,10 @@ import {environment} from "../../environments/environment";
import {countryCodeToFlag} from "../format";
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
import {Field, Query, QueryBuilderComponent} from "../../corelib/components/query-builder/query-builder.component";
import {RouterLink} from "@angular/router";
import {CalculatePageRangePipe} from "../../corelib/calculate-page-range.pipe";
import {DownloadFilesService} from "../../corelib/service/download-files.service";
import {catchError, throwError} from "rxjs";
interface SchemaField {
name: string;
@ -18,6 +22,14 @@ interface SchemaField {
interface SearchResponse {
scores: SearchResponseEntry[];
pagination: SearchPagination;
}
interface SearchPagination {
currentPage: number;
pageSize: number;
totalResults: number;
totalPages: number;
}
interface Sorting {
@ -73,15 +85,18 @@ interface SearchResponseEntry {
NgIf,
DecimalPipe,
OsuGradeComponent,
QueryBuilderComponent
QueryBuilderComponent,
RouterLink,
CalculatePageRangePipe
],
templateUrl: './search.component.html',
styleUrl: './search.component.css'
})
export class SearchComponent implements OnInit {
constructor(private httpClient: HttpClient) { }
constructor(private httpClient: HttpClient, public downloadFilesService: DownloadFilesService) { }
isLoading = false;
response: SearchResponse | null = null;
fields: SchemaField[] = [
@ -183,6 +198,45 @@ export class SearchComponent implements OnInit {
});
}
updateLocalStorage(): void {
console.warn('Updating local storage');
localStorage.setItem('search_queries', JSON.stringify(this.queries));
}
exportSettings(): void {
const settings = {
queries: this.queries,
sorting: this.sortingOrder
} as any;
this.downloadFilesService.downloadJSON(settings);
}
importSettings(event: any): void {
const file = event.target.files[0];
if (file) {
const fileReader = new FileReader();
fileReader.onload = (e) => {
try {
const json = JSON.parse(fileReader.result as string);
if (this.verifySchema(json)) {
this.queries = json.queries;
this.sortingOrder = json.sorting;
} else {
console.error('Invalid file schema');
}
} catch (error) {
console.error('Error parsing JSON', error);
}
};
fileReader.readAsText(file);
}
}
verifySchema(json: any): boolean {
// TODO: Implement schema verification logic here
return 'queries' in json && 'sorting' in json;
}
saveColumnsStatusToLocalStorage(): void {
const statusMap = this.fields.reduce<{ [key: string]: boolean }>((acc, field) => {
acc[field.name] = field.active;
@ -192,15 +246,31 @@ export class SearchComponent implements OnInit {
localStorage.setItem('columns_status', JSON.stringify(statusMap));
}
search(): void {
search(pageNumber: number = 1): void {
this.isLoading = true;
const body = {
queries: this.queries,
sorting: this.sortingOrder
sorting: this.sortingOrder,
page: pageNumber
}
this.httpClient.post<SearchResponse>(`${environment.apiUrl}/search`, body).subscribe(response => {
this.response = response;
this.httpClient.post<SearchResponse>(`${environment.apiUrl}/search`, body).pipe(
catchError(error => {
// Handle the error or rethrow
console.error('An error occurred:', error);
return throwError(() => new Error('An error occurred')); // Rethrow or return a new observable
})
).subscribe({
next: (response) => {
this.response = response;
this.isLoading = false;
},
error: (error) => {
// Handle subscription error
console.error('Subscription error:', error);
this.isLoading = false;
}
});
// this.updateLocalStorage();
this.updateLocalStorage();
}
// Add this method to the SearchComponent class
@ -208,7 +278,6 @@ export class SearchComponent implements OnInit {
return entry[columnName as keyof SearchResponseEntry];
}
protected readonly countryCodeToFlag = countryCodeToFlag;
protected readonly Math = Math;
}

View File

@ -0,0 +1,23 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
standalone: true,
name: 'calculatePageRange'
})
export class CalculatePageRangePipe implements PipeTransform {
transform(totalPages: number, currentPage: number, totalPagesCount: number): number[] {
const visiblePages = 10;
let startPage = Math.max(currentPage - 5, 1);
let endPage = Math.min(startPage + visiblePages - 1, totalPagesCount);
// Adjust the start and end if near the beginning or end
if (currentPage < 6) {
endPage = Math.min(visiblePages, totalPagesCount);
} else if (currentPage > totalPagesCount - 5) {
startPage = Math.max(totalPagesCount - visiblePages + 1, 1);
}
// Generate the range of page numbers to display
return Array.from({ length: (endPage - startPage) + 1 }, (_, i) => startPage + i);
}
}

View File

@ -1,26 +0,0 @@
.query {
padding: 10px;
margin-bottom: 10px;
}
.logical-operator-toggle button {
/* your styles for the logical operator buttons */
}
.logical-operator-toggle button.active {
/* your styles for the active state */
}
.predicate {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.predicate select, .predicate input, .predicate button {
margin-right: 5px;
}
.predicate button {
/* style it to look more like a close button */
}

View File

@ -1,33 +1,7 @@
<fieldset>
<legend>Query builder</legend>
<fieldset *ngFor="let query of queries; let i = index" class="query">
<legend>Predicate #{{ i + 1 }}</legend>
<div class="logical-operator-toggle">
<label>
<input type="radio" name="logicalOperator{{i}}" [(ngModel)]="query.logicalOperator" value="AND" />
AND
</label>
<label>
<input type="radio" name="logicalOperator{{i}}" [(ngModel)]="query.logicalOperator" value="OR" />
OR
</label>
</div>
<div *ngFor="let predicate of query.predicates; let j = index" class="predicate">
<select>
<option *ngFor="let field of fields" (click)="onFieldChange(predicate, field)" [selected]="field.name === predicate.field?.name">{{ field.name }}</option>
</select>
<select [(ngModel)]="predicate.operator" [disabled]="!predicate.field">
<option *ngFor="let operator of getOperators(predicate.field?.type)" [value]="operator">
{{ operator }}
</option>
</select>
<input [(ngModel)]="predicate.value" type="text" placeholder="Value" [disabled]="!predicate.field">
<button (click)="removePredicate(i, j)">X</button>
</div>
<button (click)="addPredicate(i)">+ Rule</button>
</fieldset>
<legend>Query Builder</legend>
<button (click)="addQuery()">+ Predicate</button>
<div *ngFor="let query of queries; let i = index">
<app-query [query]="query" [fields]="fields" (removeQuery)="removeQuery(i)" (queryChanged)="queryChanged()"></app-query>
</div>
</fieldset>

View File

@ -1,6 +1,7 @@
import {Component, Input} from '@angular/core';
import {FormsModule} from "@angular/forms";
import {NgForOf, NgIf} from "@angular/common";
import {QueryComponent} from "../query/query.component";
export type FieldType = 'number' | 'string' | 'flag' | 'grade' | 'boolean';
export type OperatorType = '=' | '>' | '<' | 'contains' | 'like' | '>=' | '<=' | '!=';
@ -19,6 +20,7 @@ export interface Predicate {
export interface Query {
predicates: Predicate[];
logicalOperator: 'AND' | 'OR';
childQueries?: Query[]; // Optional property for sub-queries
}
@Component({
@ -27,7 +29,8 @@ export interface Query {
imports: [
FormsModule,
NgForOf,
NgIf
NgIf,
QueryComponent
],
templateUrl: './query-builder.component.html',
styleUrl: './query-builder.component.css'
@ -37,35 +40,6 @@ export class QueryBuilderComponent {
@Input() queries: Query[] = [];
@Input() fields: Field[] = [];
addPredicate(queryIndex: number): void {
const newPredicate: Predicate = { field: null, operator: null, value: null};
this.queries[queryIndex].predicates.push(newPredicate);
}
onFieldChange(predicate: Predicate, selectedField: any): void {
predicate.field = selectedField;
predicate.operator = this.getOperators(selectedField.type)[0];
}
getOperators(fieldType: FieldType | undefined): OperatorType[] {
switch (fieldType) {
case 'number':
return ['=', '>', '<', '>=', '<=', '!='];
case 'string':
return ['=', 'contains', 'like'];
default:
return [];
}
}
removePredicate(queryIndex: number, predicateIndex: number): void {
if(!this.queries) {
return;
}
this.queries[queryIndex].predicates.splice(predicateIndex, 1);
}
addQuery(): void {
this.queries.push({
predicates: [],
@ -73,9 +47,12 @@ export class QueryBuilderComponent {
});
}
private updateLocalStorage(): void {
console.warn('Updating local storage');
localStorage.setItem('search_queries', JSON.stringify(this.queries));
removeQuery(queryIndex: number): void {
this.queries.splice(queryIndex, 1);
}
queryChanged(): void {
console.log(this.queries);
}
}

View File

@ -0,0 +1,26 @@
.query {
padding: 10px;
margin-bottom: 10px;
}
.logical-operator-toggle button {
/* your styles for the logical operator buttons */
}
.logical-operator-toggle button.active {
/* your styles for the active state */
}
.predicate {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.predicate select, .predicate input, .predicate button {
margin-right: 5px;
}
.predicate button {
/* style it to look more like a close button */
}

View File

@ -0,0 +1,37 @@
<fieldset class="query">
<legend>Predicate <button (click)="removeQuery.emit()">Delete</button></legend>
<div class="logical-operator-toggle">
<label>
<input type="radio" name="logicalOperator-{{ queryId }}" [(ngModel)]="query.logicalOperator" value="AND" (change)="this.queryChanged.emit()"/>
AND
</label>
<label>
<input type="radio" name="logicalOperator-{{ queryId }}" [(ngModel)]="query.logicalOperator" value="OR" (change)="this.queryChanged.emit()"/>
OR
</label>
</div>
<div *ngFor="let predicate of query.predicates; let i = index">
<select style="max-width: 40%">
<option *ngFor="let field of fields" (click)="onFieldChange(predicate, field)" [selected]="field.name === predicate.field?.name">{{ field.name }}</option>
</select>
<select [(ngModel)]="predicate.operator" [disabled]="!predicate.field">
<option *ngFor="let operator of getOperators(predicate.field?.type)" [value]="operator" (change)="this.queryChanged.emit()">
{{ operator }}
</option>
</select>
<input [(ngModel)]="predicate.value" type="text" placeholder="Value" [disabled]="!predicate.field" (change)="this.queryChanged.emit()">
<button (click)="removePredicate(i)">X</button>
</div>
<button (click)="addPredicate()" style="margin-top: 5px">+ Rule</button>
<div *ngFor="let childQuery of query.childQueries; let i = index">
<app-query [query]="childQuery" [fields]="fields" (removeQuery)="removeSubQuery(i)" (queryChanged)="queryChanged.emit()"></app-query>
</div>
<button (click)="addSubQuery()">+ Sub-Predicate</button>
</fieldset>

View File

@ -0,0 +1,70 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {Field, FieldType, OperatorType, Predicate, Query} from "../query-builder/query-builder.component";
import {NgForOf} from "@angular/common";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import { v4 as uuidv4 } from 'uuid';
@Component({
selector: 'app-query',
standalone: true,
imports: [
NgForOf,
ReactiveFormsModule,
FormsModule
],
templateUrl: './query.component.html',
styleUrl: './query.component.css'
})
export class QueryComponent {
@Input() query!: Query;
@Input() fields!: Field[];
@Output() removeQuery = new EventEmitter<void>();
@Output() queryChanged = new EventEmitter<void>();
queryId = uuidv4();
constructor() {}
onFieldChange(predicate: Predicate, selectedField: any): void {
predicate.field = selectedField;
predicate.operator = this.getOperators(selectedField.type)[0];
}
getOperators(fieldType: FieldType | undefined): OperatorType[] {
switch (fieldType) {
case 'number':
return ['=', '>', '<', '>=', '<=', '!='];
case 'string':
return ['=', 'contains', 'like'];
default:
return [];
}
}
addPredicate(): void {
this.query.predicates.push({ field: null, operator: null, value: null });
this.queryChanged.emit();
}
removePredicate(index: number): void {
this.query.predicates.splice(index, 1);
this.queryChanged.emit();
}
addSubQuery(): void {
if (!this.query.childQueries) {
this.query.childQueries = [];
}
this.query.childQueries.push({ predicates: [], logicalOperator: 'AND' });
this.queryChanged.emit();
}
removeSubQuery(index: number): void {
// @ts-ignore
this.query.childQueries.splice(index, 1);
this.queryChanged.emit();
}
}

View File

@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
import * as XLSX from "xlsx";
@Injectable({
providedIn: 'root'
})
export class DownloadFilesService {
downloadJSON(input: Object[]) {
const dataStr = JSON.stringify(input);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'data.json';
link.click();
}
downloadCSV(input: Object[]) {
let csvData = input.map(row => Object.values(row).join(',')).join('\n');
const blob = new Blob([csvData], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'data.csv';
link.click();
}
downloadXLSX(input: Object[]) {
const ws: XLSX.WorkSheet = XLSX.utils.json_to_sheet(input);
const wb: XLSX.WorkBook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Data');
XLSX.writeFile(wb, 'data.xlsx');
}
}

View File

@ -74,7 +74,7 @@ export class UserService {
}
clearCurrentUserFromLocalStorage() {
localStorage.clear();
localStorage.setItem('currentUser', '');
document.cookie = 'SESSION=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
}