Search and search switches are functional

This commit is contained in:
Claude Brisson
2023-12-17 19:42:16 +01:00
parent ea44f6068e
commit 98192a1ebc
13 changed files with 228 additions and 82 deletions

View File

@@ -25,6 +25,6 @@ object EGFRatingsHandler: RatingsHandler(RatingsManager.Ratings.EGF) {
} }
// 19574643 Abad Jahin FR 38GJ 20k -- 15 2 T200202B // 19574643 Abad Jahin FR 38GJ 20k -- 15 2 T200202B
var linePattern = 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*") Regex("\\s+(?<egf>\\d{8})\\s+(?<name>$atom+)\\s(?<firstname>$atom+)?,?\\s+(?<country>[A-Z]{2})\\s+(?<club>\\S{1,4})\\s+(?<rank>[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") val groups = arrayOf("egf", "name", "firstname", "country", "club", "rank", "rating")
} }

View File

@@ -22,6 +22,10 @@ object FFGRatingsHandler: RatingsHandler(RatingsManager.Ratings.FFG) {
}.toTypedArray() }.toTypedArray()
Json.MutableObject(*pairs).also { Json.MutableObject(*pairs).also {
it["origin"] = "FFG" it["origin"] = "FFG"
val rating = it["rating"]?.toString()?.toIntOrNull()
if (rating != null) {
it["rank"] = (rating/100).let { if (it < 0) "${-it}k" else "${it+1}d" }
}
} }
} }
} }

View File

