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.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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
110
nise-frontend/package-lock.json
generated
110
nise-frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('currentUser', '');
|
||||
document.cookie = 'SESSION=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user