Files
Assetmanager/app/static/js/table-tools.js
T

1437 lines
56 KiB
JavaScript
Executable File

(() => {
const uiState = window.AssetBrowserState;
const stateGet = key => {
if (uiState) return uiState.get(key);
try { return window.localStorage.getItem(`assetmanager:${key}`); } catch (_) { return null; }
};
const stateSet = (key, value) => {
if (uiState) { uiState.set(key, value); return; }
try { window.localStorage.setItem(`assetmanager:${key}`, String(value)); } catch (_) {}
};
function normalizeSearchText(value, options = {}) {
const caseSensitive = Boolean(options.caseSensitive);
const collapseWhitespace = Boolean(options.collapseWhitespace);
let text = String(value ?? '');
if (collapseWhitespace) text = text.trim().replace(/\s+/g, ' ');
if (!caseSensitive) text = text.toLocaleLowerCase();
const protectedGerman = text
.replace(/ä/g, '\uE000')
.replace(/ö/g, '\uE001')
.replace(/ü/g, '\uE002')
.replace(/ß/g, '\uE003');
return protectedGerman
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\uE000/g, 'ä')
.replace(/\uE001/g, 'ö')
.replace(/\uE002/g, 'ü')
.replace(/\uE003/g, 'ß');
}
function germanSearchKey(value, options = {}) {
let text = normalizeSearchText(value, options);
// Mark only plausible ASCII umlaut spellings. The marker keeps a real or
// transliterated umlaut distinct from natural letter pairs in words such
// as "Bauer", "teuer" and "euer".
text = text.replace(/(^|[^aeiouäöü])([aou])e/g, '$1$2~e');
return text
.replace(/ä/g, 'a~e')
.replace(/ö/g, 'o~e')
.replace(/ü/g, 'u~e')
.replace(/ß/g, 's~s');
}
function searchTextMatches(value, term, options = {}) {
const directValue = normalizeSearchText(value, options);
const directTerm = normalizeSearchText(term, options);
if (!directTerm) return true;
if (directValue.includes(directTerm)) return true;
return germanSearchKey(value, options).includes(germanSearchKey(term, options));
}
// Standalone filters and duplicate detection use the exact same comparison
// rules as the generic table filters.
window.AssetManagerNormalizeSearchText = normalizeSearchText;
window.AssetManagerGermanSearchKey = germanSearchKey;
window.AssetManagerSearchTextMatches = searchTextMatches;
const collator = new Intl.Collator(document.documentElement.lang || 'de', {
numeric: true,
sensitivity: 'base'
});
function controlValue(control) {
if (control.tagName === 'SELECT') {
const selected = control.options?.[control.selectedIndex];
return [
control.value || '',
selected?.textContent || ''
].join(' ');
}
if (control.type === 'checkbox' || control.type === 'radio') {
return control.checked
? [
control.value || '',
control.getAttribute('aria-label') || '',
control.closest('label')?.textContent || ''
].join(' ')
: '';
}
return [
control.value || '',
control.getAttribute('placeholder') || '',
control.getAttribute('aria-label') || ''
].join(' ');
}
function textOutsideControls(cell) {
const clone = cell.cloneNode(true);
clone.querySelectorAll('input, select, textarea, option').forEach(node => node.remove());
return (clone.textContent || '').trim();
}
function textValue(cell) {
if (!cell) return '';
const explicitFilterValue = cell.dataset.filterValue || '';
if (cell.dataset.sortValue !== undefined) {
return `${cell.dataset.sortValue} ${explicitFilterValue}`.trim();
}
const controls = [...cell.querySelectorAll('input, select, textarea')];
if (controls.length) {
const controlText = controls.map(controlValue).join(' ').trim();
const additionalText = textOutsideControls(cell);
return `${explicitFilterValue} ${controlText} ${additionalText}`.trim();
}
return `${explicitFilterValue} ${cell.textContent || ''}`.trim();
}
function parsedValue(value) {
const text = value.trim();
if (!text) return { type: 'empty', value: '' };
if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) {
const timestamp = Date.parse(text.replace(' ', 'T'));
if (!Number.isNaN(timestamp)) return { type: 'date', value: timestamp };
}
if (/^\d{1,2}\.\d{1,2}\.\d{4}$/.test(text)) {
const [day, month, year] = text.split('.').map(Number);
return { type: 'date', value: new Date(year, month - 1, day).getTime() };
}
if (/^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(text)) {
return { type: 'ipv4', value: text.split('.').map(Number) };
}
const numeric = text.replace(/\s/g, '').replace(',', '.');
if (/^[-+]?\d+(?:\.\d+)?$/.test(numeric)) {
return { type: 'number', value: Number(numeric) };
}
return { type: 'text', value: text };
}
function compareValues(a, b) {
const left = parsedValue(a);
const right = parsedValue(b);
if (left.type === 'empty' && right.type !== 'empty') return 1;
if (right.type === 'empty' && left.type !== 'empty') return -1;
if (left.type === right.type && (left.type === 'number' || left.type === 'date')) {
return left.value - right.value;
}
if (left.type === 'ipv4' && right.type === 'ipv4') {
for (let index = 0; index < 4; index += 1) {
const difference = left.value[index] - right.value[index];
if (difference) return difference;
}
return 0;
}
return collator.compare(String(left.value), String(right.value));
}
function ensureStandardTableShell(table) {
table.classList.add('standard-data-table');
const shellSelector = '.standard-data-table-scroll, .table-scroll, .table-wrap, .sticky-table, .management-table-scroll';
const directParent = table.parentElement;
let shell = directParent?.matches(shellSelector) ? directParent : null;
if (!shell) {
const parent = table.parentNode;
if (!parent) return null;
shell = document.createElement('div');
shell.className = 'table-scroll standard-data-table-scroll';
parent.insertBefore(shell, table);
shell.appendChild(table);
} else {
shell.classList.add('standard-data-table-scroll');
}
return shell;
}
function ensureStableTableStorageKey(table) {
if (table.dataset.storageKey) return table.dataset.storageKey;
if (table.id) {
table.dataset.storageKey = table.id;
return table.id;
}
const tables = [...document.querySelectorAll('main table.data-table')];
const index = Math.max(0, tables.indexOf(table));
const key = `${window.location.pathname}:data-table-${index}`;
table.dataset.storageKey = key;
return key;
}
function initTable(table) {
const thead = table.tHead;
const tbody = table.tBodies[0];
if (!thead || !tbody || !thead.rows.length) return;
const tableShell = ensureStandardTableShell(table);
ensureStableTableStorageKey(table);
if (table.dataset.tableToolsReady === '1') return;
const headerRow = thead.rows[0];
// Sticky title/filter rows and resizable columns must coexist. Sticky is
// applied to the TH cells in CSS; this custom property supplies the exact
// offset for the filter row from the rendered title-row height.
function updateStickyHeaderMetrics() {
const measured = headerRow.getBoundingClientRect().height || headerRow.offsetHeight || 0;
if (measured > 0) {
table.style.setProperty('--table-title-row-height', `${Math.ceil(measured)}px`);
}
}
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(() => updateStickyHeaderMetrics());
observer.observe(headerRow);
table._stickyHeaderResizeObserver = observer;
}
window.addEventListener('resize', updateStickyHeaderMetrics);
// Add a purely visual row-number column before every real table column.
// It is recalculated after filtering, sorting and partial server reloads and
// therefore never represents a database identifier.
if (!headerRow.querySelector('th[data-row-number-column="1"]')) {
const numberHeader = document.createElement('th');
numberHeader.className = 'table-row-number-header';
numberHeader.dataset.rowNumberColumn = '1';
numberHeader.dataset.noSort = '1';
numberHeader.dataset.noFilter = '1';
numberHeader.textContent = '#';
numberHeader.title = document.body.dataset.rowNumberLabel || 'Row number';
headerRow.insertBefore(numberHeader, headerRow.firstChild);
}
const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]');
const rowNumberStoragePart = ensureStableTableStorageKey(table);
const rowNumberWidthKey = `table:${rowNumberStoragePart}:row-number-width`;
const defaultRowNumberWidth = 64;
const minimumRowNumberWidth = 48;
const maximumRowNumberWidth = 180;
function readStoredRowNumberWidth() {
try {
const stored = Number.parseInt(stateGet(rowNumberWidthKey) || '', 10);
if (Number.isFinite(stored)) {
return Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, stored));
}
} catch (_) {}
return defaultRowNumberWidth;
}
function applyRowNumberWidth(width) {
const normalized = Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, Math.round(width)));
table.style.setProperty('--table-row-number-width', `${normalized}px`);
table.dataset.rowNumberWidth = String(normalized);
return normalized;
}
function installRowNumberResizer() {
if (!numberHeader || numberHeader.querySelector('.table-row-number-resizer')) return;
const handle = document.createElement('span');
handle.className = 'table-row-number-resizer';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.tabIndex = 0;
handle.title = document.body.dataset.rowNumberResizeLabel || 'Drag to change column width';
numberHeader.appendChild(handle);
const saveWidth = width => {
const normalized = applyRowNumberWidth(width);
try { stateSet(rowNumberWidthKey, String(normalized)); } catch (_) {}
};
handle.addEventListener('pointerdown', event => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth;
handle.setPointerCapture?.(event.pointerId);
document.documentElement.classList.add('table-column-resizing');
const move = moveEvent => applyRowNumberWidth(startWidth + moveEvent.clientX - startX);
const finish = finishEvent => {
saveWidth(Number.parseInt(table.dataset.rowNumberWidth || '', 10) || startWidth);
handle.releasePointerCapture?.(finishEvent.pointerId);
document.documentElement.classList.remove('table-column-resizing');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', finish);
handle.removeEventListener('pointercancel', finish);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
});
handle.addEventListener('keydown', event => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const current = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth;
saveWidth(current + (event.key === 'ArrowRight' ? 8 : -8));
});
}
applyRowNumberWidth(readStoredRowNumberWidth());
installRowNumberResizer();
// Every visible data column can be resized independently. Widths are
// stored per table and per field/index so they survive navigation,
// reloads and container updates. The visual row-number column keeps its
// dedicated implementation above.
const columnWidthStoragePrefix = `table:${rowNumberStoragePart}:column-width:`;
const minimumColumnWidth = 56;
// No arbitrary upper limit: widening a column grows the whole table.
function columnStorageId(header, columnIndex) {
const field = header?.dataset?.field;
if (field) return field;
return `index-${columnIndex}`;
}
function normalizeColumnWidth(width) {
return Math.max(minimumColumnWidth, Math.round(width));
}
function applyColumnWidth(columnIndex, width) {
const normalized = normalizeColumnWidth(width);
const rows = [
...thead.rows,
...tbody.rows
];
rows.forEach(row => {
const cell = row.cells?.[columnIndex];
if (!cell || cell.dataset.rowNumberColumn === '1') return;
cell.style.width = `${normalized}px`;
cell.style.minWidth = `${normalized}px`;
cell.style.maxWidth = `${normalized}px`;
});
return normalized;
}
function readStoredColumnWidth(header, columnIndex) {
try {
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`;
const stored = Number.parseInt(stateGet(key) || '', 10);
if (Number.isFinite(stored)) return normalizeColumnWidth(stored);
} catch (_) {}
return null;
}
function saveColumnWidth(header, columnIndex, width) {
const normalized = applyColumnWidth(columnIndex, width);
if (header?.dataset?.field) header.dataset.columnWidth = String(normalized);
try {
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`;
stateSet(key, String(normalized));
} catch (_) {}
return normalized;
}
function freezeTableGeometry() {
const widths = [...headerRow.cells].map(cell => {
const measured = Math.round(cell.getBoundingClientRect().width || 0);
return measured > 0 ? Math.max(minimumColumnWidth, measured) : 0;
});
const total = widths.reduce((sum, width) => sum + width, 0);
widths.forEach((width, columnIndex) => {
if (!width || headerRow.cells[columnIndex]?.dataset.rowNumberColumn === '1') return;
applyColumnWidth(columnIndex, width);
});
table.style.tableLayout = 'fixed';
table.style.width = `${total}px`;
table.style.minWidth = `${total}px`;
}
function setTableWidth(total) {
const normalized = Math.max(1, Math.round(total || 0));
table.style.width = `${normalized}px`;
table.style.minWidth = `${normalized}px`;
if (table.id === 'asset-table') {
table.dataset.calculatedWidth = String(normalized);
window.dispatchEvent(new Event('asset-table-columns-changed'));
}
}
function currentTableWidth() {
return [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0);
}
function synchronizeTableWidth() {
const total = currentTableWidth();
if (total > 0) setTableWidth(total);
}
function applyStoredColumnWidths() {
let restored = false;
[...headerRow.cells].forEach((header, columnIndex) => {
if (header.dataset.rowNumberColumn === '1') return;
const stored = readStoredColumnWidth(header, columnIndex);
if (stored !== null) {
applyColumnWidth(columnIndex, stored);
restored = true;
}
});
if (restored && table.id !== 'asset-table') {
table.style.tableLayout = 'fixed';
requestAnimationFrame(synchronizeTableWidth);
}
}
function installColumnResizers() {
[...headerRow.cells].forEach((header, columnIndex) => {
if (header.dataset.rowNumberColumn === '1') return;
if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return;
if (header.querySelector(':scope > .table-column-resizer')) return;
header.classList.add('table-resizable-column-header');
const handle = document.createElement('span');
handle.className = 'table-column-resizer';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.tabIndex = 0;
handle.title = document.body.dataset.columnResizeLabel || 'Drag to change the column width';
header.appendChild(handle);
handle.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
});
handle.addEventListener('pointerdown', event => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
freezeTableGeometry();
// A header can be moved after the resizer was installed. Always use
// its current DOM position instead of the index captured at startup.
const currentColumnIndex = header.cellIndex;
const startWidth = header.getBoundingClientRect().width || minimumColumnWidth;
const startTableWidth = currentTableWidth();
document.documentElement.classList.add('table-column-resizing');
// Resize only the dragged column. The table width follows the actual
// column delta exactly; all other columns keep their current widths.
const resizeColumn = delta => {
const newWidth = applyColumnWidth(currentColumnIndex, startWidth + delta);
setTableWidth(startTableWidth + (newWidth - startWidth));
};
const move = moveEvent => resizeColumn(moveEvent.clientX - startX);
const finish = () => {
saveColumnWidth(header, currentColumnIndex, header.getBoundingClientRect().width || startWidth);
synchronizeTableWidth();
document.documentElement.classList.remove('table-column-resizing');
window.removeEventListener('pointermove', move, true);
window.removeEventListener('pointerup', finish, true);
window.removeEventListener('pointercancel', finish, true);
};
window.addEventListener('pointermove', move, true);
window.addEventListener('pointerup', finish, true);
window.addEventListener('pointercancel', finish, true);
});
handle.addEventListener('keydown', event => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
event.stopPropagation();
freezeTableGeometry();
const currentColumnIndex = header.cellIndex;
const step = event.key === 'ArrowRight' ? 12 : -12;
const current = header.getBoundingClientRect().width || minimumColumnWidth;
saveColumnWidth(header, currentColumnIndex, current + step);
synchronizeTableWidth();
});
});
}
function ensureRowNumberCells() {
[...tbody.rows].forEach(row => {
if (row.querySelector('td[data-row-number-column="1"]')) return;
const cell = document.createElement('td');
cell.className = 'table-row-number-cell';
cell.dataset.rowNumberColumn = '1';
cell.setAttribute('aria-hidden', 'true');
row.insertBefore(cell, row.firstChild);
});
}
ensureRowNumberCells();
const isAssetTable = table.id === 'asset-table';
const usesServerFilters = table.dataset.serverFilters === '1';
let serverFilterTimer = null;
let serverFilterController = null;
let serverFilterRequestId = 0;
// Normal tables get stable synthetic keys. The asset table already has
// real data-field keys on its movable columns; selection and action
// columns must remain without a field key.
if (!isAssetTable) {
let syntheticIndex = 0;
[...headerRow.cells].forEach(header => {
if (header.dataset.rowNumberColumn === '1') return;
if (!header.dataset.field) {
header.dataset.field = `__col_${syntheticIndex}`;
header.dataset.syntheticField = '1';
}
syntheticIndex += 1;
});
[...tbody.rows].forEach(row => {
[...row.cells].forEach((cell, index) => {
if (cell.dataset.rowNumberColumn === '1') return;
const header = headerRow.cells[index];
if (header && !cell.dataset.field) {
cell.dataset.field = header.dataset.field;
cell.dataset.syntheticField = '1';
}
});
});
}
const filterRow = document.createElement('tr');
filterRow.className = 'column-filter-row';
filterRow.dataset.tableFilterRow = '1';
const filterDefinitions = [];
function filterStatusTarget() {
const wrapper = table.closest(
'#asset-table-scroll, .management-table-scroll, .table-scroll, .table-wrap, .sticky-table'
);
return wrapper || table;
}
function ensureFilterStatus() {
let status = table._filterStatusElement;
if (status?.isConnected) return status;
status = document.createElement('div');
status.className = 'table-filter-status';
status.hidden = true;
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
const icon = document.createElement('span');
icon.className = 'table-filter-status-icon';
icon.textContent = '⌕';
icon.setAttribute('aria-hidden', 'true');
const label = document.createElement('strong');
label.className = 'table-filter-status-label';
const details = document.createElement('span');
details.className = 'table-filter-status-details';
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'button button-secondary table-filter-clear';
clearButton.textContent =
document.body.dataset.clearFiltersLabel || 'Filter zurücksetzen';
clearButton.addEventListener('click', () => {
filterDefinitions.forEach(definition => {
definition.input.value = '';
});
applyFilters();
if (usesServerFilters) submitServerFilters(true);
// Keep the persisted browser state in sync with the visible table.
// Programmatic value changes do not trigger input/change automatically.
window.dispatchEvent(new CustomEvent('asset-table-filters-cleared', {
detail: { table }
}));
filterDefinitions[0]?.input?.focus();
});
status.append(icon, label, details, clearButton);
const target = filterStatusTarget();
target.parentNode?.insertBefore(status, target);
table._filterStatusElement = status;
return status;
}
function updateFilterPresentation(active, visibleRows, totalRows) {
filterDefinitions.forEach(definition => {
const hasValue = Boolean(definition.input.value.trim());
definition.input.classList.toggle('filter-input-active', hasValue);
definition.input.closest('th')?.classList.toggle('filter-cell-active', hasValue);
});
const status = ensureFilterStatus();
const label = status.querySelector('.table-filter-status-label');
const details = status.querySelector('.table-filter-status-details');
const activeCount = active.length;
status.hidden = activeCount === 0;
status.classList.toggle('is-active', activeCount > 0);
if (activeCount > 0) {
const activeTemplate =
document.body.dataset.filtersActiveTemplate || '{count} Filter aktiv';
const statusTemplate =
document.body.dataset.filterStatusTemplate ||
'Gefiltert: {visible} von {total} Datensätzen';
label.textContent = activeTemplate.replace('{count}', String(activeCount));
details.textContent = statusTemplate
.replace('{visible}', String(visibleRows))
.replace('{total}', String(totalRows));
}
}
function cellForDefinition(row, definition) {
if (definition.field) {
return row.querySelector(`td[data-field="${CSS.escape(definition.field)}"]`);
}
return row.cells[definition.columnIndex] || null;
}
function rebuildMissingCellKeys() {
ensureRowNumberCells();
if (isAssetTable) return;
[...tbody.rows].forEach(row => {
[...row.cells].forEach((cell, index) => {
if (cell.dataset.rowNumberColumn === '1') return;
const header = headerRow.cells[index];
if (header && !cell.dataset.field) {
cell.dataset.field = header.dataset.field;
}
});
});
}
function comparableCellValue(cell) {
if (!cell) return '';
if (cell.dataset.sortValue !== undefined && String(cell.dataset.sortValue).trim()) {
return String(cell.dataset.sortValue).trim();
}
if (cell.dataset.filterValue !== undefined && String(cell.dataset.filterValue).trim()) {
return String(cell.dataset.filterValue).trim();
}
return textValue(cell).trim();
}
function parseFilterNumber(rawValue) {
const text = String(rawValue ?? '').trim().replace(/\s/g, '').replace(',', '.');
if (!/^[-+]?\d+(?:\.\d+)?$/.test(text)) return null;
const value = Number(text);
return Number.isFinite(value) ? value : null;
}
function parseFilterDate(rawValue) {
const text = String(rawValue ?? '').trim();
if (!text) return null;
let match = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/);
if (match) {
if (match[4] && (text.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(text))) {
const timestamp = Date.parse(text.replace(' ', 'T'));
if (!Number.isNaN(timestamp)) return {timestamp, dateOnly: false};
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4] || 0);
const minute = Number(match[5] || 0);
const second = Number(match[6] || 0);
const value = new Date(year, month - 1, day, hour, minute, second);
if (
value.getFullYear() === year && value.getMonth() === month - 1 &&
value.getDate() === day && value.getHours() === hour && value.getMinutes() === minute
) {
return {timestamp: value.getTime(), dateOnly: !match[4]};
}
return null;
}
match = text.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
if (match) {
let year = Number(match[3]);
if (year < 100) year += year >= 70 ? 1900 : 2000;
const month = Number(match[2]);
const day = Number(match[1]);
const hour = Number(match[4] || 0);
const minute = Number(match[5] || 0);
const second = Number(match[6] || 0);
const value = new Date(year, month - 1, day, hour, minute, second);
if (
value.getFullYear() === year && value.getMonth() === month - 1 &&
value.getDate() === day && value.getHours() === hour && value.getMinutes() === minute
) {
return {timestamp: value.getTime(), dateOnly: !match[4]};
}
}
return null;
}
function rangeParts(term) {
let match = term.match(/^(?:von|from)\s+(.+?)\s+(?:bis|to)\s+(.+)$/i);
if (!match) match = term.match(/^(?:zwischen|between)\s+(.+?)\s+(?:und|and)\s+(.+)$/i);
if (!match) match = term.match(/^(.+?)\s*\.\.\s*(.+)$/);
if (!match) match = term.match(/^(.+?)\s+-\s+(.+)$/);
return match ? [match[1].trim(), match[2].trim()] : null;
}
function numericFilterMatches(rawValue, rawTerm, allowPlainExact = true) {
const value = parseFilterNumber(rawValue);
if (value === null) return null;
const term = String(rawTerm || '').trim();
if (!term) return true;
let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/);
if (match) {
const expected = parseFilterNumber(match[2]);
if (expected === null) return null;
if (match[1] === '<') return value < expected;
if (match[1] === '<=') return value <= expected;
if (match[1] === '>') return value > expected;
if (match[1] === '>=') return value >= expected;
return value === expected;
}
let compactRange = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/);
const range = compactRange ? [compactRange[1], compactRange[2]] : rangeParts(term);
if (range) {
const first = parseFilterNumber(range[0]);
const second = parseFilterNumber(range[1]);
if (first !== null && second !== null) {
const minimum = Math.min(first, second);
const maximum = Math.max(first, second);
return value >= minimum && value <= maximum;
}
}
if (!allowPlainExact) return null;
const exact = parseFilterNumber(term);
return exact === null ? null : value === exact;
}
function dateFilterMatches(rawValue, rawTerm) {
const value = parseFilterDate(rawValue);
if (!value) return null;
const term = String(rawTerm || '').trim();
if (!term) return true;
const nextDay = parsed => parsed.timestamp + 24 * 60 * 60 * 1000;
let match = term.match(/^(<=|>=|<|>|=)\s*(.+)$/);
if (match) {
const expected = parseFilterDate(match[2]);
if (!expected) return null;
if (match[1] === '<') return value.timestamp < expected.timestamp;
if (match[1] === '<=') return expected.dateOnly ? value.timestamp < nextDay(expected) : value.timestamp <= expected.timestamp;
if (match[1] === '>') return expected.dateOnly ? value.timestamp >= nextDay(expected) : value.timestamp > expected.timestamp;
if (match[1] === '>=') return value.timestamp >= expected.timestamp;
if (expected.dateOnly) return value.timestamp >= expected.timestamp && value.timestamp < nextDay(expected);
return value.timestamp === expected.timestamp;
}
const range = rangeParts(term);
if (range) {
const first = parseFilterDate(range[0]);
const second = parseFilterDate(range[1]);
if (first && second) {
const start = Math.min(first.timestamp, second.timestamp);
const endSource = first.timestamp <= second.timestamp ? second : first;
const end = endSource.dateOnly ? nextDay(endSource) : endSource.timestamp;
return value.timestamp >= start && (endSource.dateOnly ? value.timestamp < end : value.timestamp <= end);
}
}
return null;
}
function advancedFilterMatches(cell, displayValue, term, definition) {
const comparable = comparableCellValue(cell);
const neutralTerm = String(term || '').trim().toLowerCase();
if (neutralTerm === '@empty') return !String(comparable || '').trim();
if (neutralTerm === '@notempty') return !!String(comparable || '').trim();
// Preserve the pre-existing software-summary numeric behavior exactly:
// plain numeric input is an exact match in columns explicitly marked as numeric.
if (definition.numericFilter) {
const numeric = numericFilterMatches(comparable, term, true);
return numeric === null ? searchTextMatches(displayValue, term) : numeric;
}
// Everywhere else advanced comparisons are opt-in by syntax. A normal
// filter such as "5" or "2026" therefore keeps the existing substring behavior.
const numeric = numericFilterMatches(comparable, term, false);
if (numeric !== null) return numeric;
const date = dateFilterMatches(comparable, term);
if (date !== null) return date;
return searchTextMatches(displayValue, term);
}
function inferredAdvancedFilterType(header, columnIndex, serverName) {
const explicit = String(header.dataset.filterType || '').toLowerCase();
if (['integer', 'decimal', 'number'].includes(explicit)) return 'number';
if (['date', 'datetime'].includes(explicit)) return 'date';
if (header.dataset.numericFilter === '1') return 'number';
if (['job_id', 'priority'].includes(serverName)) return 'number';
if (serverName === 'created_at') return 'date';
// Generic fallback for tables that do not declare a data type. Only
// classify a column when every sampled non-empty value is parseable.
const samples = [...tbody.rows]
.map(row => comparableCellValue(row.cells[columnIndex]))
.filter(value => String(value || '').trim())
.slice(0, 12);
if (!samples.length) return '';
if (samples.every(value => parseFilterDate(value))) return 'date';
if (samples.every(value => parseFilterNumber(value) !== null)) return 'number';
return '';
}
function createAdvancedFilterHelper(filterCell, input, kind) {
if (!kind) return;
filterCell.classList.add('advanced-filter-cell');
input.classList.add('advanced-filter-input');
// Keep the popup outside the table. Asset tables use sticky headers and
// overflow scrollers, which clip absolutely positioned descendants of TH.
// A body-level fixed popup remains visible in every table layout.
const popup = document.createElement('div');
popup.className = 'advanced-filter-popup advanced-filter-popup-portal';
popup.hidden = true;
document.body.appendChild(popup);
const title = document.createElement('strong');
title.className = 'advanced-filter-title';
title.textContent = document.body.dataset.advancedFilterTitle || 'Advanced filter';
popup.appendChild(title);
const operatorLabel = document.createElement('label');
operatorLabel.textContent = document.body.dataset.advancedFilterOperator || 'Condition';
const operator = document.createElement('select');
const options = [
['=', document.body.dataset.advancedFilterEqual || 'Equals'],
['>', document.body.dataset.advancedFilterGt || 'Greater than'],
['>=', document.body.dataset.advancedFilterGte || 'Greater than or equal'],
['<', document.body.dataset.advancedFilterLt || 'Less than'],
['<=', document.body.dataset.advancedFilterLte || 'Less than or equal'],
['range', document.body.dataset.advancedFilterRange || 'Range'],
['@empty', document.body.dataset.advancedFilterEmpty || 'Empty'],
['@notempty', document.body.dataset.advancedFilterNotEmpty || 'Not empty']
];
options.forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
operator.appendChild(option);
});
operatorLabel.appendChild(operator);
popup.appendChild(operatorLabel);
const valueWrap = document.createElement('div');
valueWrap.className = 'advanced-filter-values';
const makeValueInput = (labelText, className) => {
const label = document.createElement('label');
label.textContent = labelText;
const valueInput = document.createElement('input');
valueInput.className = className;
valueInput.type = kind === 'date' ? 'date' : 'number';
if (kind === 'number') valueInput.step = 'any';
label.appendChild(valueInput);
valueWrap.appendChild(label);
return {label, input: valueInput};
};
const first = makeValueInput(document.body.dataset.advancedFilterValue || 'Value', 'advanced-filter-value-first');
const second = makeValueInput(document.body.dataset.advancedFilterValueTo || 'To', 'advanced-filter-value-second');
second.label.hidden = true;
popup.appendChild(valueWrap);
const actions = document.createElement('div');
actions.className = 'advanced-filter-actions';
const apply = document.createElement('button');
apply.type = 'button';
apply.className = 'button button-primary';
apply.textContent = document.body.dataset.advancedFilterApply || 'Apply';
const clear = document.createElement('button');
clear.type = 'button';
clear.className = 'button button-secondary';
clear.textContent = document.body.dataset.advancedFilterClear || 'Clear';
actions.append(apply, clear);
popup.appendChild(actions);
const syncOperator = () => {
const range = operator.value === 'range';
const needsValue = !['@empty', '@notempty'].includes(operator.value);
first.label.hidden = !needsValue;
first.label.firstChild.textContent = range
? (document.body.dataset.advancedFilterValueFrom || 'From')
: (document.body.dataset.advancedFilterValue || 'Value');
second.label.hidden = !range || !needsValue;
};
operator.addEventListener('change', syncOperator);
syncOperator();
const positionPopup = () => {
if (popup.hidden) return;
const rect = input.getBoundingClientRect();
const margin = 6;
const minWidth = Math.max(250, Math.round(rect.width));
popup.style.minWidth = `${minWidth}px`;
popup.style.maxWidth = `min(360px, calc(100vw - ${margin * 2}px))`;
// Measure after making the popup visible, then choose below/above.
const popupRect = popup.getBoundingClientRect();
let left = rect.left;
if (left + popupRect.width > window.innerWidth - margin) {
left = Math.max(margin, window.innerWidth - popupRect.width - margin);
}
left = Math.max(margin, left);
let top = rect.bottom + 4;
if (top + popupRect.height > window.innerHeight - margin && rect.top > popupRect.height + margin) {
top = rect.top - popupRect.height - 4;
}
popup.style.left = `${Math.round(left)}px`;
popup.style.top = `${Math.round(Math.max(margin, top))}px`;
};
const openPopup = () => {
popup.hidden = false;
popup.dataset.open = '1';
positionPopup();
};
const closePopup = () => {
popup.hidden = true;
delete popup.dataset.open;
};
// Focus/click both open the helper. Click is kept explicitly because a
// textarea can already be focused when the user clicks it again.
input.addEventListener('focus', openPopup);
input.addEventListener('click', openPopup);
popup.addEventListener('click', event => event.stopPropagation());
popup.addEventListener('pointerdown', event => event.stopPropagation());
document.addEventListener('click', event => {
if (event.target === input || popup.contains(event.target)) return;
closePopup();
});
window.addEventListener('resize', positionPopup);
// Reposition while any surrounding table/page scroller moves.
window.addEventListener('scroll', positionPopup, true);
const triggerFilter = () => {
input.dispatchEvent(new Event('input', {bubbles: true}));
};
apply.addEventListener('click', () => {
if (['@empty', '@notempty'].includes(operator.value)) {
input.value = operator.value;
triggerFilter();
closePopup();
return;
}
const value1 = first.input.value.trim();
if (!value1) return;
if (operator.value === 'range') {
const value2 = second.input.value.trim();
if (!value2) return;
input.value = `${value1}..${value2}`;
} else {
input.value = `${operator.value}${value1}`;
}
triggerFilter();
closePopup();
});
clear.addEventListener('click', () => {
first.input.value = '';
second.input.value = '';
input.value = '';
triggerFilter();
closePopup();
});
[first.input, second.input].forEach(valueInput => {
valueInput.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
apply.click();
} else if (event.key === 'Escape') {
closePopup();
input.focus();
}
});
});
}
function submitServerFilters(immediate = false) {
if (!usesServerFilters) return;
if (serverFilterTimer) window.clearTimeout(serverFilterTimer);
const navigate = async () => {
const target = new URL(
table.dataset.serverFilterUrl || window.location.pathname,
window.location.origin
);
const current = new URL(window.location.href);
const limit = current.searchParams.get('job_limit');
if (limit) target.searchParams.set('job_limit', limit);
filterDefinitions.forEach(definition => {
if (!definition.serverName) return;
const value = definition.input.value.trim();
if (value) target.searchParams.set(`job_filter_${definition.serverName}`, value);
});
const requestId = ++serverFilterRequestId;
serverFilterController?.abort();
serverFilterController = new AbortController();
table.classList.add('server-filter-loading');
table.setAttribute('aria-busy', 'true');
try {
const response = await fetch(target.toString(), {
cache: 'no-store',
headers: {'X-Requested-With': 'AssetManagerTableFilter'},
signal: serverFilterController.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const html = await response.text();
if (requestId !== serverFilterRequestId) return;
const documentCopy = new DOMParser().parseFromString(html, 'text/html');
const replacement = documentCopy.querySelector(
`table[data-storage-key="${CSS.escape(table.dataset.storageKey || '')}"]`
);
const replacementBody = replacement?.tBodies?.[0];
if (!replacement || !replacementBody) throw new Error('Filtered table not found');
tbody.replaceChildren(...[...replacementBody.rows].map(row => row.cloneNode(true)));
table.dataset.serverFilterTotal = replacement.dataset.serverFilterTotal || String(tbody.rows.length);
window.history.replaceState({}, '', target.toString());
window.AssetManagerTime?.scan(tbody);
applyFilters();
window.dispatchEvent(new CustomEvent('asset-table-content-reloaded', {
detail: {table}
}));
} catch (error) {
if (error?.name !== 'AbortError') {
// Fall back to a normal navigation only when the partial reload fails.
window.location.assign(target.toString());
}
} finally {
if (requestId === serverFilterRequestId) {
table.classList.remove('server-filter-loading');
table.removeAttribute('aria-busy');
}
}
};
if (immediate) navigate();
else serverFilterTimer = window.setTimeout(navigate, 700);
}
function updateRowNumbers() {
let visibleIndex = 0;
[...tbody.rows].forEach(row => {
const cell = row.querySelector('td[data-row-number-column="1"]');
if (!cell) return;
if (row.hidden) {
cell.textContent = '';
return;
}
visibleIndex += 1;
cell.textContent = String(visibleIndex);
});
}
function applyFilters() {
rebuildMissingCellKeys();
const active = filterDefinitions
.map(definition => ({
definition,
terms: String(definition.input?.value || '')
.split(/\r?\n/)
.map(term => term.trim())
.filter(Boolean)
}))
.filter(item => item.terms.length);
if (usesServerFilters) {
const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10);
const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length;
updateFilterPresentation(active, tbody.rows.length, total);
updateRowNumbers();
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
detail: {
table,
visible: tbody.rows.length,
total,
activeFilters: active.length
}
}));
return;
}
let visibleRows = 0;
[...tbody.rows].forEach(row => {
const hidden = active.some(({ definition, terms }) => {
const cell = cellForDefinition(row, definition);
const value = textValue(cell);
const matchesAnyTerm = terms.some(term =>
advancedFilterMatches(cell, value, term, definition)
);
return !matchesAnyTerm;
});
row.hidden = hidden;
if (!hidden) visibleRows += 1;
});
updateFilterPresentation(active, visibleRows, tbody.rows.length);
updateRowNumbers();
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
detail: {
table,
visible: visibleRows,
total: tbody.rows.length,
activeFilters: active.length
}
}));
}
function applySort(field, direction, options = {}) {
const header = [...headerRow.cells].find(item => item.dataset.field === field);
if (!header || header.dataset.noSort) return false;
const columnIndex = header.cellIndex;
[...headerRow.cells].forEach(item => {
delete item.dataset.sortDirection;
item.classList.remove('sort-asc', 'sort-desc');
});
const normalizedDirection = direction === 'desc' ? 'desc' : 'asc';
header.dataset.sortDirection = normalizedDirection;
header.classList.add(normalizedDirection === 'asc' ? 'sort-asc' : 'sort-desc');
const rows = [...tbody.rows];
rows.sort((left, right) => {
const definition = { field, columnIndex };
const result = compareValues(
textValue(cellForDefinition(left, definition)),
textValue(cellForDefinition(right, definition))
);
return normalizedDirection === 'asc' ? result : -result;
});
const fragment = document.createDocumentFragment();
rows.forEach(row => fragment.appendChild(row));
tbody.appendChild(fragment);
applyFilters();
if (!options.silent) {
window.dispatchEvent(new CustomEvent('asset-table-sort-changed', {
detail: { table, field, direction: normalizedDirection }
}));
}
return true;
}
[...headerRow.cells].forEach((header, columnIndex) => {
const field = header.dataset.field || '';
const serverName = header.dataset.serverFilterName || '';
if (!header.dataset.noSort) {
header.classList.add('sortable-column');
header.title = header.dataset.sortTitle || 'Zum Sortieren anklicken';
header.addEventListener('click', event => {
if (event.target.closest('input,button,a,select,textarea,label')) return;
const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc';
applySort(field, direction);
});
}
const filterCell = document.createElement('th');
if (field) filterCell.dataset.field = field;
filterCell.dataset.columnIndex = String(columnIndex);
if (header.dataset.rowNumberColumn === '1') {
filterCell.dataset.rowNumberColumn = '1';
filterCell.className = 'table-row-number-filter';
}
if (!header.dataset.noFilter && (!usesServerFilters || serverName)) {
const input = document.createElement('textarea');
input.rows = 1;
input.placeholder = header.dataset.filterPlaceholder || 'Filtern …';
input.setAttribute('aria-label', `Filter ${textValue(header)}`);
input.title = document.body.dataset.multiValueFilterHint || '';
input.autocomplete = 'off';
input.spellcheck = false;
if (usesServerFilters) {
input.name = `job_filter_${serverName}`;
input.value = header.dataset.serverFilterValue || '';
input.addEventListener('input', () => {
applyFilters();
submitServerFilters(false);
});
} else {
input.addEventListener('input', applyFilters);
}
filterCell.appendChild(input);
const advancedFilterType = inferredAdvancedFilterType(header, columnIndex, serverName);
createAdvancedFilterHelper(filterCell, input, advancedFilterType);
filterDefinitions.push({
field,
columnIndex,
input,
serverName,
numericFilter: header.dataset.numericFilter === '1',
advancedFilterType
});
}
filterRow.appendChild(filterCell);
});
thead.appendChild(filterRow);
updateStickyHeaderMetrics();
requestAnimationFrame(updateStickyHeaderMetrics);
// All AssetManager data tables use the same resize implementation.
// This keeps width changes, table growth and user-state persistence
// identical across Assets, Software, Jobs, Settings and detail lists.
installColumnResizers();
applyStoredColumnWidths();
requestAnimationFrame(updateStickyHeaderMetrics);
// Editable cells are always read live, so changed values immediately
// participate in filtering without a stale cache.
tbody.addEventListener('input', event => {
if (event.target.matches('input,select,textarea')) applyFilters();
});
tbody.addEventListener('change', event => {
if (event.target.matches('input,select,textarea')) applyFilters();
});
table.assetApplyFilters = applyFilters;
table.assetSubmitServerFilters = submitServerFilters;
table.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options);
table.assetUpdateRowNumbers = updateRowNumbers;
table.assetRebuildFilterCache = rebuildMissingCellKeys;
table.assetApplyStoredColumnWidths = applyStoredColumnWidths;
table.assetUpdateStickyHeaderMetrics = updateStickyHeaderMetrics;
table.dataset.tableToolsReady = '1';
ensureFilterStatus();
// Covers browser-restored values after reload/back navigation.
requestAnimationFrame(() => {
applyFilters();
if (usesServerFilters) {
try {
const lastName = window.sessionStorage.getItem('assetmanager:software-jobs:last-server-filter');
const lastInput = lastName
? filterDefinitions.find(definition => definition.input.name === lastName)?.input
: null;
if (lastInput) {
lastInput.focus();
lastInput.setSelectionRange(lastInput.value.length, lastInput.value.length);
}
window.sessionStorage.removeItem('assetmanager:software-jobs:last-server-filter');
} catch (_) {}
}
});
window.dispatchEvent(new CustomEvent('asset-table-structure-ready', {
detail: { table }
}));
}
function initUniversalTableResize(table) {
if (!table || table.id === 'asset-table' || table.dataset.columnResizeReady === '1') return;
const thead = table.tHead;
const tbody = table.tBodies?.[0];
if (!thead || !tbody || !thead.rows.length) return;
const headerRow = thead.rows[0];
const storagePart = table.dataset.storageKey || table.id || `${window.location.pathname}:table-${[...document.querySelectorAll('main table')].indexOf(table)}`;
const storagePrefix = `table:${storagePart}:column-width:`;
const minWidth = 56;
const normalize = width => Math.max(minWidth, Math.round(width));
const columnKey = (header, index) => header.dataset.field || `index-${index}`;
const applyWidth = (index, width) => {
const normalized = normalize(width);
[...thead.rows, ...tbody.rows].forEach(row => {
const cell = row.cells?.[index];
if (!cell) return;
cell.style.width = `${normalized}px`;
cell.style.minWidth = `${normalized}px`;
cell.style.maxWidth = `${normalized}px`;
});
return normalized;
};
const freezeGeometry = () => {
const widths = [...headerRow.cells].map(cell => {
const measured = Math.round(cell.getBoundingClientRect().width || 0);
return measured > 0 ? normalize(measured) : 0;
});
const total = widths.reduce((sum, width) => sum + width, 0);
widths.forEach((width, index) => { if (width) applyWidth(index, width); });
table.style.tableLayout = 'fixed';
table.style.width = `${total}px`;
table.style.minWidth = `${total}px`;
};
const setTableWidth = total => {
const normalized = Math.max(1, Math.round(total || 0));
table.style.width = `${normalized}px`;
table.style.minWidth = `${normalized}px`;
};
const currentTableWidth = () => [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0);
const syncTableWidth = () => {
const total = currentTableWidth();
if (total > 0) setTableWidth(total);
};
[...headerRow.cells].forEach((header, index) => {
if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return;
const key = `${storagePrefix}${columnKey(header, index)}`;
try {
const stored = Number.parseInt(stateGet(key) || '', 10);
if (Number.isFinite(stored)) applyWidth(index, stored);
} catch (_) {}
if (header.querySelector(':scope > .table-column-resizer')) return;
header.classList.add('table-resizable-column-header');
const handle = document.createElement('span');
handle.className = 'table-column-resizer';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.tabIndex = 0;
handle.title = document.body.dataset.columnResizeLabel || 'Drag to change the column width';
header.appendChild(handle);
const save = width => {
const normalized = applyWidth(index, width);
try { stateSet(key, String(normalized)); } catch (_) {}
};
handle.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); });
handle.addEventListener('pointerdown', event => {
event.preventDefault(); event.stopPropagation();
const startX = event.clientX;
freezeGeometry();
const currentIndex = header.cellIndex;
const startWidth = header.getBoundingClientRect().width || minWidth;
const startTableWidth = currentTableWidth();
document.documentElement.classList.add('table-column-resizing');
const resizeColumn = delta => {
const newWidth = applyWidth(currentIndex, startWidth + delta);
setTableWidth(startTableWidth + (newWidth - startWidth));
};
const move = e => resizeColumn(e.clientX - startX);
const finish = () => {
const normalized = applyWidth(currentIndex, header.getBoundingClientRect().width || startWidth);
try { stateSet(`${storagePrefix}${columnKey(header, currentIndex)}`, String(normalized)); } catch (_) {}
syncTableWidth();
document.documentElement.classList.remove('table-column-resizing');
window.removeEventListener('pointermove', move, true);
window.removeEventListener('pointerup', finish, true);
window.removeEventListener('pointercancel', finish, true);
};
window.addEventListener('pointermove', move, true);
window.addEventListener('pointerup', finish, true);
window.addEventListener('pointercancel', finish, true);
});
handle.addEventListener('keydown', event => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault(); event.stopPropagation();
freezeGeometry();
const currentIndex = header.cellIndex;
const step = event.key === 'ArrowRight' ? 12 : -12;
const current = header.getBoundingClientRect().width || minWidth;
const normalized = applyWidth(currentIndex, current + step);
try { stateSet(`${storagePrefix}${columnKey(header, currentIndex)}`, String(normalized)); } catch (_) {}
syncTableWidth();
});
});
table.dataset.columnResizeReady = '1';
}
const initializeTableTools = () => {
document.querySelectorAll('table.data-table').forEach(initTable);
document.querySelectorAll('main table:not(.data-table):not([data-column-resize="off"])').forEach(initUniversalTableResize);
};
if (uiState?.ready) uiState.ready.finally(initializeTableTools);
else initializeTableTools();
window.addEventListener('asset-table-content-reloaded', event => {
const table = event.detail?.table;
if (table?.assetRebuildFilterCache) {
table.assetRebuildFilterCache();
const sortedHeader = table.tHead?.rows?.[0]?.querySelector('[data-sort-direction]');
if (sortedHeader?.dataset.field && table.assetApplySort) {
table.assetApplySort(sortedHeader.dataset.field, sortedHeader.dataset.sortDirection, {silent: true});
} else {
table.assetApplyFilters?.();
}
table.assetUpdateRowNumbers?.();
table.assetApplyStoredColumnWidths?.();
requestAnimationFrame(() => table.assetUpdateStickyHeaderMetrics?.());
}
});
})();