Parse EGF and FFG ratings

This commit is contained in:
Claude Brisson
2023-12-04 21:12:54 +01:00
parent 8510bb69ec
commit 519eca8af3
12 changed files with 274 additions and 53 deletions

View File

@@ -9,7 +9,7 @@ private fun createStoreImplementation(): StoreImplementation {
return when (val storeProperty = WebappManager.getProperty("store") ?: "memory") { return when (val storeProperty = WebappManager.getProperty("store") ?: "memory") {
"memory" -> MemoryStore() "memory" -> MemoryStore()
"file" -> { "file" -> {
val filePath = WebappManager.getMandatoryProperty("store.file.path") ?: "." val filePath = WebappManager.getProperty("store.file.path") ?: "."
FileStore(filePath) FileStore(filePath)
} }
else -> throw Error("unknown store: $storeProperty") else -> throw Error("unknown store: $storeProperty")

View File

@@ -0,0 +1,14 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import java.net.URL
object AGARatingsHandler: RatingsHandler(RatingsManager.Ratings.AGA) {
override val defaultURL: URL by lazy {
throw Error("No URL for AGA...")
}
override val active = false
override fun parsePayload(payload: String): Json.Array {
return Json.Array()
}
}

View File

@@ -0,0 +1,28 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import java.net.URL
object EGFRatingsHandler: RatingsHandler(RatingsManager.Ratings.EGF) {
override val defaultURL = URL("https://www.europeangodatabase.eu/EGD/EGD_2_0/downloads/allworld_lp.html")
override fun parsePayload(payload: String): Json.Array {
return payload.lines().filter {
it.matches(Regex("\\s+\\d+(?!.*\\(undefined\\)|Anonymous).*"))
}.mapNotNullTo(Json.MutableArray()) {
val match = linePattern.matchEntire(it)
if (match == null) {
logger.error("could not parse line: $it")
null
} else {
val pairs = groups.map {
Pair(it, match.groups[it]?.value)
}.toTypedArray()
Json.Object(*pairs)
}
}
}
// 19574643 Abad Jahin FR 38GJ 20k -- 15 2 T200202B
var linePattern =
Regex("\\s+(?<egf>\\d{8})\\s+(?<name>$atom+)\\s(?<firstname>$atom+)?,?\\s+(?<country>[A-Z]{2})\\s+(?<club>\\S{1,4})\\s+(?<grade>[1-9][0-9]?[kdp])\\s+(?<promotion>[1-9][0-9]?[kdp]|--)\\s+(?<rating>-?[0-9]+)\\s+(?<nt>[0-9]+)\\s+(?<last>\\S+)\\s*")
val groups = arrayOf("egf", "name", "firstname", "country", "club", "grade", "rating")
}

View File

@@ -0,0 +1,33 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URL
import java.nio.charset.Charset
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)
if (match == null) {
logger.error("could not parse line: $it")
null
} else {
val pairs = groups.map {
Pair(it, match.groups[it]?.value)
}.toTypedArray()
Json.Object(*pairs)
}
}
}
override fun defaultCharset() = StandardCharsets.ISO_8859_1
var linePattern =
Regex("(?<name>$atom+(?:\\((?:$atom|[0-9])+\\))?)\\s(?<firstname>$atom+(?:\\((?:$atom|[0-9])+\\))?)\\s+(?<rating>-?[0-9]+)\\s(?<license>[-eCLX])\\s(?<ffg>(?:\\d|[A-Z]){7}|-------)\\s(?<club>xxxx|XXXX|\\d{2}[a-zA-Z0-9]{2})\\s(?<country>[A-Z]{2})")
val groups = arrayOf("name", "firstname", "rating", "license", "club", "country")
}

View File

