(() => { 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?.()); } }); })();