border-right: none;
}
.tabulator .tabulator-header .tabulator-col .tabulator-col-content {
+ /* Lay out as a column that fills the header cell, so the header-filter can
+ be pushed to a shared bottom baseline. Column titles wrap to differing
+ numbers of lines, which otherwise leaves the filters misaligned. */
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
padding: 8px;
}
+/* Grow the title holder to fill the content cell so it pushes the
+ header-filter (its following sibling) down to a shared bottom baseline.
+ Column titles wrap to differing numbers of lines, which otherwise leaves
+ the filters misaligned. Growing the holder is used instead of an auto
+ margin on the filter: the cell's height comes from flex-grow, and auto
+ margins don't distribute reliably against a flex-determined size. */
+.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-title-holder {
+ flex-grow: 1;
+}
/* Keep the first glyph's left bearing clear of the title's overflow:hidden
(e.g. the "W" in "Worker type" was clipped on the left). */
.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-title {
.tabulator .tabulator-header .tabulator-col.tabulator-sortable .tabulator-col-content .tabulator-col-title {
padding-right: 15px;
}
+/* Drop the filter to the bottom of the (flex-column) content so every
+ column's filter shares one baseline, regardless of title wrap height. */
+.tabulator .tabulator-header-filter {
+ margin-top: auto;
+}
+
/* Header filter input — render as a Bootstrap form-control.
appearance:none drops the UA search-input chrome that ignored the
dark background (input looked too light in dark mode).
return value + " " + unit;
};
+ // ── Global (boolean) query filter ─────────────────────────────────
+ // A single search box per table driving a Tabulator setFilter(). The
+ // query language is ported from FooTable's built-in filtering:
+ // `foo bar` → foo AND bar (whitespace is AND)
+ // `foo OR bar` → foo OR bar
+ // `foo -bar` → foo AND NOT bar (leading "-" negates)
+ // `"soft reject"` → exact contiguous phrase
+ // `foo bar OR baz` → (foo AND bar) OR baz
+ // Matching is case-insensitive substring. Quotes are honoured when
+ // splitting on AND/OR, so a phrase may itself contain those words.
+ // The setFilter() predicate ANDs with the per-column header filters.
+ const SEARCH_FIELDS = ["id", "ip", "sender_mime", "rcpt_mime", "rcpt_mime_short",
+ "subject", "user", "passthrough_module", "file", "action"];
+ // Haystack cache keyed by row data object (see buildSearchHaystack).
+ const haystackCache = new WeakMap();
+
+ function decodeEntities(str) {
+ // Values are HTML-escaped upstream (preprocess_item); decode so a
+ // literal "&" or "<" typed in the box still matches. & last to
+ // avoid double-decoding entities like &lt;.
+ return str.replace(/</g, "<").replace(/>/g, ">")
+ .replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
+ }
+
+ // Compile a query string into a {test(haystack)} predicate, or null when
+ // the query is blank (meaning "no filter"). Tokenize keeping quoted
+ // phrases intact; "AND"/"OR" are operators; a leading "-" negates the
+ // following term or phrase.
+ function compileFilterQuery(input) {
+ if (!input || !input.trim()) return null;
+ const tokens = input.match(/"[^"]*"|\S+/g) || [];
+ const groups = [[]]; // OR of AND-groups
+ tokens.forEach((token) => {
+ if (token.toUpperCase() === "OR") {
+ groups.push([]);
+ return;
+ }
+ if (token.toUpperCase() === "AND") return;
+ let term = token;
+ const negate = term.charAt(0) === "-";
+ if (negate) term = term.slice(1);
+ const quoted = term.length >= 2 && term.charAt(0) === '"' && term.slice(-1) === '"';
+ if (quoted) term = term.slice(1, -1);
+ if (!term) return;
+ groups[groups.length - 1].push({q: term.toLowerCase(), negate});
+ });
+ // Drop groups left empty by dangling/leading/doubled operators
+ // (e.g. "foo OR", "OR foo", "foo OR OR bar") so they don't match all
+ // rows via [].every() vacuous truth.
+ const orGroups = groups.filter((g) => g.length);
+ if (!orGroups.length) return null;
+ return {
+ test(haystack) {
+ return orGroups.some((group) => group.every((t) => {
+ const found = haystack.indexOf(t.q) !== -1;
+ return t.negate ? !found : found;
+ }));
+ }
+ };
+ }
+
+ // Lowercased searchable text for a row. Numeric/formatted columns (time,
+ // time_real, size, score) are excluded — their raw values are useless for
+ // free-text search, and the per-column header filters cover them. Memoized
+ // in a WeakMap keyed by the data object so it is built once per row, not
+ // on every keystroke; entries are GC'd when the row data is replaced.
+ function buildSearchHaystack(data) {
+ if (haystackCache.has(data)) return haystackCache.get(data);
+ let s = SEARCH_FIELDS.map((f) => (typeof data[f] === "string" ? data[f] : "")).join(" ");
+ if (data.symbols_obj) {
+ s += " " + Object.values(data.symbols_obj)
+ .map((sym) => sym.name + " " + (sym.description || ""))
+ .join(" ");
+ }
+ s = decodeEntities(s).toLowerCase();
+ haystackCache.set(data, s);
+ return s;
+ }
+
+ // Apply the current filter box value to the table, or clear it. Reads the
+ // Tabulator instance lazily so it works across a destroy+re-init.
+ function applyGlobalFilter(table) {
+ const tab = common.tables[table];
+ if (!tab) return;
+ const input = document.getElementById("filter_" + table);
+ const compiled = compileFilterQuery(input ? input.value : null);
+ if (!compiled) {
+ tab.clearFilter();
+ return;
+ }
+ tab.setFilter((data) => compiled.test(buildSearchHaystack(data)));
+ }
+
+ // Bind the filter box: live, debounced. Bound once per table; the handler
+ // resolves the current instance at fire time, so it survives rebuilds.
+ function bindGlobalFilter(table) {
+ const input = document.getElementById("filter_" + table);
+ if (!input) return;
+ input.title = "Search syntax: match all rows containing\n\n" +
+ '"exact phrase" — exact string (including spaces)\n' +
+ "term1 OR term2 — either term\n" +
+ "term1 AND term2 — both terms\n" +
+ "term1 term2 — both terms (same as AND)\n" +
+ "term1 -term2 — term1 but exclude rows with term2";
+ let timer = null;
+ input.addEventListener("input", () => {
+ clearTimeout(timer);
+ timer = setTimeout(() => applyGlobalFilter(table), 250);
+ });
+ }
+
ui.columns_v2 = function (table) {
const cols = [{
// Toggle to expand collapsed (responsive) columns
}, {
title: "ID",
field: "id",
+ headerFilter: "input",
responsive: 0,
minWidth: 130,
widthGrow: 2,
}, {
title: "File name",
field: "file",
+ headerFilter: "input",
responsive: 1,
minWidth: 260,
widthGrow: 4,
}, {
title: "IP address",
field: "ip",
+ headerFilter: "input",
responsive: 3,
minWidth: 98,
width: 98,
}, {
title: "[Envelope From] From",
field: "sender_mime",
+ headerFilter: "input",
responsive: 3,
minWidth: 100,
maxWidth: 200,
}, {
title: "Subject",
field: "subject",
+ headerFilter: "input",
responsive: 3,
minWidth: 150,
widthGrow: 2,
}, {
title: '<div title="The module that has set the pre-result"><nobr>Pass-through</nobr> module</div>',
field: "passthrough_module",
+ headerFilter: "input",
minWidth: 98,
width: 98,
}, {
}, {
title: "Authenticated user",
field: "user",
+ headerFilter: "input",
responsive: 3,
minWidth: 100,
maxWidth: 130,
$("#selSymOrder_" + table).val(order);
change_symbols_order(order);
});
+
+ bindGlobalFilter(table);
};
ui.destroyTable = function (table) {
common.tables[table].on("tableBuilt", () => {
setActiveSymOrderButton(table);
+ // Re-apply a global filter in place when the table was rebuilt
+ // (e.g. via the column-options dropdown): a fresh Tabulator
+ // instance starts unfiltered, but the search box keeps its value.
+ const filterBox = document.getElementById("filter_" + table);
+ if (filterBox && filterBox.value.trim()) applyGlobalFilter(table);
});
if (postdrawCallback) common.tables[table].on("renderComplete", postdrawCallback);
// The "Sort by:" buttons are rendered inside each row's collapsed