@@ -0,0 +1,62 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jeudego.pairgoth.web.WebappManager
import org.slf4j.LoggerFactory
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.*
import java.util.concurrent.TimeUnit
abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
private val delay = TimeUnit.HOURS.toMillis(1L)
private val client = OkHttpClient()
abstract val defaultURL: URL
open val active = true
val cacheFile = RatingsManager.path.resolve("${origin.name}.json").toFile()
lateinit var players: Json.Array
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) {
RatingsManager.logger.info("Updating $origin cache from $url")
val payload = fetchPayload()
players = parsePayload(payload).also {
val cachePayload = it.toString()
cacheFile.printWriter().use { out ->
out.println(cachePayload)
}
}
} else if (!this::players.isInitialized) {
players = Json.parse(cacheFile.readText())?.asArray() ?: Json.Array()
}
}
fun fetchPlayers(): Json.Array {
updateIfNeeded()
return players
}
protected fun fetchPayload(): String {
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Error("Could not fetch $origin ratings: unexpected code $response")
val contentType = response.headers["Content-Type"]?.toMediaType()
return response.body!!.source().readString(contentType?.charset() ?: defaultCharset())
}
}
open fun defaultCharset() = StandardCharsets.UTF_8
abstract fun parsePayload(payload: String): Json.Array
val logger = LoggerFactory.getLogger(origin.name)
val atom = "[-._`'a-zA-ZÀ-ÿ]"
}

View File

@@ -0,0 +1,43 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import org.jeudego.pairgoth.web.WebappManager
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.util.*
object RatingsManager: Runnable {
enum class Ratings {
AGA,
EGF,
FFG
}
val ratingsHandlers by lazy {
mapOf(
Pair(Ratings.AGA, AGARatingsHandler),
Pair(Ratings.EGF, EGFRatingsHandler),
Pair(Ratings.FFG, FFGRatingsHandler)
);
}
val timer = Timer()
lateinit var players: Json.MutableArray
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()
}
}
}
val logger = LoggerFactory.getLogger("ratings")
val path = Path.of(WebappManager.getProperty("ratings.path") ?: "ratings").also {
val file = it.toFile()
if (!file.mkdirs() && !file.isDirectory) throw Error("Property pairgoth.ratings.path must be a directory")
}
}

View File

@@ -1,15 +1,13 @@
package org.jeudego.pairgoth.web package org.jeudego.pairgoth.web
import com.republicate.mailer.SmtpLoop
import org.apache.commons.lang3.tuple.Pair import org.apache.commons.lang3.tuple.Pair
import org.jeudego.pairgoth.ratings.RatingsManager
import org.jeudego.pairgoth.util.Translator import org.jeudego.pairgoth.util.Translator
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.IOException import java.io.IOException
import java.lang.IllegalAccessError
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.* import java.util.*
import java.util.IllegalFormatCodePointException
import javax.net.ssl.* import javax.net.ssl.*
import javax.servlet.* import javax.servlet.*
import javax.servlet.annotation.WebListener import javax.servlet.annotation.WebListener
@@ -53,6 +51,7 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
override fun contextInitialized(sce: ServletContextEvent) { override fun contextInitialized(sce: ServletContextEvent) {
context = sce.servletContext context = sce.servletContext
logger.info("---------- Starting $WEBAPP_NAME ----------") logger.info("---------- Starting $WEBAPP_NAME ----------")
logger.info("info level is active")
logger.debug("debug level is active") logger.debug("debug level is active")
logger.trace("trace level is active") logger.trace("trace level is active")
webappRoot = context.getRealPath("/") webappRoot = context.getRealPath("/")
@@ -78,6 +77,9 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
// fail to correctly implement SSL... // fail to correctly implement SSL...
disableSSLCertificateChecks() disableSSLCertificateChecks()
registerService("ratings", RatingsManager)
startService("ratings")
} catch (ioe: IOException) { } catch (ioe: IOException) {
logger.error("webapp initialization error", ioe) logger.error("webapp initialization error", ioe)
} }

View File

@@ -254,6 +254,7 @@
left: -50%; left: -50%;
border-radius: 10px; border-radius: 10px;
padding: 0.5em 1em; padding: 0.5em 1em;
z-index: 100;
} }
#success { #success {

View File

