const searchFilter = document.getElementById('search-filter'); const body = document.getElementById('documents-body'); const summaryCards = document.getElementById('summary-cards'); const refreshButton = document.getElementById('refresh-btn'); const listStatus = document.getElementById('list-status'); const loadMoreButton = document.getElementById('load-more-btn'); const loadSentinel = document.getElementById('load-sentinel'); let columnFilters = []; const tableHead = document.getElementById('documents-head'); const columnOptions = document.getElementById('column-options'); const selectedIds = new Set(); let allFilteredSelection = false; let availableAdditionalFields = []; const defaultColumns = ['status', 'current_key', 'emitting_company', 'document_type', 'period', 'updated_at']; const columnDefinitions = { status: { label: 'Status', filter: 'status', sortable: true }, current_key: { label: 'Document', filter: 'current_key', sortable: true }, emitting_company: { label: 'Company', filter: 'emitting_company', sortable: true }, document_type: { label: 'Document type', filter: 'document_type', sortable: true }, period: { label: 'Period', filter: 'period', sortable: true }, updated_at: { label: 'Last modified', filter: 'updated_at', sortable: true }, template_name: { label: 'Template', filter: 'template', sortable: true }, emission_date: { label: 'Emission date', filter: 'emission_date', sortable: true, inputType: 'date' }, confidence: { label: 'Confidence', filter: 'confidence', sortable: true }, }; let visibleColumns = (() => { try { const saved = JSON.parse(localStorage.getItem('pdf-renamer-columns') || 'null'); return Array.isArray(saved) ? saved : [...defaultColumns]; } catch { return [...defaultColumns]; } })(); const selectedCount = () => allFilteredSelection ? total : selectedIds.size; const updateSelectionUi = () => { document.getElementById('selection-count').textContent = allFilteredSelection ? `All ${total} filtered documents selected` : `${selectedIds.size} selected`; document.getElementById('bulk-edit-btn').disabled = selectedCount() === 0; const headerSelect = document.getElementById('select-page-head'); if (headerSelect) { headerSelect.checked = loadedItems.length > 0 && loadedItems.every((item) => selectedIds.has(item.id)); headerSelect.indeterminate = !headerSelect.checked && loadedItems.some((item) => selectedIds.has(item.id)); } }; const renderColumnPicker = () => { const options = [...Object.entries(columnDefinitions).map(([key, value]) => [key, value.label]), ...availableAdditionalFields.map((key) => [`additional:${key}`, key])]; columnOptions.innerHTML = options.map(([key, label]) => `` ).join(''); columnOptions.querySelectorAll('[data-column-option]').forEach((control) => control.addEventListener('change', () => { const key = control.dataset.columnOption; visibleColumns = control.checked ? [...visibleColumns, key] : visibleColumns.filter((item) => item !== key); if (!visibleColumns.length) { visibleColumns = [...defaultColumns]; } localStorage.setItem('pdf-renamer-columns', JSON.stringify(visibleColumns)); renderTableHead(); updateSortIndicators(); renderRows(loadedItems, false); })); }; const renderTableHead = () => { const header = (key) => { const def = columnDefinitions[key] || { label: key.replace(/^additional:/, ''), filter: `additional_field__${key.slice(11)}`, sortable: true }; return `${def.sortable ? `` : escapeHtml(def.label)}`; }; const previousFilterValues = Object.fromEntries(columnFilters.map((filter) => [filter.dataset.filter, filter.value])); const filters = visibleColumns.map((key) => { const def = columnDefinitions[key] || (key.startsWith('additional:') ? { label: key.slice(11), filter: `additional_field__${key.slice(11)}`, inputType: 'search' } : null); if (!def || !def.filter) return ''; const filterId = `column-filter-${def.filter}`; if (def.filter === 'status') return ``; const inputType = def.inputType || (def.filter === 'updated_at' ? 'date' : 'search'); return ``; }).join(''); tableHead.innerHTML = `${visibleColumns.map(header).join('')}${filters}`; columnFilters = [...tableHead.querySelectorAll('[data-filter]')]; columnFilters.forEach((filter) => filter.addEventListener('input', () => { selectedIds.clear(); allFilteredSelection = false; updateSelectionUi(); loadDocuments(); })); tableHead.querySelectorAll('[data-sort]').forEach((button) => button.addEventListener('click', () => { const selectedSort = button.dataset.sort.startsWith('additional:') ? `additional_field__${button.dataset.sort.slice(11)}` : button.dataset.sort; if (sortBy === selectedSort) sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; else { sortBy = selectedSort; sortOrder = 'asc'; } updateSortIndicators(); loadDocuments(); })); const selectHead = tableHead.querySelector('#select-page-head'); selectHead.addEventListener('change', () => selectLoadedRows(selectHead.checked)); }; let sortButtons = []; let sortBy = 'updated_at'; let sortOrder = 'desc'; let page = 1; let total = 0; let hasMore = true; let loading = false; let reloadAfterRequest = false; let loadedItems = []; const formatUpdated = (value) => { if (!value) return '—'; try { return new Date(value).toLocaleString('fr-FR'); } catch { return value; } }; const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[character])); const getListParams = () => { const params = new URLSearchParams(); columnFilters.forEach((filter) => { const value = filter.value.trim(); if (value) params.set(filter.dataset.filter, value); }); const search = searchFilter.value.trim(); if (search) params.set('q', search); params.set('sort_by', sortBy); params.set('sort_order', sortOrder); return params; }; const badgeClass = (status) => { const normalized = (status || 'unknown').trim().toLowerCase(); if (normalized === 'processed' || normalized === 'validated') return normalized; if (normalized === 'not processed') return 'not processed'; return 'unknown'; }; const renderSummary = (totals) => { summaryCards.innerHTML = `
Total ${totals.total}
Processed ${totals.processed}
Validated ${totals.validated}
Pending ${totals.pending}
`; }; const renderRows = (items, append) => { if (!items.length && !append) { body.innerHTML = `No documents found.`; return; } const listParams = getListParams(); const rows = items.map((item) => { const detailParams = new URLSearchParams(listParams); detailParams.set('id', item.id); const cells = visibleColumns.map((key) => { if (key.startsWith('additional:')) return `${escapeHtml(item.optional_fields?.[key.slice(11)] ?? '—')}`; if (key === 'status') return `${escapeHtml(item.status || 'unknown')}`; if (key === 'current_key') return `${escapeHtml(item.current_key?.split('/').pop() || 'Untitled document')}
${escapeHtml(item.current_key || '—')}
`; if (key === 'updated_at') return `${escapeHtml(formatUpdated(item.updated_at))}`; if (key === 'confidence') return `${item.confidence == null ? '—' : `${(Number(item.confidence) * 100).toFixed(1)}%`}`; return `${escapeHtml(item[key] ?? '—')}`; }).join(''); return `${cells}`; }).join(''); if (append) body.insertAdjacentHTML('beforeend', rows); else body.innerHTML = rows; body.querySelectorAll('[data-select-id]').forEach((control) => control.addEventListener('change', () => { if (control.checked) selectedIds.add(control.dataset.selectId); else selectedIds.delete(control.dataset.selectId); allFilteredSelection = false; updateSelectionUi(); })); updateSelectionUi(); }; const selectLoadedRows = (checked) => { if (checked) loadedItems.forEach((item) => selectedIds.add(item.id)); else loadedItems.forEach((item) => selectedIds.delete(item.id)); allFilteredSelection = false; renderRows(loadedItems, false); updateSelectionUi(); }; const loadDocuments = async ({ append = false } = {}) => { if (loading) { if (!append) reloadAfterRequest = true; return; } if (append && !hasMore) return; loading = true; loadMoreButton.disabled = true; if (!append) { page = 1; total = 0; hasMore = true; loadedItems = []; listStatus.textContent = 'Loading documents...'; } else { listStatus.textContent = `Loading more documents (${loadedItems.length} of ${total})...`; } const params = getListParams(); params.set('page', String(page)); try { const response = await fetch(`/files?${params.toString()}`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const items = await response.json(); total = Number(response.headers.get('X-Total-Count')) || 0; const counts = JSON.parse(response.headers.get('X-Status-Counts') || '{}'); renderSummary({ total: counts.total ?? total, processed: counts.processed ?? 0, validated: counts.validated ?? 0, pending: counts.pending ?? 0, }); renderRows(items, append); loadedItems = append ? loadedItems.concat(items) : items; availableAdditionalFields = [...new Set(loadedItems.flatMap((item) => Object.keys(item.optional_fields || {})))]; renderColumnPicker(); page += 1; hasMore = loadedItems.length < total; listStatus.textContent = total ? `Showing ${loadedItems.length} of ${total} matching documents.` : 'No documents match these filters.'; loadMoreButton.hidden = !hasMore; } catch (error) { if (!append) { body.innerHTML = 'Unable to load documents. Check the API server.'; summaryCards.innerHTML = '
Error!
'; } listStatus.textContent = append ? `Unable to load more documents. ${loadedItems.length} of ${total} loaded.` : 'Unable to load documents. Check the API server.'; console.error(error); } finally { loading = false; loadMoreButton.disabled = false; if (reloadAfterRequest) { reloadAfterRequest = false; loadDocuments(); } } }; const updateSortIndicators = () => { sortButtons = [...document.querySelectorAll('[data-sort]')]; document.querySelectorAll('[data-sort-column]').forEach((header) => { const columnSort = header.dataset.sortColumn.startsWith('additional:') ? `additional_field__${header.dataset.sortColumn.slice(11)}` : header.dataset.sortColumn; const active = columnSort === sortBy; header.setAttribute('aria-sort', active ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'); const indicator = header.querySelector('.sort-button span'); indicator.textContent = active ? (sortOrder === 'asc' ? '↑' : '↓') : ''; }); }; renderColumnPicker(); renderTableHead(); fetch('/document-options').then((response) => response.ok ? response.json() : null).then((options) => { if (options && Array.isArray(options.optional_field_names)) { availableAdditionalFields = [...new Set([...availableAdditionalFields, ...options.optional_field_names])]; renderColumnPicker(); } }).catch(() => {}); searchFilter.addEventListener('input', () => { selectedIds.clear(); allFilteredSelection = false; updateSelectionUi(); loadDocuments(); }); refreshButton.addEventListener('click', loadDocuments); loadMoreButton.addEventListener('click', () => loadDocuments({ append: true })); sortButtons.forEach((button) => { button.addEventListener('click', () => { if (sortBy === button.dataset.sort) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortBy = button.dataset.sort; sortOrder = 'asc'; } updateSortIndicators(); loadDocuments(); }); }); document.getElementById('select-filtered-btn').addEventListener('click', () => { allFilteredSelection = true; selectedIds.clear(); updateSelectionUi(); }); document.getElementById('clear-selection-btn').addEventListener('click', () => { selectedIds.clear(); allFilteredSelection = false; renderRows(loadedItems, false); updateSelectionUi(); }); const bulkOptions = { names: [], values: {} }; const setBulkSelectOptions = (select, values, placeholder = 'Keep unchanged') => { select.replaceChildren(); const empty = document.createElement('option'); empty.value = ''; empty.textContent = placeholder; select.append(empty); [...new Set((values || []).filter((value) => value !== null && value !== undefined && String(value).trim() !== ''))].sort((a,b) => String(a).localeCompare(String(b))).forEach((value) => { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); }); }; const renderBulkAdditionalRow = (fieldName = '') => { const host = document.getElementById('bulk-additional-fields'); const row = document.createElement('div'); row.className = 'additional-field-row bulk-additional-row'; const nameGroup = document.createElement('div'); nameGroup.className = 'field-group'; const nameLabel = document.createElement('label'); nameLabel.textContent = 'Additional field'; const name = document.createElement('select'); name.className = 'bulk-additional-name'; name.setAttribute('aria-label', 'Additional field'); setBulkSelectOptions(name, bulkOptions.names, 'Choose field'); if (fieldName && ![...name.options].some((option) => option.value === fieldName)) { const option = document.createElement('option'); option.value = fieldName; option.textContent = fieldName; name.append(option); } name.value = fieldName; nameGroup.append(nameLabel, name); const valueGroup = document.createElement('div'); valueGroup.className = 'field-group'; const valueLabel = document.createElement('label'); valueLabel.textContent = 'Value'; const value = document.createElement('select'); value.className = 'bulk-additional-value'; value.setAttribute('aria-label', 'Additional field value'); const populateValues = () => { setBulkSelectOptions(value, bulkOptions.values[name.value] || [], 'Choose value'); const clear = document.createElement('option'); clear.value = '__CLEAR_FIELD__'; clear.textContent = 'Clear field'; value.append(clear); }; populateValues(); name.addEventListener('change', populateValues); valueGroup.append(valueLabel, value); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'secondary-btn small-btn'; remove.textContent = 'Remove'; remove.addEventListener('click', () => row.remove()); row.append(nameGroup, valueGroup, remove); host.append(row); }; fetch('/document-options').then((response) => response.ok ? response.json() : null).then((options) => { if (!options) return; bulkOptions.names = options.optional_field_names || []; bulkOptions.values = options.optional_field_values || {}; setBulkSelectOptions(document.getElementById('bulk-template'), options.templates || []); setBulkSelectOptions(document.getElementById('bulk-period-format'), options.period_formats || []); setBulkSelectOptions(document.getElementById('bulk-company'), options.companies || []); setBulkSelectOptions(document.getElementById('bulk-type'), options.document_types || []); }).catch((error) => console.error('Unable to load bulk edit options', error)); document.getElementById('bulk-add-field').addEventListener('click', () => renderBulkAdditionalRow()); document.getElementById('bulk-edit-btn').addEventListener('click', () => { document.getElementById('bulk-edit-form').hidden = false; document.getElementById('bulk-scope').textContent = allFilteredSelection ? `This will apply to all ${total} documents matching the active filters.` : `This will apply to ${selectedIds.size} selected documents.`; document.getElementById('bulk-result').textContent = ''; }); document.getElementById('cancel-bulk-edit').addEventListener('click', () => { document.getElementById('bulk-edit-form').hidden = true; }); document.getElementById('bulk-edit-form').addEventListener('submit', async (event) => { event.preventDefault(); const changes = {}; for (const [id, key] of [['bulk-template', 'template_name'], ['bulk-period-format', 'period_format'], ['bulk-company', 'emitting_company'], ['bulk-type', 'document_type']]) { const value = document.getElementById(id).value; if (value) changes[key] = value; } const emissionDate = document.getElementById('bulk-emission-date').value; if (emissionDate) changes.emission_date = emissionDate; const additionalFields = {}; for (const row of document.querySelectorAll('.bulk-additional-row')) { const name = row.querySelector('.bulk-additional-name').value; const value = row.querySelector('.bulk-additional-value').value; if (!name || !value) continue; if (Object.prototype.hasOwnProperty.call(additionalFields, name)) { document.getElementById('bulk-result').textContent = `Choose each additional field only once: ${name}`; return; } additionalFields[name] = value === '__CLEAR_FIELD__' ? null : value; } if (Object.keys(additionalFields).length) changes.additional_fields = additionalFields; if (!Object.keys(changes).length) { document.getElementById('bulk-result').textContent = 'Choose at least one field to change.'; return; } const payload = allFilteredSelection ? { all_matching: true, filters: Object.fromEntries(getListParams()), changes } : { ids: [...selectedIds], changes }; const result = document.getElementById('bulk-result'); const summary = Object.entries(changes).map(([key, value]) => key === 'additional_fields' ? `Additional fields: ${Object.entries(value).map(([field, fieldValue]) => `${field} → ${fieldValue === null ? 'clear' : fieldValue}`).join(', ')}` : `${key}: ${value}`).join('; '); if (!window.confirm(`Apply to ${allFilteredSelection ? total : selectedIds.size} document(s)?\n\n${summary}`)) return; result.textContent = 'Applying changes…'; try { const response = await fetch('/files/bulk-edit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`); result.textContent = `${data.succeeded} succeeded, ${data.failed} failed out of ${data.requested}.`; selectedIds.clear(); allFilteredSelection = false; await loadDocuments(); } catch (error) { result.textContent = `Bulk edit failed: ${error.message}`; } }); updateSortIndicators(); loadDocuments(); if ('IntersectionObserver' in window) { const loadObserver = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { loadDocuments({ append: true }); } }, { rootMargin: '300px' }); loadObserver.observe(loadSentinel); }