diff --git a/app/static/css/form.css b/app/static/css/form.css index f7e5965..fa11e42 100644 --- a/app/static/css/form.css +++ b/app/static/css/form.css @@ -122,3 +122,37 @@ font-family: monospace; height: 192px; } + +/* Interactive filter forms */ + +.form.filter > p:first-child { + font-size: 80%; + color: gray; + margin-bottom: 2px; +} +.form.filter { + margin-bottom: 16px; +} +.form.filter input { + font-family: monospace; +} + +.form.filter .syntax-explanation { + font-size: 80%; + color: gray; + margin-top: 8px; +} +.form.filter .syntax-explanation ul { + font-size: inherit; + color: inherit; + padding-left: 16px; + line-height: 20px; + margin-top: 2px; +} +.form.filter .syntax-explanation li { +} +.form.filter .syntax-explanation code { + background: rgba(0,0,0,.05); + padding: 1px 2px; + border-radius: 2px; +} diff --git a/app/static/css/table.css b/app/static/css/table.css index 7b99124..2cd3d76 100644 --- a/app/static/css/table.css +++ b/app/static/css/table.css @@ -109,3 +109,13 @@ table.thread div.info { width: 136px; } } + +/* Tables with filters */ + +table.filter-target th:after { + content: attr(data-filter); + display: block; + font-size: 80%; + font-family: monospace; + font-weight: normal; +} diff --git a/app/static/scripts/filter.js b/app/static/scripts/filter.js new file mode 100644 index 0000000..6d5f9d3 --- /dev/null +++ b/app/static/scripts/filter.js @@ -0,0 +1,143 @@ +/* Tokens and patterns; keep arrays in order of token numbers */ +const T = { + END:-2, ERR:-1, NAME:0, COMP:1, UNARY:2, AND:3, OR:4, LPAR:5, RPAR:6, STR:7 +} +const patterns = [ + /[a-z]+/, /=|!=|~=|!~=/, /!/, /&&/, /\|\|/, /\(/, /\)/, /"([^"]*)"/ +] + +function* lex(str) { + while(str = str.trim()) { + var t = T.ERR, best = undefined; + + for(const i in patterns) { + const m = str.match(patterns[i]); + if(m === null || m.index != 0 || (best && m[0].length < best[0].length)) continue; + t = i; + best = m; + } + + if(t == T.ERR) throw "LEXING ERROR"; + yield [t, best[best.length-1]]; + str = str.slice(best[0].length); + } + /* Finish with a continuous stream of T.END */ + while(true) yield [T.END, undefined]; +} + +class Parser { + constructor(lex) { + this.lex = lex; + [this.la, this.value] = lex.next().value; + } + + /* Expect a token of type t, returns the value */ + expect(t) { + const v = this.value; + if(this.la != t) throw (`SYNTAX ERROR, EXPECTED ${t}, GOT ${this.la} ${this.value}`); + [this.la, this.value] = this.lex.next().value; + return v; + } + + /* filter -> END | expr END */ + filter() { + if(this.la == T.END) return true; + const e = this.expr(); + this.expect(T.END); + return e; + } + /* expr -> term_and | term_and OR expr */ + expr() { + const left = this.term_and(); + if(this.la != T.OR) return left; + this.expect(T.OR); + return { + type: "Or", + left: left, + right: this.expr(), + }; + } + /* term_and -> unary | unary AND term_and */ + term_and() { + const left = this.unary(); + if(this.la != T.AND) return left; + this.expect(T.AND); + return { + type: "And", + left: left, + right: this.term_and(), + }; + } + /* unary -> UNARY* atom */ + unary() { + if(this.la == T.UNARY) return { + type: "Unary", + op: this.expect(T.UNARY), + val: this.unary(), + }; + return this.atom(); + } + /* atom -> NAME COMP STR | LPAR expr RPAR */ + atom() { + if(this.la == T.LPAR) { + this.expect(T.LPAR); + const e = this.expr(); + this.expect(T.RPAR); + return e; + } + + var e = { + type: "Atom", + field: this.expect(T.NAME), + op: this.expect(T.COMP), + }; + const str = this.expect(T.STR); + + /* Precompile regular expressions */ + if(e.op == "~=" || e.op == "!~=") + e.regex = new RegExp(str, "i"); + else + e.str = str; + return e; + } +} + +function ev(e, row, fields) { + switch(e.type) { + case "Atom": + const val = row.children[fields[e.field]].textContent.trim(); + if(e.op == "=") return val == e.str; + if(e.op == "!=") return val != e.str; + if(e.op == "~=") return e.regex.test(val); + if(e.op == "!~=") return !e.regex.test(val); + case "Unary": + if(e.op == "!") return !ev(e.val, row, fields); + case "And": + return ev(e.left, row, fields) && ev(e.right, row, fields); + case "Or": + return ev(e.left, row, fields) || ev(e.right, row, fields); + } +} + +function filter_update(input) { + const t = document.querySelector(input.parentNode.dataset.target); + const th = t.querySelectorAll("tr:first-child > th"); + + /* Generate the names of fields from the header */ + var fields = {}; + for(var i = 0; i < th.length; i++) { + const name = th[i].dataset.filter; + if(name) fields[name] = i; + } + + /* Parse the filter as an expression */ + const parser = new Parser(lex(input.value)); + const expr = parser.filter(); + + /* Evaluate the expression on each row of the table */ + const rows = t.querySelectorAll("tr:not(:first-child)"); + for(const row of rows) { + const ok = (expr === true) || ev(expr, row, fields); + row.style.display = ok ? "table-row" : "none"; + } +} diff --git a/app/templates/admin/members.html b/app/templates/admin/members.html index bf6ee55..e8d008f 100644 --- a/app/templates/admin/members.html +++ b/app/templates/admin/members.html @@ -6,13 +6,31 @@ {% block content %}
-

Listes des membres inscrits

+

Listes des membres

- {# TODO: Barre de recherche interactive #} +
+

Filtrer les entrées :

+ +
Syntaxe : +
  • Comparaisons avec = ou != : name="DarkStorm"
  • +
  • Comparaison regex avec ~= ou !~= (insensible à la casse) : name~="^dark"
  • +
  • Combiner avec !, &&, || et parenthèses : (name~="^dark" || name="Lephenixnoir") && (groups~="administrateur")
  • +
+
+ +
- - - +
PseudoEmailInscrit leGroupesPrivilèges spéciauxModifier
+ + + + + + + + {% for user in users %} diff --git a/app/utils/render.py b/app/utils/render.py index f61b25a..21c76d8 100644 --- a/app/utils/render.py +++ b/app/utils/render.py @@ -29,7 +29,8 @@ def render(*args, styles=[], scripts=[], **kwargs): 'scripts/trigger_menu.js', 'scripts/pc-utils.js', 'scripts/smartphone_patch.js', - 'scripts/simplemde.min.js' + 'scripts/simplemde.min.js', + 'scripts/filter.js' ] for s in styles:
PseudoEmailInscrit leGroupesPrivilèges spéciauxModifier
{{ user.name }}