From ea44f6068e00103223b28923e46d6be106f0b1ef Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Fri, 15 Dec 2023 13:45:23 +0100 Subject: [PATCH] Registration and fuzzy search in progress --- .../org/jeudego/pairgoth/server/ApiServlet.kt | 4 +- .../org/jeudego/pairgoth/server/Logging.kt | 2 + view-webapp/pom.xml | 17 ++ .../pairgoth/ratings/EGFRatingsHandler.kt | 4 +- .../pairgoth/ratings/FFGRatingsHandler.kt | 10 +- .../jeudego/pairgoth/ratings/PlayerIndex.kt | 101 +++++++++++ .../pairgoth/ratings/RatingsHandler.kt | 12 +- .../pairgoth/ratings/RatingsManager.kt | 56 ++++++- .../org/jeudego/pairgoth/web/ApiException.kt | 36 ++++ .../org/jeudego/pairgoth/web/SearchServlet.kt | 158 ++++++++++++++++++ view-webapp/src/main/sass/main.scss | 47 +++--- view-webapp/src/main/sass/tour.scss | 36 +++- .../main/webapp/WEB-INF/layouts/standard.html | 18 +- view-webapp/src/main/webapp/WEB-INF/web.xml | 12 +- view-webapp/src/main/webapp/js/main.js | 33 +++- .../main/webapp/js/tour-registration.inc.js | 73 +++++++- .../sorts/tablesort.date.min.js | 6 + .../sorts/tablesort.dotsep.min.js | 6 + .../sorts/tablesort.filesize.min.js | 6 + .../sorts/tablesort.monthname.min.js | 6 + .../sorts/tablesort.number.min.js | 6 + .../webapp/lib/tablesort-5.4.0/tablesort.css | 33 ++++ .../tablesort.min.js} | 4 +- .../src/main/webapp/tour-information.inc.html | 58 +++---- .../main/webapp/tour-registration.inc.html | 64 +++++-- view-webapp/src/main/webapp/tour.html | 2 + 26 files changed, 708 insertions(+), 102 deletions(-) create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/PlayerIndex.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiException.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/SearchServlet.kt create mode 100644 view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.date.min.js create mode 100644 view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.dotsep.min.js create mode 100644 view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.filesize.min.js create mode 100644 view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.monthname.min.js create mode 100644 view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.number.min.js create mode 100644 view-webapp/src/main/webapp/lib/tablesort-5.4.0/tablesort.css rename view-webapp/src/main/webapp/lib/{tablesort-5.4.0.min.js => tablesort-5.4.0/tablesort.min.js} (96%) diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt index 3404cb6..c371e49 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt @@ -23,7 +23,7 @@ import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse -class ApiServlet : HttpServlet() { +class ApiServlet: HttpServlet() { public override fun doGet(request: HttpServletRequest, response: HttpServletResponse) { doRequest(request, response) @@ -90,7 +90,7 @@ class ApiServlet : HttpServlet() { "team" -> TeamHandler else -> ApiHandler.badRequest("unknown sub-entity: $subEntity") } - "player" -> PlayerHandler + // "player" -> PlayerHandler else -> ApiHandler.badRequest("unknown entity: $entity") } diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/Logging.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/Logging.kt index b975f39..10c7615 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/Logging.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/Logging.kt @@ -15,9 +15,11 @@ fun Logger.logRequest(req: HttpServletRequest, logHeaders: Boolean = false) { .append(req.localName) val port = req.localPort if (port != 80) builder.append(':').append(port) + /* if (!req.contextPath.isEmpty()) { builder.append(req.contextPath) } + */ builder.append(req.requestURI) if (req.method == "GET") { val qs = req.queryString diff --git a/view-webapp/pom.xml b/view-webapp/pom.xml index a9be6fa..fc541b1 100644 --- a/view-webapp/pom.xml +++ b/view-webapp/pom.xml @@ -17,6 +17,7 @@ TODO 5.7.1 + 9.9.0 package @@ -247,6 +248,22 @@ velocity-engine-core 2.4-SNAPSHOT + + + org.apache.lucene + lucene-core + ${lucene.version} + + + org.apache.lucene + lucene-analysis-common + ${lucene.version} + + + org.apache.lucene + lucene-queryparser + ${lucene.version} + org.junit.jupiter diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/EGFRatingsHandler.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/EGFRatingsHandler.kt index d56f42d..200b1b7 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/EGFRatingsHandler.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/EGFRatingsHandler.kt @@ -17,7 +17,9 @@ object EGFRatingsHandler: RatingsHandler(RatingsManager.Ratings.EGF) { val pairs = groups.map { Pair(it, match.groups[it]?.value) }.toTypedArray() - Json.Object(*pairs) + Json.MutableObject(*pairs).also { + it["origin"] = "EGF" + } } } } diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/FFGRatingsHandler.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/FFGRatingsHandler.kt index 5db4c70..98d3155 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/FFGRatingsHandler.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/FFGRatingsHandler.kt @@ -11,16 +11,18 @@ import java.nio.charset.StandardCharsets object FFGRatingsHandler: RatingsHandler(RatingsManager.Ratings.FFG) { override val defaultURL = URL("https://ffg.jeudego.org/echelle/echtxt/ech_ffg_V3.txt") override fun parsePayload(payload: String): Json.Array { - return payload.lines().mapNotNullTo(Json.MutableArray()) { - val match = linePattern.matchEntire(it) + return payload.lines().mapNotNullTo(Json.MutableArray()) { line -> + val match = linePattern.matchEntire(line) if (match == null) { - logger.error("could not parse line: $it") + logger.error("could not parse line: $line") null } else { val pairs = groups.map { Pair(it, match.groups[it]?.value) }.toTypedArray() - Json.Object(*pairs) + Json.MutableObject(*pairs).also { + it["origin"] = "FFG" + } } } } diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/PlayerIndex.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/PlayerIndex.kt new file mode 100644 index 0000000..fce0d79 --- /dev/null +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/PlayerIndex.kt @@ -0,0 +1,101 @@ +package org.jeudego.pairgoth.ratings + +import com.republicate.kson.Json +import org.apache.lucene.analysis.LowerCaseFilter +import org.apache.lucene.analysis.standard.StandardAnalyzer +import org.apache.lucene.document.Document +import org.apache.lucene.document.Field +import org.apache.lucene.document.StoredField +import org.apache.lucene.document.StringField +import org.apache.lucene.document.TextField +import org.apache.lucene.index.DirectoryReader +import org.apache.lucene.index.IndexWriter +import org.apache.lucene.index.IndexWriterConfig +import org.apache.lucene.index.Term +import org.apache.lucene.queryparser.complexPhrase.ComplexPhraseQueryParser +import org.apache.lucene.search.BooleanClause +import org.apache.lucene.search.BooleanQuery +import org.apache.lucene.search.FuzzyQuery +import org.apache.lucene.search.IndexSearcher +import org.apache.lucene.search.TermQuery +import org.apache.lucene.store.ByteBuffersDirectory +import org.apache.lucene.store.Directory +import org.apache.lucene.store.NoLockFactory +import org.slf4j.LoggerFactory +import java.util.* + +class PlayerIndex { + companion object { + val ID = "id" + val ORIGIN = "origin" + val NAME = "name" + val FIRSTNAME = "firstname" + val TEXT = "text" + + val MAX_HITS = 100 + val logger = LoggerFactory.getLogger("index") + val queryParser = ComplexPhraseQueryParser(TEXT, StandardAnalyzer()) + } + private final val directory: Directory = ByteBuffersDirectory(NoLockFactory.INSTANCE) + private val reader by lazy { DirectoryReader.open(directory) } + private val searcher by lazy { IndexSearcher(reader) } + + // helper functions + fun Json.Object.field(key: String) = getString(key) ?: throw Error("missing $key") + fun Json.Object.nullableField(key: String) = getString(key) ?: "" + + fun build(players: Json.Array) { + logger.info("indexing players") + var count = 0L + IndexWriter(directory, IndexWriterConfig(StandardAnalyzer()).apply { + setOpenMode(IndexWriterConfig.OpenMode.CREATE) + }).use { writer -> + players.forEachIndexed { i, p -> + val player = p as Json.Object + val origin = p.getString(ORIGIN) ?: throw Error("unknown origin") + val text = player.field(NAME) + val doc = Document() + doc.add(StoredField(ID, i)); + doc.add(StringField(ORIGIN, player.field(ORIGIN), Field.Store.NO)) + doc.add(TextField(TEXT, "${player.field(NAME)} ${player.nullableField(FIRSTNAME)}", Field.Store.NO)) + writer.addDocument(doc); + ++count + } + } + logger.info("indexed $count players") + } + + fun match(needle: String, origins: Int): List { + // val fuzzy = FuzzyQuery(Term(TEXT, needle)) + val terms = needle.split(Regex("[ -_']+")) + .filter { !it.isEmpty() } + .map { "$it~" } + .joinToString(" ") + val fuzzy = queryParser.parse(terms) + val activeMask = RatingsManager.activeMask() + val query = when (origins.countOneBits()) { + 0 -> return emptyList() + 1 -> { + val filter = TermQuery(Term(ORIGIN, RatingsManager.Ratings.codeOf(origins))) + BooleanQuery.Builder() + .add(fuzzy, BooleanClause.Occur.SHOULD) + .add(filter, BooleanClause.Occur.MUST) + .build() + } + 2 -> { + if (activeMask.countOneBits() > 2) { + val filter = + TermQuery(Term(ORIGIN, RatingsManager.Ratings.codeOf((origins xor activeMask) and activeMask))) + BooleanQuery.Builder() + .add(fuzzy, BooleanClause.Occur.SHOULD) + .add(filter, BooleanClause.Occur.MUST_NOT) + .build() + } else fuzzy + } + 3 -> fuzzy + else -> throw Error("wrong origins mask") + } + val docs = searcher.search(query, MAX_HITS) + return docs.scoreDocs.map { searcher.doc(it.doc).getField(ID).numericValue().toInt() }.toList() + } +} diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/RatingsHandler.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/RatingsHandler.kt index 1180be9..15eeb51 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/RatingsHandler.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/RatingsHandler.kt @@ -19,13 +19,14 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) { open val active = true val cacheFile = RatingsManager.path.resolve("${origin.name}.json").toFile() lateinit var players: Json.Array + private var updated = false val url: URL by lazy { WebappManager.getProperty("ratings.${origin.name.lowercase(Locale.ROOT)}")?.let { URL(it) } ?: defaultURL } - fun updateIfNeeded() { - if (Date().time - cacheFile.lastModified() > delay) { + fun updateIfNeeded(): Boolean { + return if (Date().time - cacheFile.lastModified() > delay) { RatingsManager.logger.info("Updating $origin cache from $url") val payload = fetchPayload() players = parsePayload(payload).also { @@ -34,13 +35,17 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) { out.println(cachePayload) } } + true } else if (!this::players.isInitialized) { players = Json.parse(cacheFile.readText())?.asArray() ?: Json.Array() + true + } else { + false } } fun fetchPlayers(): Json.Array { - updateIfNeeded() + updated = updateIfNeeded() return players } @@ -56,6 +61,7 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) { } } open fun defaultCharset() = StandardCharsets.UTF_8 + fun updated() = updated abstract fun parsePayload(payload: String): Json.Array val logger = LoggerFactory.getLogger(origin.name) val atom = "[-._`'a-zA-ZÀ-ÿ]" diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/RatingsManager.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/RatingsManager.kt index 74a5dbf..2e2f778 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/RatingsManager.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/ratings/RatingsManager.kt @@ -3,15 +3,25 @@ package org.jeudego.pairgoth.ratings import com.republicate.kson.Json import org.jeudego.pairgoth.web.WebappManager import org.slf4j.LoggerFactory +import java.lang.Exception import java.nio.file.Path import java.util.* +import java.util.concurrent.locks.ReadWriteLock +import java.util.concurrent.locks.ReentrantReadWriteLock object RatingsManager: Runnable { - enum class Ratings { - AGA, - EGF, - FFG + enum class Ratings(val flag: Int) { + AGA(1), + EGF(2), + FFG(4); + companion object { + fun valueOf(mask: Int): Ratings { + if (mask.countOneBits() != 1) throw Error("wrong use") + return values().filter { it.flag == mask }.firstOrNull() ?: throw Error("wrong mask") + } + fun codeOf(mask: Int) = valueOf(mask).name.lowercase(Locale.ROOT) + } } val ratingsHandlers by lazy { @@ -22,16 +32,34 @@ object RatingsManager: Runnable { ); } + fun activeMask() = ratingsHandlers.entries.filter { it.value.active }.map { it.key.flag }.reduce { a,b -> a or b } + val timer = Timer() lateinit var players: Json.MutableArray + val updateLock: ReadWriteLock = ReentrantReadWriteLock() override fun run() { logger.info("launching ratings manager") timer.scheduleAtFixedRate(Task, 0L, 3600000L) } object Task: TimerTask() { override fun run() { - players = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings -> - ratings.fetchPlayers() + try { + players = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings -> + ratings.fetchPlayers() + } + val updated = ratingsHandlers.values.filter { it.active }.map { it.updated() }.reduce { u1, u2 -> + u1 or u2 + } + if (updated) { + try { + updateLock.writeLock().lock() + index.build(players) + } finally { + updateLock.writeLock().unlock() + } + } + } catch (e: Exception) { + logger.error("could not build or refresh index", e) } } } @@ -40,4 +68,20 @@ object RatingsManager: Runnable { val file = it.toFile() if (!file.mkdirs() && !file.isDirectory) throw Error("Property pairgoth.ratings.path must be a directory") } + + fun search(needle: String, aga: Boolean, egf: Boolean, ffg: Boolean): Json.Array { + try { + updateLock.readLock().lock() + var mask = 0 + if (aga && ratingsHandlers[Ratings.AGA]!!.active) mask = mask or Ratings.AGA.flag + if (egf && ratingsHandlers[Ratings.EGF]!!.active) mask = mask or Ratings.EGF.flag + if (ffg && ratingsHandlers[Ratings.FFG]!!.active) mask = mask or Ratings.FFG.flag + val matches = index.match(needle, mask) + return matches.map { it -> players[it] }.toCollection(Json.MutableArray()) + } finally { + updateLock.readLock().unlock() + } + + } + val index = PlayerIndex() } diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiException.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiException.kt new file mode 100644 index 0000000..13d2a9d --- /dev/null +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiException.kt @@ -0,0 +1,36 @@ +package org.jeudego.pairgoth.web + +import com.republicate.kson.Json +import java.io.IOException + +class ApiException : IOException { + var code: Int + private set + var details: Json.Object + private set + + constructor(code: Int) : super("error") { + this.code = code + details = Json.Object("message" to message) + } + + constructor(code: Int, message: String?) : super(message) { + this.code = code + details = Json.Object("message" to message) + } + + constructor(code: Int, cause: Exception) : super(cause) { + this.code = code + details = Json.Object("message" to "Erreur interne du serveur : " + cause.message) + } + + constructor(code: Int, message: String, cause: Exception) : super(message, cause) { + this.code = code + details = Json.Object("message" to message + " : " + cause.message) + } + + constructor(code: Int, details: Json.Object) : super(details.getString("message")) { + this.code = code + this.details = details + } +} \ No newline at end of file diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/SearchServlet.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/SearchServlet.kt new file mode 100644 index 0000000..4c62668 --- /dev/null +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/SearchServlet.kt @@ -0,0 +1,158 @@ +package org.jeudego.pairgoth.web + +import com.republicate.kson.Json +import org.jeudego.pairgoth.ratings.RatingsManager +import org.jeudego.pairgoth.util.Colorizer +import org.jeudego.pairgoth.util.parse +import org.jeudego.pairgoth.util.toString +import org.slf4j.LoggerFactory +import java.io.IOException +import java.util.* +import javax.servlet.http.HttpServlet +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class SearchServlet: HttpServlet() { + + public override fun doPost(request: HttpServletRequest, response: HttpServletResponse) { + val uri = request.requestURI + logger.logRequest(request, !uri.contains(".") && uri.length > 1) + var payload: Json? = null + var reason = "OK" + try { + validateContentType(request) + val query = request.getAttribute(PAYLOAD_KEY) as Json.Object? ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no payload") + val needle = query.getString("needle") ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no needle") + val aga = query.getBoolean("aga") ?: false + val egf = query.getBoolean("egf") ?: false + val ffg = query.getBoolean("ffg") ?: false + payload = RatingsManager.search(needle, aga, egf, ffg) + setContentType(response) + payload.toString(response.writer) + } catch (ioe: IOException) { + logger.error(Colorizer.red("could not process call"), ioe) + reason = ioe.message ?: "unknown i/o exception" + error(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, reason, ioe) + } finally { + val builder = StringBuilder() + builder.append(response.status).append(' ') + .append(reason) + if (response.status == HttpServletResponse.SC_INTERNAL_SERVER_ERROR) { + logger.trace(Colorizer.red(">> {}"), builder.toString()) + } else { + logger.trace(Colorizer.green(">> {}"), builder.toString()) + } + + // CB TODO - should be bufferized and asynchronously written in synchronous chunks + // so that header lines from parallel requests are not mixed up in the logs ; + // synchronizing the whole request log is not desirable + for (header in response.headerNames) { + val value = response.getHeader(header) + logger.trace(Colorizer.green(">> {}: {}"), header, value) + } + if (payload != null) { + try { + logger.logPayload(">> ", payload, false) + } catch (ioe: IOException) { + } + } + } + + } + + @Throws(ApiException::class) + protected fun validateContentType(request: HttpServletRequest) { + // extract content type parts + val contentType = request.contentType + if (contentType == null) { + if (request.method == "GET") return + throw ApiException( + HttpServletResponse.SC_BAD_REQUEST, + "no content type header" + ) + } + val sep = contentType.indexOf(';') + val mimeType: String + var charset: String? = null + if (sep == -1) mimeType = contentType else { + mimeType = contentType.substring(0, sep).trim { it <= ' ' } + val params = + contentType.substring(sep + 1).split("=".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + if (params.size == 2 && params[0].lowercase(Locale.getDefault()) + .trim { it <= ' ' } == "charset" + ) { + charset = params[1].lowercase(Locale.getDefault()).trim { it <= ' ' } + .replace("-".toRegex(), "") + } + } + + // check charset + if (charset != null && EXPECTED_CHARSET != charset.lowercase(Locale.ROOT).replace("-", "")) throw ApiException( + HttpServletResponse.SC_BAD_REQUEST, + "UTF-8 content expected" + ) + + // check content type + if (isJson(mimeType)) { + // put Json body as request attribute + try { + Json.parse(request.reader)?.let { payload: Json -> + request.setAttribute(PAYLOAD_KEY, payload) + if (logger.isInfoEnabled) { + logger.logPayload("<< ", payload, true) + } + } + } catch (ioe: IOException) { + throw ApiException(HttpServletResponse.SC_BAD_REQUEST, ioe) + } + } + else throw ApiException( + HttpServletResponse.SC_BAD_REQUEST, + "JSON content expected" + ) + + } + + protected fun error( + request: HttpServletRequest, + response: HttpServletResponse, + code: Int, + message: String?, + cause: Throwable? = null + ) { + try { + if (code == 500 || response.isCommitted) { + logger.error( + "Request {} {} gave error {} {}", + request.method, + request.requestURI, + code, + message, + cause + ) + } + response.status = code + if (response.isCommitted) return + val errorPayload = Json.Object( + "success" to false, + "error" to (message ?: "unknown error") + ) + setContentType(response) + errorPayload.toString(response.writer) + } catch (ioe: IOException) { + logger.error("Could not send back error", ioe) + } + } + + protected fun setContentType(response: HttpServletResponse) { + response.contentType = "application/json; charset=UTF-8" + } + + companion object { + private var logger = LoggerFactory.getLogger("search") + private const val EXPECTED_CHARSET = "utf8" + const val PAYLOAD_KEY = "PAYLOAD" + fun isJson(mimeType: String) = "text/json" == mimeType || "application/json" == mimeType || mimeType.endsWith("+json") + } +} diff --git a/view-webapp/src/main/sass/main.scss b/view-webapp/src/main/sass/main.scss index f9e31f4..c1f068f 100644 --- a/view-webapp/src/main/sass/main.scss +++ b/view-webapp/src/main/sass/main.scss @@ -79,6 +79,7 @@ } .section { + text-align: center; padding: 0.5em; } @@ -152,27 +153,31 @@ position: relative; cursor: pointer; transform: scale(1.2); - #lang-list { - position: absolute; - display: none; - top:100%; - right: 1em; - flex-flow: column nowrap; - padding: 0.5em; - gap: 0.5em; - background-color: #dddddd; - align-items: flex-start; - z-index: 50; - &.shown { - display: flex; - } - a { - display: inline-block; - white-space: nowrap; - text-align: center; - i { - vertical-align: middle; - } + } + + #lang-list { + position: fixed; + display: none; + top:3em; + right: 1em; + flex-flow: column nowrap; + padding: 0.5em; + gap: 0.5em; + background-color: #dddddd; + align-items: flex-start; + z-index: 60; + &.shown { + display: flex; + } + .lang { + cursor: pointer; + } + a { + display: inline-block; + white-space: nowrap; + text-align: center; + i { + vertical-align: middle; } } } diff --git a/view-webapp/src/main/sass/tour.scss b/view-webapp/src/main/sass/tour.scss index cfe55d4..e03f1e1 100644 --- a/view-webapp/src/main/sass/tour.scss +++ b/view-webapp/src/main/sass/tour.scss @@ -32,9 +32,43 @@ justify-content: space-around; margin: 1px; background-color: #eeeeee; + &.centered { + align-items: center; + } + } + .inline.fields { + background-color: #eeeeee; + margin-left: -0.5em; + margin-right: -0.5em; + padding-left: 0.5em; + padding-right: 0.5em; + .centered.field > label { + margin-right: 0; + } } /* registration section */ - + #player-form { + &:not(.add) { + #search-form, #search-result { + display: none; + } + } + } + #search-form { + position: relative; + } + #search-result { + position: absolute; + background-color: gray; + z-index: 2; + width:100%; + top: 100%; + padding: 1em; + overflow-y: auto; + &.hidden { + display: none; + } + } } diff --git a/view-webapp/src/main/webapp/WEB-INF/layouts/standard.html b/view-webapp/src/main/webapp/WEB-INF/layouts/standard.html index fad05e1..30c41a4 100644 --- a/view-webapp/src/main/webapp/WEB-INF/layouts/standard.html +++ b/view-webapp/src/main/webapp/WEB-INF/layouts/standard.html @@ -36,13 +36,6 @@
-
-#foreach($lang in $translate.flags.entrySet()) - #if($lang != $request.lang) -  $lang.key - #end -#end -
@@ -60,8 +53,17 @@
+
+#foreach($lang in $translate.flags.entrySet()) + #if($lang != $request.lang) +  $lang.key + #end +#end +
- + + + diff --git a/view-webapp/src/main/webapp/WEB-INF/web.xml b/view-webapp/src/main/webapp/WEB-INF/web.xml index 26b0e9e..c7b8823 100644 --- a/view-webapp/src/main/webapp/WEB-INF/web.xml +++ b/view-webapp/src/main/webapp/WEB-INF/web.xml @@ -65,6 +65,12 @@ 1 true + + search + org.jeudego.pairgoth.web.SearchServlet + 1 + true + @@ -77,7 +83,11 @@ api - /api/* + /api/tour/* + + + search + /api/search/* diff --git a/view-webapp/src/main/webapp/js/main.js b/view-webapp/src/main/webapp/js/main.js index 16ae0b8..09750c9 100644 --- a/view-webapp/src/main/webapp/js/main.js +++ b/view-webapp/src/main/webapp/js/main.js @@ -105,7 +105,8 @@ Element.prototype.modal = function(show) { /* DOM helpers */ -HTMLFormElement.prototype.val = function(name) { +HTMLFormElement.prototype.val = function(name, value) { + let hasValue = typeof(value) !== 'undefined'; let ctl = this.find(`[name="${name}"]`)[0]; if (!ctl) { console.error(`unknown input name: ${name}`) @@ -113,15 +114,30 @@ HTMLFormElement.prototype.val = function(name) { let tag = ctl.tagName; let type = tag === 'INPUT' ? ctl.attr('type') : undefined; if ( - (tag === 'INPUT' && ['text', 'number'].includes(ctl.attr('type'))) || + (tag === 'INPUT' && ['text', 'number', 'hidden'].includes(ctl.attr('type'))) || tag === 'SELECT' ) { - return ctl.value; + if (hasValue) { + ctl.value = value; + return; + } + else return ctl.value; } else if (tag === 'INPUT' && ctl.attr('type') === 'radio') { - ctl = $(`input[name="${name}"]:checked`)[0]; - if (ctl) return ctl.value; + if (hasValue) { + ctl = $(`input[name="${name}"][value="${value}"]`); + if (ctl) ctl.checked = true; + return; + } else { + ctl = $(`input[name="${name}"]:checked`); + if (ctl) return ctl[0].value; + else return null; + } } else if (tag === 'INPUT' && ctl.attr('type') === 'checkbox') { - return ctl.checked; + if (hasValue) { + ctl.checked = value !== 'false' && Boolean(value); + return; + } + else return ctl.checked; } console.error(`unhandled input tag or type for input ${name} (tag: ${tag}, type:${type}`); return null; @@ -142,6 +158,11 @@ function modal(id) { $(`#${id}.popup`).addClass('shown'); } +function close_modal() { + $('body').removeClass('dimmed'); + $(`.popup`).removeClass('shown'); +} + onLoad(() => { $('button.close').on('click', e => { let modal = e.target.closest('.popup'); diff --git a/view-webapp/src/main/webapp/js/tour-registration.inc.js b/view-webapp/src/main/webapp/js/tour-registration.inc.js index 1bb43dc..000c66c 100644 --- a/view-webapp/src/main/webapp/js/tour-registration.inc.js +++ b/view-webapp/src/main/webapp/js/tour-registration.inc.js @@ -5,8 +5,23 @@ onLoad(() => { min: 0, max: 4000 }); + new Tablesort($('#players')[0]); + $('#add').on('click', e => { + let form = $('#player-form')[0]; + form.addClass('add'); + // $('#player-form input.participation').forEach(chk => chk.checked = true); + form.reset(); + modal('player'); + }); + $('#cancel-register').on('click', e => { + e.preventDefault(); + close_modal(); + return false; + }); + $('#register').on('click', e => { let form = e.target.closest('form'); + let valid = true; let required = ['name', 'firstname', 'country', 'club', 'rank', 'rating']; for (let name of required) { let ctl = form.find(`[name=${name}]`)[0]; @@ -29,15 +44,65 @@ onLoad(() => { rating: form.val('rating'), rank: form.val('rank'), country: form.val('country'), - club: form.val('club') + club: form.val('club'), + skip: form.find('input.participation').map((input,i) => [i+1, input.checked]).filter(arr => !arr[1]).map(arr => arr[0]) } console.log(player); - api.postJson(`tour/${tour_id}/part`, player) + if (form.hasClass('add')) { + api.postJson(`tour/${tour_id}/part`, player) + .then(player => { + console.log(player) + if (player !== 'error') { + window.location.reload(); + } + }); + } else { + let id = form.val('id'); + player['id'] = id; + api.putJson(`tour/${tour_id}/part/${id}`, player) + .then(player => { + console.log(player) + if (player !== 'error') { + window.location.reload(); + } + }); + } + }); + $('#players > tbody > tr').on('click', e => { + let id = e.target.closest('tr').attr('data-id'); + api.getJson(`tour/${tour_id}/part/${id}`) .then(player => { - console.log(player) if (player !== 'error') { - window.location.reload(); + let form = $('#player-form')[0]; + form.val('id', player.id); + form.val('name', player.name); + form.val('firstname', player.firstname); + form.val('rating', player.rating); + form.val('rank', player.rank); + form.val('country', player.country); + form.val('club', player.club); + for (r = 1; r <= tour_rounds; ++r) { + form.val(`r${r}`, !(player.skip && player.skip.includes(r))); + } + form.removeClass('add'); + modal('player'); } }); }); + $('#needle').on('input', e => { + let needle = $('#needle')[0].value; + if (needle && needle.length > 2) { + let form = $('#player-form')[0]; + let search = { + needle: needle, + aga: form.val('aga'), + egf: form.val('egf'), + ffg: form.val('ffg') + } + api.postJson('search', search) + .then(result => { + console.log(result); + }) + } else $('#search-result').addClass('hidden'); + }); }); diff --git a/view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.date.min.js b/view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.date.min.js new file mode 100644 index 0000000..010105a --- /dev/null +++ b/view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.date.min.js @@ -0,0 +1,6 @@ +/*! + * tablesort v5.4.0 (2023-05-04) + * http://tristen.ca/tablesort/demo/ + * Copyright (c) 2023 ; Licensed MIT +*/ +!function(){function r(e){return e=(e=e.replace(/\-/g,"/")).replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/,"$3-$2-$1"),new Date(e).getTime()||-1}Tablesort.extend("date",function(e){return(-1!==e.search(/(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\.?\,?\s*/i)||-1!==e.search(/\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}/)||-1!==e.search(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/i))&&!isNaN(r(e))},function(e,n){return e=e.toLowerCase(),n=n.toLowerCase(),r(n)-r(e)})}(); \ No newline at end of file diff --git a/view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.dotsep.min.js b/view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.dotsep.min.js new file mode 100644 index 0000000..1bb2f02 --- /dev/null +++ b/view-webapp/src/main/webapp/lib/tablesort-5.4.0/sorts/tablesort.dotsep.min.js @@ -0,0 +1,6 @@ +/*! + * tablesort v5.4.0 (2023-05-04) + * http://tristen.ca/tablesort/demo/ + * Copyright (c) 2023 ; Licensed MIT +*/ +Tablesort.extend("dotsep",function(t){return/^(\d+\.)+\d+$/.test(t)},function(t,r){t=t.split("."),r=r.split(".");for(var e,n,i=0,s=t.length;iTournament type
@@ -78,9 +78,9 @@
#* MM floor parameter not shown on creation page @@ -117,24 +117,24 @@ #levels($limit) - - -
- - - + +
+ + + +
diff --git a/view-webapp/src/main/webapp/tour-registration.inc.html b/view-webapp/src/main/webapp/tour-registration.inc.html index 944c8d7..733a39f 100644 --- a/view-webapp/src/main/webapp/tour-registration.inc.html +++ b/view-webapp/src/main/webapp/tour-registration.inc.html @@ -2,7 +2,39 @@
#set($parts = $api.get("tour/${params.id}/part")) -$parts + + + + + + + + + + + +#foreach($part in $parts) + + + + + + + + + +#end + +
namefirst namecountryclubrankratingparticipation
$part.name$part.firstname$part.country$part.club#rank($part.rank)$part.rating + #foreach($round in [1..$tour.rounds]) + ## CB TODO - upstream json parsing should not give longs here, should it? + #if($part.skip && $part.skip.contains($round.longValue())) + + #else + + #end + #end +
@@ -104,10 +147,3 @@ $parts
- diff --git a/view-webapp/src/main/webapp/tour.html b/view-webapp/src/main/webapp/tour.html index 4f2b7ca..4dbd0a2 100644 --- a/view-webapp/src/main/webapp/tour.html +++ b/view-webapp/src/main/webapp/tour.html @@ -11,6 +11,7 @@ #end #end +#macro(rank $rank)#if( $rank<0 )#set( $k = -$rank )${k}k#else#set( $d=$rank+1 )${d}d#end#end #if($params.id) #set($tour = $api.get("tour/${params.id}")) #if (!$tour) @@ -63,6 +64,7 @@