@@ -31,8 +31,9 @@ class PlayerIndex {
val NAME = "name" val NAME = "name"
val FIRSTNAME = "firstname" val FIRSTNAME = "firstname"
val TEXT = "text" val TEXT = "text"
val COUNTRY = "country"
val MAX_HITS = 100 val MAX_HITS = 20
val logger = LoggerFactory.getLogger("index") val logger = LoggerFactory.getLogger("index")
val queryParser = ComplexPhraseQueryParser(TEXT, StandardAnalyzer()) val queryParser = ComplexPhraseQueryParser(TEXT, StandardAnalyzer())
} }
@@ -53,10 +54,11 @@ class PlayerIndex {
players.forEachIndexed { i, p -> players.forEachIndexed { i, p ->
val player = p as Json.Object val player = p as Json.Object
val origin = p.getString(ORIGIN) ?: throw Error("unknown origin") val origin = p.getString(ORIGIN) ?: throw Error("unknown origin")
val text = player.field(NAME) val text = player.field(NAME).lowercase(Locale.ROOT)
val doc = Document() val doc = Document()
doc.add(StoredField(ID, i)); doc.add(StoredField(ID, i));
doc.add(StringField(ORIGIN, player.field(ORIGIN), Field.Store.NO)) doc.add(StringField(ORIGIN, player.field(ORIGIN).lowercase(Locale.ROOT), Field.Store.NO))
doc.add(StringField(COUNTRY, player.field(COUNTRY).lowercase(Locale.ROOT), Field.Store.NO))
doc.add(TextField(TEXT, "${player.field(NAME)} ${player.nullableField(FIRSTNAME)}", Field.Store.NO)) doc.add(TextField(TEXT, "${player.field(NAME)} ${player.nullableField(FIRSTNAME)}", Field.Store.NO))
writer.addDocument(doc); writer.addDocument(doc);
++count ++count
@@ -65,12 +67,16 @@ class PlayerIndex {
logger.info("indexed $count players") logger.info("indexed $count players")
} }
fun match(needle: String, origins: Int): List<Int> { fun match(needle: String, origins: Int, country: String?): List<Int> {
// val fuzzy = FuzzyQuery(Term(TEXT, needle)) val terms = needle.lowercase(Locale.ROOT)
val terms = needle.split(Regex("[ -_']+")) .replace(Regex("([+&|!(){}\\[\\]^\\\\\"~*?:/]|(?<!\\b)-)"), "")
.split(Regex("[ -_']+"))
.filter { !it.isEmpty() } .filter { !it.isEmpty() }
.map { "$it~" } .map { "$it~" }
.joinToString(" ") .joinToString(" ")
.let { if (it.isEmpty()) it else "$it ${it.substring(0, it.length - 1) + "*^5"}" }
if (terms.isEmpty()) return emptyList()
logger.info("Search query: $terms")
val fuzzy = queryParser.parse(terms) val fuzzy = queryParser.parse(terms)
val activeMask = RatingsManager.activeMask() val activeMask = RatingsManager.activeMask()
val query = when (origins.countOneBits()) { val query = when (origins.countOneBits()) {
@@ -79,7 +85,7 @@ class PlayerIndex {
val filter = TermQuery(Term(ORIGIN, RatingsManager.Ratings.codeOf(origins))) val filter = TermQuery(Term(ORIGIN, RatingsManager.Ratings.codeOf(origins)))
BooleanQuery.Builder() BooleanQuery.Builder()
.add(fuzzy, BooleanClause.Occur.SHOULD) .add(fuzzy, BooleanClause.Occur.SHOULD)
.add(filter, BooleanClause.Occur.MUST) .add(filter, BooleanClause.Occur.FILTER)
.build() .build()
} }
2 -> { 2 -> {
@@ -94,6 +100,15 @@ class PlayerIndex {
} }
3 -> fuzzy 3 -> fuzzy
else -> throw Error("wrong origins mask") else -> throw Error("wrong origins mask")
}.let {
if (country == null) it
else {
val countryFilter = TermQuery(Term(COUNTRY, country.lowercase(Locale.ROOT)))
BooleanQuery.Builder()
.add(it, BooleanClause.Occur.SHOULD)
.add(countryFilter, BooleanClause.Occur.FILTER)
.build()
}
} }
val docs = searcher.search(query, MAX_HITS) val docs = searcher.search(query, MAX_HITS)
return docs.scoreDocs.map { searcher.doc(it.doc).getField(ID).numericValue().toInt() }.toList() return docs.scoreDocs.map { searcher.doc(it.doc).getField(ID).numericValue().toInt() }.toList()

View File

@@ -69,14 +69,14 @@ object RatingsManager: Runnable {
if (!file.mkdirs() && !file.isDirectory) throw Error("Property pairgoth.ratings.path must be a directory") 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 { fun search(needle: String, aga: Boolean, egf: Boolean, ffg: Boolean, country: String?): Json.Array {
try { try {
updateLock.readLock().lock() updateLock.readLock().lock()
var mask = 0 var mask = 0
if (aga && ratingsHandlers[Ratings.AGA]!!.active) mask = mask or Ratings.AGA.flag 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 (egf && ratingsHandlers[Ratings.EGF]!!.active) mask = mask or Ratings.EGF.flag
if (ffg && ratingsHandlers[Ratings.FFG]!!.active) mask = mask or Ratings.FFG.flag if (ffg && ratingsHandlers[Ratings.FFG]!!.active) mask = mask or Ratings.FFG.flag
val matches = index.match(needle, mask) val matches = index.match(needle, mask, country)
return matches.map { it -> players[it] }.toCollection(Json.MutableArray()) return matches.map { it -> players[it] }.toCollection(Json.MutableArray())
} finally { } finally {
updateLock.readLock().unlock() updateLock.readLock().unlock()

View File

@@ -23,10 +23,11 @@ class SearchServlet: HttpServlet() {
validateContentType(request) validateContentType(request)
val query = request.getAttribute(PAYLOAD_KEY) as Json.Object? ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no payload") 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 needle = query.getString("needle") ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no needle")
val country = query.getString("countryFilter")
val aga = query.getBoolean("aga") ?: false val aga = query.getBoolean("aga") ?: false
val egf = query.getBoolean("egf") ?: false val egf = query.getBoolean("egf") ?: false
val ffg = query.getBoolean("ffg") ?: false val ffg = query.getBoolean("ffg") ?: false
payload = RatingsManager.search(needle, aga, egf, ffg) payload = RatingsManager.search(needle, aga, egf, ffg, country)
setContentType(response) setContentType(response)
payload.toString(response.writer) payload.toString(response.writer)
} catch (ioe: IOException) { } catch (ioe: IOException) {

View File

@@ -339,32 +339,8 @@
} }
} }
.checkbox { .clickable {
width: 50px; pointer-events: all;
height: 26px;
border-radius: 18px;
background-color: #F7D6A3;
display: flex;
align-items: center;
padding-left: 5px;
padding-right: 5px;
cursor: pointer; cursor: pointer;
.circle {
background-color: #6B5E8A;
transform: translateX(0px);
border-radius: 50%;
width: 20px;
height: 20px;
transition: 300ms;
}
input {
display: none;
}
&.active {
background-color: rgb(218, 114, 80);
.circle {
transform: translateX(20px);
}
}
} }
} }

View File

@@ -48,6 +48,16 @@
} }
/* registration section */ /* registration section */
#player {
&.create .edition {
display: none;
}
&.edit .creation {
display: none;
}
}
#player-form { #player-form {
&:not(.add) { &:not(.add) {
#search-form, #search-result { #search-form, #search-result {
@@ -57,6 +67,42 @@
} }
#search-form { #search-form {
position: relative; position: relative;
.toggle {
cursor: pointer;
input {
display: none;
}
label {
display: block;
text-align: center;
cursor: pointer;
}
.checkbox {
width: 50px;
height: 26px;
border-radius: 18px;
background-color: #F7D6A3;
display: flex;
align-items: center;
padding-left: 5px;
padding-right: 5px;
cursor: pointer;
.circle {
background-color: #6B5E8A;
transform: translateX(0px);
border-radius: 50%;
width: 20px;
height: 20px;
transition: 300ms;
}
}
input:checked + .checkbox {
background-color: rgb(218, 114, 80);
.circle {
transform: translateX(20px);
}
}
}
} }
#search-result { #search-result {
position: absolute; position: absolute;
@@ -66,9 +112,14 @@
top: 100%; top: 100%;
padding: 1em; padding: 1em;
overflow-y: auto; overflow-y: auto;
&.hidden { &:empty {
display: none; display: none;
} }
.result-line {
cursor: pointer;
&:hover {
background-color: rgba(100,200,255,200);
}
}
} }
} }

