Improvements in advanced search
This commit is contained in:
parent
9e15e6bcc0
commit
7014dfdfb5
@ -4,10 +4,13 @@ import com.nisemoe.generated.tables.references.BEATMAPS
|
|||||||
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.Format
|
import com.nisemoe.nise.Format
|
||||||
|
import com.nisemoe.nise.service.AuthService
|
||||||
import org.jooq.Condition
|
import org.jooq.Condition
|
||||||
import org.jooq.DSLContext
|
import org.jooq.DSLContext
|
||||||
import org.jooq.Field
|
import org.jooq.Field
|
||||||
import org.jooq.OrderField
|
import org.jooq.OrderField
|
||||||
|
import org.jooq.Record
|
||||||
|
import org.jooq.Result
|
||||||
import org.jooq.impl.DSL
|
import org.jooq.impl.DSL
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
@ -18,15 +21,32 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
class SearchController(
|
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(
|
data class SearchResponse(
|
||||||
val scores: List<SearchResponseEntry>,
|
val scores: List<SearchResponseEntry>,
|
||||||
|
val pagination: SearchResponsePagination
|
||||||
val totalResults: Int? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
data class SearchResponseEntry(
|
||||||
// User fields
|
// User fields
|
||||||
val user_id: Long?,
|
val user_id: Long?,
|
||||||
@ -92,7 +112,8 @@ class SearchController(
|
|||||||
|
|
||||||
data class SearchRequest(
|
data class SearchRequest(
|
||||||
val queries: List<SearchQuery>,
|
val queries: List<SearchQuery>,
|
||||||
val sorting: SearchSorting
|
val sorting: SearchSorting,
|
||||||
|
val page: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SearchSorting(
|
data class SearchSorting(
|
||||||
@ -102,7 +123,8 @@ class SearchController(
|
|||||||
|
|
||||||
data class SearchQuery(
|
data class SearchQuery(
|
||||||
val logicalOperator: String,
|
val logicalOperator: String,
|
||||||
val predicates: List<SearchPredicate>
|
val predicates: List<SearchPredicate>,
|
||||||
|
val childQueries: List<SearchQuery>?
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SearchPredicate(
|
data class SearchPredicate(
|
||||||
@ -117,24 +139,18 @@ class SearchController(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@PostMapping("search")
|
@PostMapping("search")
|
||||||
fun doSearch(
|
fun doSearch(@RequestBody request: SearchRequest, @RequestHeader("X-NISE-API") apiVersion: String): ResponseEntity<SearchResponse> {
|
||||||
@RequestBody request: SearchRequest,
|
if(!authService.isAdmin())
|
||||||
@RequestHeader("X-NISE-API") apiVersion: String
|
return ResponseEntity.status(401).build()
|
||||||
): ResponseEntity<SearchResponse> {
|
|
||||||
if (apiVersion.isBlank())
|
if (apiVersion.isBlank())
|
||||||
return ResponseEntity.badRequest().build()
|
return ResponseEntity.badRequest().build()
|
||||||
|
|
||||||
|
// TODO: Validation
|
||||||
|
|
||||||
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() }) {
|
||||||
var condition = buildCondition(query.predicates[0]) // Start with the first predicate
|
val condition = buildCondition(query)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseQuery = baseQuery.and(condition)
|
baseQuery = baseQuery.and(condition)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,79 +223,123 @@ class SearchController(
|
|||||||
if (request.sorting.field.isNotBlank())
|
if (request.sorting.field.isNotBlank())
|
||||||
orderBy(buildSorting(request.sorting))
|
orderBy(buildSorting(request.sorting))
|
||||||
}
|
}
|
||||||
.limit(50)
|
.offset((request.page - 1) * RESULTS_PER_PAGE)
|
||||||
|
.limit(RESULTS_PER_PAGE)
|
||||||
.fetch()
|
.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(
|
val response = SearchResponse(
|
||||||
scores = results.map {
|
scores = mapRecordToScores(results),
|
||||||
SearchResponseEntry(
|
pagination = SearchResponsePagination(
|
||||||
// User fields
|
currentPage = request.page,
|
||||||
user_id = it.get(SCORES.USER_ID),
|
pageSize = RESULTS_PER_PAGE,
|
||||||
user_username = it.get(USERS.USERNAME),
|
totalResults = totalResults
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return ResponseEntity.ok(response)
|
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<*> {
|
private fun buildSorting(sorting: SearchSorting): OrderField<*> {
|
||||||
val field = mapPredicateFieldToDatabaseField(sorting.field)
|
val field = mapPredicateFieldToDatabaseField(sorting.field)
|
||||||
return when (sorting.order.lowercase()) {
|
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)
|
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, predicate.value.toDouble())
|
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator, predicate.value.toDouble())
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
package com.nisemoe.nise.database
|
package com.nisemoe.nise.database
|
||||||
|
|
||||||
import com.nisemoe.generated.tables.records.ScoresJudgementsRecord
|
|
||||||
import com.nisemoe.generated.tables.records.ScoresRecord
|
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.*
|
||||||
import com.nisemoe.nise.osu.Mod
|
import com.nisemoe.nise.osu.Mod
|
||||||
import com.nisemoe.nise.service.AuthService
|
import com.nisemoe.nise.service.AuthService
|
||||||
|
|||||||
@ -38,10 +38,10 @@ class JudgementCompressionTest {
|
|||||||
@Test
|
@Test
|
||||||
fun compressionIntegrityTest() {
|
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)
|
.from(SCORES)
|
||||||
.where(SCORES.REPLAY.isNotNull)
|
.where(SCORES.REPLAY.isNotNull)
|
||||||
.limit(100)
|
.limit(500)
|
||||||
.fetchInto(ScoresRecord::class.java)
|
.fetchInto(ScoresRecord::class.java)
|
||||||
|
|
||||||
for(score in scores) {
|
for(score in scores) {
|
||||||
@ -60,6 +60,7 @@ class JudgementCompressionTest {
|
|||||||
|
|
||||||
val compressedData = compressJudgements.serialize(result.judgements)
|
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("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))
|
this.logger.info("Compressed (Brotli) size: {} bytes", String.format("%,d", compressedData.size))
|
||||||
|
|
||||||
|
|||||||
110
nise-frontend/package-lock.json
generated
110
nise-frontend/package-lock.json
generated
@ -24,6 +24,8 @@
|
|||||||
"ng2-charts": "^5.0.4",
|
"ng2-charts": "^5.0.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zone.js": "^0.14.3"
|
"zone.js": "^0.14.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -32,6 +34,7 @@
|
|||||||
"@angular/compiler-cli": "^17.0.9",
|
"@angular/compiler-cli": "^17.0.9",
|
||||||
"@angular/localize": "^17.1.1",
|
"@angular/localize": "^17.1.1",
|
||||||
"@types/jasmine": "~4.3.0",
|
"@types/jasmine": "~4.3.0",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
"jasmine-core": "~4.6.0",
|
"jasmine-core": "~4.6.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
@ -3845,6 +3848,12 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
|
||||||
@ -4100,6 +4109,14 @@
|
|||||||
"node": ">=8.9.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||||
@ -4963,6 +4992,14 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||||
@ -5267,6 +5304,17 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"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": {
|
"node_modules/critters": {
|
||||||
"version": "0.0.20",
|
"version": "0.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/critters/-/critters-0.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/critters/-/critters-0.0.20.tgz",
|
||||||
@ -6405,6 +6453,14 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@ -7141,9 +7197,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip": {
|
"node_modules/ip": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
|
||||||
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
|
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
@ -10943,6 +10999,17 @@
|
|||||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/ssri": {
|
||||||
"version": "10.0.5",
|
"version": "10.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
|
||||||
@ -11586,7 +11653,6 @@
|
|||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
@ -12035,6 +12101,22 @@
|
|||||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"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": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@ -26,6 +26,8 @@
|
|||||||
"ng2-charts": "^5.0.4",
|
"ng2-charts": "^5.0.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zone.js": "^0.14.3"
|
"zone.js": "^0.14.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -34,6 +36,7 @@
|
|||||||
"@angular/compiler-cli": "^17.0.9",
|
"@angular/compiler-cli": "^17.0.9",
|
||||||
"@angular/localize": "^17.1.1",
|
"@angular/localize": "^17.1.1",
|
||||||
"@types/jasmine": "~4.3.0",
|
"@types/jasmine": "~4.3.0",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
"jasmine-core": "~4.6.0",
|
"jasmine-core": "~4.6.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
|||||||
@ -15,3 +15,12 @@
|
|||||||
padding: 2px;
|
padding: 2px;
|
||||||
border: 1px solid rgba(179, 184, 195, 0.1);
|
border: 1px solid rgba(179, 184, 195, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-entry {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-entry:hover {
|
||||||
|
background-color: rgba(179, 184, 195, 0.15);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
<div class="main term">
|
<div class="main term">
|
||||||
<h1><span class="board">/k/</span> - Advanced Search</h1>
|
<h1><span class="board">/k/</span> - Advanced Search</h1>
|
||||||
|
|
||||||
<!-- <pre>{{ queries | json }}</pre>-->
|
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Table columns</legend>
|
<legend>Table columns</legend>
|
||||||
<ng-container *ngFor="let category of ['user', 'beatmap', 'score']">
|
<ng-container *ngFor="let category of ['user', 'beatmap', 'score']">
|
||||||
@ -29,7 +27,11 @@
|
|||||||
<legend>sorting</legend>
|
<legend>sorting</legend>
|
||||||
|
|
||||||
<select>
|
<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>
|
</select>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
|
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
|
||||||
@ -42,10 +44,31 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="text-center">
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="this.isLoading">
|
||||||
|
<div class="text-center">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container *ngIf="response">
|
<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">
|
<div class="scrollable-table">
|
||||||
<table class="table-border">
|
<table class="table-border">
|
||||||
<thead>
|
<thead>
|
||||||
@ -56,7 +79,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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">
|
<td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px">
|
||||||
<ng-container *ngIf="column.type == 'number'">
|
<ng-container *ngIf="column.type == 'number'">
|
||||||
{{ getValue(entry, column.name) | number }}
|
{{ getValue(entry, column.name) | number }}
|
||||||
@ -67,6 +90,14 @@
|
|||||||
<ng-container *ngIf="column.type == 'grade'">
|
<ng-container *ngIf="column.type == 'grade'">
|
||||||
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
|
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
|
||||||
</ng-container>
|
</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'">
|
<ng-container *ngIf="column.type == 'string'">
|
||||||
{{ getValue(entry, column.name) }}
|
{{ getValue(entry, column.name) }}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@ -75,6 +106,26 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,10 @@ import {environment} from "../../environments/environment";
|
|||||||
import {countryCodeToFlag} from "../format";
|
import {countryCodeToFlag} from "../format";
|
||||||
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
|
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
|
||||||
import {Field, Query, QueryBuilderComponent} from "../../corelib/components/query-builder/query-builder.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 {
|
interface SchemaField {
|
||||||
name: string;
|
name: string;
|
||||||
@ -18,6 +22,14 @@ interface SchemaField {
|
|||||||
|
|
||||||
interface SearchResponse {
|
interface SearchResponse {
|
||||||
scores: SearchResponseEntry[];
|
scores: SearchResponseEntry[];
|
||||||
|
pagination: SearchPagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchPagination {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalResults: number;
|
||||||
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Sorting {
|
interface Sorting {
|
||||||
@ -73,15 +85,18 @@ interface SearchResponseEntry {
|
|||||||
NgIf,
|
NgIf,
|
||||||
DecimalPipe,
|
DecimalPipe,
|
||||||
OsuGradeComponent,
|
OsuGradeComponent,
|
||||||
QueryBuilderComponent
|
QueryBuilderComponent,
|
||||||
|
RouterLink,
|
||||||
|
CalculatePageRangePipe
|
||||||
],
|
],
|
||||||
templateUrl: './search.component.html',
|
templateUrl: './search.component.html',
|
||||||
styleUrl: './search.component.css'
|
styleUrl: './search.component.css'
|
||||||
})
|
})
|
||||||
export class SearchComponent implements OnInit {
|
export class SearchComponent implements OnInit {
|
||||||
|
|
||||||
constructor(private httpClient: HttpClient) { }
|
constructor(private httpClient: HttpClient, public downloadFilesService: DownloadFilesService) { }
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
response: SearchResponse | null = null;
|
response: SearchResponse | null = null;
|
||||||
|
|
||||||
fields: SchemaField[] = [
|
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 {
|
saveColumnsStatusToLocalStorage(): void {
|
||||||
const statusMap = this.fields.reduce<{ [key: string]: boolean }>((acc, field) => {
|
const statusMap = this.fields.reduce<{ [key: string]: boolean }>((acc, field) => {
|
||||||
acc[field.name] = field.active;
|
acc[field.name] = field.active;
|
||||||
@ -192,15 +246,31 @@ export class SearchComponent implements OnInit {
|
|||||||
localStorage.setItem('columns_status', JSON.stringify(statusMap));
|
localStorage.setItem('columns_status', JSON.stringify(statusMap));
|
||||||
}
|
}
|
||||||
|
|
||||||
search(): void {
|
search(pageNumber: number = 1): void {
|
||||||
|
this.isLoading = true;
|
||||||
const body = {
|
const body = {
|
||||||
queries: this.queries,
|
queries: this.queries,
|
||||||
sorting: this.sortingOrder
|
sorting: this.sortingOrder,
|
||||||
|
page: pageNumber
|
||||||
}
|
}
|
||||||
this.httpClient.post<SearchResponse>(`${environment.apiUrl}/search`, body).subscribe(response => {
|
this.httpClient.post<SearchResponse>(`${environment.apiUrl}/search`, body).pipe(
|
||||||
this.response = response;
|
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
|
// Add this method to the SearchComponent class
|
||||||
@ -208,7 +278,6 @@ export class SearchComponent implements OnInit {
|
|||||||
return entry[columnName as keyof SearchResponseEntry];
|
return entry[columnName as keyof SearchResponseEntry];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
protected readonly countryCodeToFlag = countryCodeToFlag;
|
protected readonly countryCodeToFlag = countryCodeToFlag;
|
||||||
|
protected readonly Math = Math;
|
||||||
}
|
}
|
||||||
|
|||||||
23
nise-frontend/src/corelib/calculate-page-range.pipe.ts
Normal file
23
nise-frontend/src/corelib/calculate-page-range.pipe.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 */
|
|
||||||
}
|
|
||||||
@ -1,33 +1,7 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Query builder</legend>
|
<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>
|
|
||||||
<button (click)="addQuery()">+ Predicate</button>
|
<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>
|
</fieldset>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {Component, Input} from '@angular/core';
|
import {Component, Input} from '@angular/core';
|
||||||
import {FormsModule} from "@angular/forms";
|
import {FormsModule} from "@angular/forms";
|
||||||
import {NgForOf, NgIf} from "@angular/common";
|
import {NgForOf, NgIf} from "@angular/common";
|
||||||
|
import {QueryComponent} from "../query/query.component";
|
||||||
|
|
||||||
export type FieldType = 'number' | 'string' | 'flag' | 'grade' | 'boolean';
|
export type FieldType = 'number' | 'string' | 'flag' | 'grade' | 'boolean';
|
||||||
export type OperatorType = '=' | '>' | '<' | 'contains' | 'like' | '>=' | '<=' | '!=';
|
export type OperatorType = '=' | '>' | '<' | 'contains' | 'like' | '>=' | '<=' | '!=';
|
||||||
@ -19,6 +20,7 @@ export interface Predicate {
|
|||||||
export interface Query {
|
export interface Query {
|
||||||
predicates: Predicate[];
|
predicates: Predicate[];
|
||||||
logicalOperator: 'AND' | 'OR';
|
logicalOperator: 'AND' | 'OR';
|
||||||
|
childQueries?: Query[]; // Optional property for sub-queries
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -27,7 +29,8 @@ export interface Query {
|
|||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgForOf,
|
NgForOf,
|
||||||
NgIf
|
NgIf,
|
||||||
|
QueryComponent
|
||||||
],
|
],
|
||||||
templateUrl: './query-builder.component.html',
|
templateUrl: './query-builder.component.html',
|
||||||
styleUrl: './query-builder.component.css'
|
styleUrl: './query-builder.component.css'
|
||||||
@ -37,35 +40,6 @@ export class QueryBuilderComponent {
|
|||||||
@Input() queries: Query[] = [];
|
@Input() queries: Query[] = [];
|
||||||
@Input() fields: Field[] = [];
|
@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 {
|
addQuery(): void {
|
||||||
this.queries.push({
|
this.queries.push({
|
||||||
predicates: [],
|
predicates: [],
|
||||||
@ -73,9 +47,12 @@ export class QueryBuilderComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateLocalStorage(): void {
|
removeQuery(queryIndex: number): void {
|
||||||
console.warn('Updating local storage');
|
this.queries.splice(queryIndex, 1);
|
||||||
localStorage.setItem('search_queries', JSON.stringify(this.queries));
|
}
|
||||||
|
|
||||||
|
queryChanged(): void {
|
||||||
|
console.log(this.queries);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 */
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
36
nise-frontend/src/corelib/service/download-files.service.ts
Normal file
36
nise-frontend/src/corelib/service/download-files.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -74,7 +74,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearCurrentUserFromLocalStorage() {
|
clearCurrentUserFromLocalStorage() {
|
||||||
localStorage.clear();
|
localStorage.setItem('currentUser', '');
|
||||||
document.cookie = 'SESSION=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
document.cookie = 'SESSION=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user