@@ -84,8 +84,8 @@ function exportCSV(filename, content) {
document.body.removeChild(link); document.body.removeChild(link);
} }
/* modals */ /* modals
NOT IN USE, see popup-related code.
NodeList.prototype.modal = function(show) { NodeList.prototype.modal = function(show) {
this.item(0).modal(show); this.item(0).modal(show);
return this; return this;
@@ -101,11 +101,12 @@ Element.prototype.modal = function(show) {
} }
return this; return this;
} }
*/
/* DOM helpers */ /* DOM helpers */
function formValue(name) { HTMLFormElement.prototype.val = function(name) {
let ctl = $(`[name="${name}"]`)[0]; let ctl = this.find(`[name="${name}"]`)[0];
if (!ctl) { if (!ctl) {
console.error(`unknown input name: ${name}`) console.error(`unknown input name: ${name}`)
} }
@@ -124,7 +125,7 @@ function formValue(name) {
} }
console.error(`unhandled input tag or type for input ${name} (tag: ${tag}, type:${type}`); console.error(`unhandled input tag or type for input ${name} (tag: ${tag}, type:${type}`);
return null; return null;
} };
function msg(id) { function msg(id) {
let ctl = $(`#${id}`)[0]; let ctl = $(`#${id}`)[0];

View File

@@ -16,12 +16,13 @@ onLoad(() => {
}); });
$('#validate').on('click', e => { $('#validate').on('click', e => {
let form = e.target.closest('form');
let valid = true; let valid = true;
// validate required fields // validate required fields
let required = ['name', 'shortName', 'startDate', 'endDate']; let required = ['name', 'shortName', 'startDate', 'endDate'];
if (!$('input[name="online"]')[0].checked) required.push('location') if (!form.find('input[name="online"]')[0].checked) required.push('location')
for (let name of required) { for (let name of required) {
let ctl = $(`input[name=${name}]`)[0]; let ctl = form.find(`input[name=${name}]`)[0];
let val = ctl.value; let val = ctl.value;
if (val) { if (val) {
ctl.setCustomValidity(''); ctl.setCustomValidity('');
@@ -32,7 +33,7 @@ onLoad(() => {
} }
if (!valid) return; if (!valid) return;
// validate short_name // validate short_name
let shortNameCtl = $('input[name="shortName"]')[0]; let shortNameCtl = form.find('input[name="shortName"]')[0];
let shortName = shortNameCtl.value; let shortName = shortNameCtl.value;
if (safeRegex.test(shortName)) { if (safeRegex.test(shortName)) {
shortNameCtl.setCustomValidity(''); shortNameCtl.setCustomValidity('');
@@ -40,7 +41,8 @@ onLoad(() => {
valid = false; valid = false;
shortNameCtl.setCustomValidity(msg('invalid_character')); shortNameCtl.setCustomValidity(msg('invalid_character'));
} }
if (!valid) return; // if (!valid) return;
// ...
}); });
for(let name of ['startDate', 'endDate']) { for(let name of ['startDate', 'endDate']) {
@@ -99,37 +101,38 @@ onLoad(() => {
$('#tournament-infos').on('submit', e => { $('#tournament-infos').on('submit', e => {
e.preventDefault(); e.preventDefault();
let form = e.target;
let tour = { let tour = {
name: formValue('name'), name: form.val('name'),
shortName: formValue('shortName'), shortName: form.val('shortName'),
startDate: parseDate(formValue('startDate')), startDate: parseDate(form.val('startDate')),
endDate: parseDate(formValue('endDate')), endDate: parseDate(form.val('endDate')),
type: formValue('type'), type: form.val('type'),
rounds: formValue('rounds'), rounds: form.val('rounds'),
country: formValue('country'), country: form.val('country'),
online: formValue('online'), online: form.val('online'),
location: formValue('online') ? "" : formValue('location'), location: form.val('online') ? "" : form.val('location'),
pairing: { pairing: {
type: formValue('pairing'), type: form.val('pairing'),
// mmFloor: formValue('mmFloor'), // mmFloor: form.val('mmFloor'),
mmBar: formValue('mmBar'), mmBar: form.val('mmBar'),
main: { main: {
firstSeed: formValue('firstSeed'), firstSeed: form.val('firstSeed'),
secondSeed: formValue('secondSeed') secondSeed: form.val('secondSeed')
}, },
handicap: { handicap: {
correction: formValue('correction'), correction: form.val('correction'),
treshold: formValue('treshold') treshold: form.val('treshold')
} }
}, },
timeSystem: { timeSystem: {
type: formValue('timeSystemType'), type: form.val('timeSystemType'),
mainTime: fromHMS(formValue('mainTime')), mainTime: fromHMS(form.val('mainTime')),
increment: fromHMS(formValue('increment')), increment: fromHMS(form.val('increment')),
maxTime: fromHMS(formValue('maxTime')), maxTime: fromHMS(form.val('maxTime')),
byoyomi: fromHMS(formValue('byoyomi')), byoyomi: fromHMS(form.val('byoyomi')),
periods: formValue('periods'), periods: form.val('periods'),
stones: formValue('stones') stones: form.val('stones')
} }
} }
console.log(tour); console.log(tour);

View File

@@ -5,5 +5,39 @@ onLoad(() => {
min: 0, min: 0,
max: 4000 max: 4000
}); });
//$('') $('#register').on('click', e => {
let form = e.target.closest('form');
let required = ['name', 'firstname', 'country', 'club', 'rank', 'rating'];
for (let name of required) {
let ctl = form.find(`[name=${name}]`)[0];
let val = ctl.value;
if (val) {
ctl.setCustomValidity('');
} else {
valid = false;
ctl.setCustomValidity(msg('required_field'));
}
}
if (!valid) return;
});
$('#player-form').on('submit', e => {
e.preventDefault();
let form = e.target;
let player = {
name: form.val('name'),
firstname: form.val('firstname'),
rating: form.val('rating'),
rank: form.val('rank'),
country: form.val('country'),
club: form.val('club')
}
console.log(player);
api.postJson(`tour/${tour_id}/part`, player)
.then(player => {
console.log(player)
if (player !== 'error') {
window.location.reload();
}
});
});
}); });

View File

@@ -14,8 +14,8 @@ $parts
</div> </div>
<div id="player" class="popup"> <div id="player" class="popup">
<div class="popup-body"> <div class="popup-body">
<div class="popup-content"> <form id="player-form" class="ui form edit">
<form id="player-form" class="ui form edit"> <div class="popup-content">
<div class="four stackable fields"> <div class="four stackable fields">
<div class="twelve wide field"> <div class="twelve wide field">
<div class="ui icon input"> <div class="ui icon input">
@@ -54,7 +54,7 @@ $parts
<div class="eight wide field"> <div class="eight wide field">
<label>Given name</label> <label>Given name</label>
<span class="info"></span> <span class="info"></span>
<input type="text" name="name" placeholder="first name"/> <input type="text" name="firstname" placeholder="first name"/>
</div> </div>
</div> </div>
<div class="two stackable fields"> <div class="two stackable fields">
@@ -72,7 +72,7 @@ $parts
<div class="eight wide field"> <div class="eight wide field">
<label>Club</label> <label>Club</label>
<span class="info"></span> <span class="info"></span>
<input type="text" name="name" placeholder="club"/> <input type="text" name="club" placeholder="club"/>
</div> </div>
</div> </div>
<div class="two stackable fields"> <div class="two stackable fields">
@@ -90,18 +90,18 @@ $parts
<input name="rating" type="text" class="numeric"/> <input name="rating" type="text" class="numeric"/>
</div> </div>
</div> </div>
</form> </div>
</div> <div class="popup-footer">
<div class="popup-footer"> <button class="ui gray right labeled icon floating close button">
<button class="ui gray right labeled icon floating close button"> <i class="times icon"></i>
<i class="times icon"></i> Cancel
Cancel </button>
</button> <button id="register" class="ui green right labeled icon floating button">
<button class="ui green right labeled icon floating add button"> <i class="plus icon"></i>
<i class="plus icon"></i> Register
Register </button>
</button> </div>
</div> </form>
</div> </div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">