View File

@@ -92,14 +92,13 @@
$('#lang-list').removeClass('shown'); $('#lang-list').removeClass('shown');
} }
}); });
$('.popup .close').on('click', e => { $('.popup .popup-footer .close').on('click', e => {
let popup = e.target.closest('.popup'); let popup = e.target.closest('.popup');
if (popup) { if (popup) {
popup.classList.remove('shown'); popup.classList.remove('shown');
$('body').removeClass('dimmed'); $('body').removeClass('dimmed');
} }
}); });
}); });
// syntaxic sugar for IMask // syntaxic sugar for IMask

View File

@@ -113,6 +113,32 @@ NodeList.prototype.find = function(selector) {
}); });
return Reflect.construct(Array, result, NodeList); return Reflect.construct(Array, result, NodeList);
} }
Element.prototype.find = function (selector) { Element.prototype.find = function(selector) {
return this.querySelectorAll(':scope ' + selector); return this.querySelectorAll(':scope ' + selector);
} }
NodeList.prototype.clear = function() {
this.forEach(function (elem, i) {
elem.clear();
});
return this;
}
Element.prototype.clear = function() {
this.innerHTML = '';
return this;
}
/*
TODO - conflicts with from.val(), rename one of the two
NodeList.prototype.val = function(value) {
this.item(0).val(value);
}
Element.prototype.val = function(value) {
// TODO - check that "this" has the "value" property
if (typeof(value) === 'undefined') {
return this.value;
} else {
this.value = value;
}
}
*/

View File

@@ -137,7 +137,7 @@ HTMLFormElement.prototype.val = function(name, value) {
ctl.checked = value !== 'false' && Boolean(value); ctl.checked = value !== 'false' && Boolean(value);
return; return;
} }
else return ctl.checked; else return ctl.checked && ctl.value ? ctl.value : ctl.checked;
} }
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;
@@ -171,12 +171,6 @@ onLoad(() => {
$('body').removeClass('dimmed'); $('body').removeClass('dimmed');
} }
}); });
$('.checkbox').on('click', e => {
let chk = e.target.closest('.checkbox');
chk.toggleClass('active');
let checkbox = chk.find('input')[0];
checkbox.checked = !checkbox.checked;
});
/* commented for now - do we want this? /* commented for now - do we want this?
$('#dimmer').on('click', e => $('.popup').removeClass('shown'); $('#dimmer').on('click', e => $('.popup').removeClass('shown');
*/ */

View File

@@ -1,3 +1,50 @@
const SEARCH_DELAY = 100;
let searchTimer = undefined;
let resultTemplate;
function initSearch() {
let needle = $('#needle')[0].value;
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
search(needle);
}, SEARCH_DELAY);
}
function search(needle) {
needle = needle.trim();
console.log(needle)
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'),
}
let country = form.val('countryFilter');
if (country) search.countryFilter = country;
let searchFormState = {
countryFilter: country ? true : false,
aga: search.aga,
egf: search.egf,
ffg: search.ffg
};
store('searchFormState', searchFormState);
console.log(search)
api.postJson('search', search)
.then(result => {
if (Array.isArray(result)) {
console.log(result)
let html = resultTemplate.render(result);
$('#search-result')[0].innerHTML = html;
} else console.log(result);
})
} else $('#search-result').clear();
}
onLoad(() => { onLoad(() => {
$('input.numeric').imask({ $('input.numeric').imask({
mask: Number, mask: Number,
@@ -11,6 +58,7 @@ onLoad(() => {
form.addClass('add'); form.addClass('add');
// $('#player-form input.participation').forEach(chk => chk.checked = true); // $('#player-form input.participation').forEach(chk => chk.checked = true);
form.reset(); form.reset();
$('#player').removeClass('edit').addClass('create');
modal('player'); modal('player');
}); });
$('#cancel-register').on('click', e => { $('#cancel-register').on('click', e => {
@@ -85,24 +133,29 @@ onLoad(() => {
form.val(`r${r}`, !(player.skip && player.skip.includes(r))); form.val(`r${r}`, !(player.skip && player.skip.includes(r)));
} }
form.removeClass('add'); form.removeClass('add');
$('#player').removeClass('create').addClass('edit');
modal('player'); modal('player');
} }
}); });
}); });
resultTemplate = jsrender.templates($('#result')[0]);
$('#needle').on('input', e => { $('#needle').on('input', e => {
let needle = $('#needle')[0].value; initSearch();
if (needle && needle.length > 2) { });
let form = $('#player-form')[0]; $('#clear-search').on('click', e => {
let search = { $('#needle')[0].value = '';
needle: needle, $('#search-result').clear();
aga: form.val('aga'), });
egf: form.val('egf'), let searchFromState = store('searchFormState')
ffg: form.val('ffg') if (searchFromState) {
} for (let id of ["countryFilter", "aga", "egf", "ffg"]) {
api.postJson('search', search) $(`#${id}`)[0].checked = searchFromState[id];
.then(result => { }
console.log(result); }
}) $('.toggle').on('click', e => {
} else $('#search-result').addClass('hidden'); let chk = e.target.closest('.toggle');
let checkbox = chk.find('input')[0];
checkbox.checked = !checkbox.checked;
initSearch();
}); });
}); });

File diff suppressed because one or more lines are too long

View File

@@ -49,35 +49,50 @@
<form id="player-form" class="ui form edit"> <form id="player-form" class="ui form edit">
<input type="hidden" name="id"/> <input type="hidden" name="id"/>
<div class="popup-content"> <div class="popup-content">
<div id="search-form" class="four stackable fields"> <div id="search-form" class="five stackable fields">
<div class="twelve wide field"> <div class="two wide field">
<div class="toggle">
<input id="countryFilter" name="countryFilter" type="checkbox" value="$tour.country"/>
<div class="search-param checkbox">
<div class="circle"></div>
</div>
<label>$tour.country.toUpperCase()</label>
</div>
</div>
<div class="ten wide field">
<div class="ui icon input"> <div class="ui icon input">
<input id="needle" name="needle" type="text" placeholder="Search..."> <input id="needle" name="needle" type="text" placeholder="Search...">
<i class="search icon"></i> <i id="clear-search" class="clickable close icon"></i>
</div> </div>
</div> </div>
<div class="two wide field"> <div class="two wide field">
<div class="active checkbox"> <div class="toggle">
<div class="circle"></div> <input id="aga" name="aga" type="checkbox" value="true"/>
<input name="aga" type="checkbox" class="hidden" checked/> <div class="search-param checkbox">
<div class="circle"></div>
</div>
<label>AGA</label>
</div> </div>
AGA
</div> </div>
<div class="two wide field"> <div class="two wide field">
<div class="active checkbox"> <div class="toggle">
<div class="circle"></div> <input id="egf" name="egf" type="checkbox" checked value="true"/>
<input name="egf" type="checkbox" class="hidden" checked/> <div class="search-param checkbox">
<div class="circle"></div>
</div>
<label>EGF</label>
</div> </div>
EGF
</div> </div>
<div class="two wide field"> <div class="two wide field">
<div class="active checkbox"> <div class="toggle">
<div class="circle"></div> <input id="ffg" name="ffg" type="checkbox" checked value="true"/>
<input name="ffg" type="checkbox" class="hidden" checked/> <div class="search-param checkbox">
<div class="circle"></div>
</div>
<label>FFG</label>
</div> </div>
FFG
</div> </div>
<div id="search-result" class="hidden">hophop</div> <div id="search-result"></div>
</div> </div>
<div class="two stackable fields"> <div class="two stackable fields">
<div class="eight wide field"> <div class="eight wide field">
@@ -137,13 +152,21 @@
<div class="popup-footer"> <div class="popup-footer">
<button id="cancel-register" class="ui gray right labeled icon floating close button"> <button id="cancel-register" class="ui gray right labeled icon floating close button">
<i class="times icon"></i> <i class="times icon"></i>
Cancel <span class="edition">Close</span>
<span class="creation">Cancel</span>
</button> </button>
<button id="register" class="ui green right labeled icon floating button"> <button id="register" class="ui green right labeled icon floating button">
<i class="plus icon"></i> <i class="plus icon"></i>
Register <span class="edition">Update</span>
<span class="creation">Register</span>
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<script id="result" type="text/template">
{{for #data}}
<div class="result-line">[{{:origin}}] {{:country}} {{:name}} {{:firstname}} {{:rank}} ({{:club}})</div>
{{/for}}
</script>
<script type="text/javascript" src="/lib/jsrender-1.0.13/jsrender.min.js"></script>