diff --git a/app/storage.py b/app/storage.py index 29e90d5..ccea22b 100644 --- a/app/storage.py +++ b/app/storage.py @@ -289,6 +289,27 @@ class ObjectStorage: safe_key = self._sanitize_object_key(object_key) return self._read_metadata(bucket_path.name, safe_key) or {} + def _cleanup_empty_parents(self, path: Path, stop_at: Path) -> None: + """Remove empty parent directories up to (but not including) stop_at. + + On Windows/OneDrive, directories may be locked briefly after file deletion. + This method retries with a small delay to handle that case. + """ + for parent in path.parents: + if parent == stop_at: + break + # Retry a few times with small delays for Windows/OneDrive + for attempt in range(3): + try: + if parent.exists() and not any(parent.iterdir()): + parent.rmdir() + break # Success, move to next parent + except OSError: + if attempt < 2: + time.sleep(0.1) # Brief delay before retry + # Final attempt failed - continue to next parent + break + def delete_object(self, bucket_name: str, object_key: str) -> None: bucket_path = self._bucket_path(bucket_name) path = self._object_path(bucket_name, object_key) @@ -303,12 +324,7 @@ class ObjectStorage: self._delete_metadata(bucket_id, rel) self._invalidate_bucket_stats_cache(bucket_id) - - for parent in path.parents: - if parent == bucket_path: - break - if parent.exists() and not any(parent.iterdir()): - parent.rmdir() + self._cleanup_empty_parents(path, bucket_path) def purge_object(self, bucket_name: str, object_key: str) -> None: bucket_path = self._bucket_path(bucket_name) @@ -330,12 +346,7 @@ class ObjectStorage: # Invalidate bucket stats cache self._invalidate_bucket_stats_cache(bucket_id) - - for parent in target.parents: - if parent == bucket_path: - break - if parent.exists() and not any(parent.iterdir()): - parent.rmdir() + self._cleanup_empty_parents(target, bucket_path) def is_versioning_enabled(self, bucket_name: str) -> bool: bucket_path = self._bucket_path(bucket_name) diff --git a/app/ui.py b/app/ui.py index 654d2c7..3fe71c0 100644 --- a/app/ui.py +++ b/app/ui.py @@ -712,12 +712,15 @@ def object_presign(bucket_name: str, object_key: str): except IamError as exc: return jsonify({"error": str(exc)}), 403 - connection_url = "http://127.0.0.1:5000" - url = f"{connection_url}/presign/{bucket_name}/{object_key}" + api_base = current_app.config.get("API_BASE_URL") or "http://127.0.0.1:5000" + api_base = api_base.rstrip("/") + url = f"{api_base}/presign/{bucket_name}/{object_key}" + # Use API base URL for forwarded headers so presigned URLs point to API, not UI + parsed_api = urlparse(api_base) headers = _api_headers() - headers["X-Forwarded-Host"] = request.host - headers["X-Forwarded-Proto"] = request.scheme + headers["X-Forwarded-Host"] = parsed_api.netloc or "127.0.0.1:5000" + headers["X-Forwarded-Proto"] = parsed_api.scheme or "http" headers["X-Forwarded-For"] = request.remote_addr or "127.0.0.1" try: diff --git a/app/version.py b/app/version.py index 950456f..4a04f44 100644 --- a/app/version.py +++ b/app/version.py @@ -1,7 +1,7 @@ """Central location for the application version string.""" from __future__ import annotations -APP_VERSION = "0.1.2" +APP_VERSION = "0.1.3" def get_version() -> str: diff --git a/static/css/main.css b/static/css/main.css index e603255..a75838a 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -396,12 +396,25 @@ code { .preview-card { top: 1rem; } .preview-stage { - min-height: 260px; background-color: var(--myfsio-preview-bg); overflow: hidden; border-color: var(--myfsio-card-border) !important; } +.preview-stage:has(#preview-placeholder:not(.d-none)) { + min-height: 0; +} + +.preview-stage:has(#preview-image:not(.d-none)), +.preview-stage:has(#preview-video:not(.d-none)), +.preview-stage:has(#preview-iframe:not(.d-none)) { + min-height: 200px; +} + +#preview-placeholder { + padding: 2rem 1rem; +} + .upload-progress-stack { display: flex; flex-direction: column; @@ -928,6 +941,19 @@ pre code { background-color: var(--myfsio-hover-bg) !important; } +.folder-row { + background-color: var(--myfsio-section-bg); + transition: background-color 0.15s ease; +} + +.folder-row:hover { + background-color: var(--myfsio-hover-bg) !important; +} + +.folder-row td:first-child { + padding-left: 0.5rem; +} + .btn-group-sm .btn { padding: 0.25rem 0.6rem; font-size: 0.875rem; diff --git a/templates/bucket_detail.html b/templates/bucket_detail.html index 47f95f4..4e2571e 100644 --- a/templates/bucket_detail.html +++ b/templates/bucket_detail.html @@ -84,7 +84,7 @@ Upload - + + +
-
- +
+
No preview available
- Object preview - - + Object preview + +
@@ -1022,18 +1035,18 @@ > @@ -1443,6 +1489,277 @@ if (generatePresignButton) generatePresignButton.disabled = true; if (downloadButton) downloadButton.classList.add('disabled'); + // ========== Folder Navigation ========== + const folderBreadcrumb = document.getElementById('folder-breadcrumb'); + const objectsTableBody = document.querySelector('#objects-table tbody'); + let currentPrefix = ''; + let allObjects = []; // Store all object data for folder navigation + + // Collect all object data from the table rows + rows.forEach(row => { + allObjects.push({ + key: row.dataset.key, + size: row.dataset.size, + lastModified: row.dataset.lastModified, + etag: row.dataset.etag, + previewUrl: row.dataset.previewUrl, + downloadUrl: row.dataset.downloadUrl, + presignEndpoint: row.dataset.presignEndpoint, + deleteEndpoint: row.dataset.deleteEndpoint, + metadata: row.dataset.metadata, + versionsEndpoint: row.dataset.versionsEndpoint, + restoreTemplate: row.dataset.restoreTemplate, + element: row + }); + }); + + // Check if we have any prefixed objects (folders) + const hasFolders = allObjects.some(obj => obj.key.includes('/')); + + // Get unique folder prefixes at a given level + const getFoldersAtPrefix = (prefix) => { + const folders = new Set(); + const files = []; + + allObjects.forEach(obj => { + const key = obj.key; + if (!key.startsWith(prefix)) return; + + const remainder = key.slice(prefix.length); + const slashIndex = remainder.indexOf('/'); + + if (slashIndex === -1) { + // This is a file at this level + files.push(obj); + } else { + // This is a folder + const folderName = remainder.slice(0, slashIndex + 1); + folders.add(prefix + folderName); + } + }); + + return { folders: Array.from(folders).sort(), files }; + }; + + // Count objects in a folder + const countObjectsInFolder = (folderPrefix) => { + return allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length; + }; + + // Render breadcrumb + const renderBreadcrumb = (prefix) => { + if (!folderBreadcrumb) return; + + if (!prefix && !hasFolders) { + folderBreadcrumb.classList.add('d-none'); + return; + } + + folderBreadcrumb.classList.remove('d-none'); + const ol = folderBreadcrumb.querySelector('ol'); + ol.innerHTML = ''; + + // Root item + const rootLi = document.createElement('li'); + rootLi.className = 'breadcrumb-item'; + if (!prefix) { + rootLi.classList.add('active'); + rootLi.setAttribute('aria-current', 'page'); + rootLi.innerHTML = ` + + + + Root + `; + } else { + rootLi.innerHTML = ` + + + + + Root + + `; + } + ol.appendChild(rootLi); + + // Build path segments + if (prefix) { + const parts = prefix.split('/').filter(Boolean); + let accumulated = ''; + parts.forEach((part, index) => { + accumulated += part + '/'; + const li = document.createElement('li'); + li.className = 'breadcrumb-item'; + + if (index === parts.length - 1) { + li.classList.add('active'); + li.setAttribute('aria-current', 'page'); + li.textContent = part; + } else { + const a = document.createElement('a'); + a.href = '#'; + a.className = 'text-decoration-none'; + a.dataset.folderNav = accumulated; + a.textContent = part; + li.appendChild(a); + } + ol.appendChild(li); + }); + } + + // Add click handlers + ol.querySelectorAll('[data-folder-nav]').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + navigateToFolder(link.dataset.folderNav); + }); + }); + }; + + // Get all objects inside a folder (for bulk selection) + const getObjectsInFolder = (folderPrefix) => { + return allObjects.filter(obj => obj.key.startsWith(folderPrefix)); + }; + + // Create folder row element + const createFolderRow = (folderPath) => { + const folderName = folderPath.slice(currentPrefix.length).replace(/\/$/, ''); + const objectCount = countObjectsInFolder(folderPath); + + const tr = document.createElement('tr'); + tr.className = 'folder-row'; + tr.dataset.folderPath = folderPath; + tr.style.cursor = 'pointer'; + + tr.innerHTML = ` + + + + +
+ + + + ${escapeHtml(folderName)}/ +
+
${objectCount} object${objectCount !== 1 ? 's' : ''}
+ + + + + + + + `; + + // Handle folder checkbox + const checkbox = tr.querySelector('[data-folder-select]'); + checkbox?.addEventListener('change', (e) => { + e.stopPropagation(); + const folderObjects = getObjectsInFolder(folderPath); + folderObjects.forEach(obj => { + const objCheckbox = obj.element.querySelector('[data-object-select]'); + if (objCheckbox) { + objCheckbox.checked = checkbox.checked; + } + toggleRowSelection(obj.element, checkbox.checked); + }); + }); + + tr.addEventListener('click', (e) => { + if (e.target.closest('[data-folder-select]') || e.target.closest('button')) return; + navigateToFolder(folderPath); + }); + + return tr; + }; + + // Navigate to a folder + const navigateToFolder = (prefix) => { + currentPrefix = prefix; + renderBreadcrumb(prefix); + renderObjectsView(); + // Clear selection when navigating + selectedRows.clear(); + // Defer updateBulkDeleteState call to ensure it's defined + if (typeof updateBulkDeleteState === 'function') { + updateBulkDeleteState(); + } + // Clear preview + if (previewPanel) previewPanel.classList.add('d-none'); + if (previewEmpty) previewEmpty.classList.remove('d-none'); + activeRow = null; + }; + + // Render objects view based on current prefix + const renderObjectsView = () => { + if (!objectsTableBody) return; + + const { folders, files } = getFoldersAtPrefix(currentPrefix); + + // Clear table + objectsTableBody.innerHTML = ''; + + // Add folder rows first + folders.forEach(folderPath => { + objectsTableBody.appendChild(createFolderRow(folderPath)); + }); + + // Add file rows + files.forEach(obj => { + objectsTableBody.appendChild(obj.element); + obj.element.style.display = ''; + // Update displayed key to show just filename when inside a folder + const keyCell = obj.element.querySelector('.object-key .fw-medium'); + if (keyCell && currentPrefix) { + const displayName = obj.key.slice(currentPrefix.length); + keyCell.textContent = displayName; + keyCell.closest('.object-key').title = obj.key; // Full path in tooltip + } else if (keyCell) { + keyCell.textContent = obj.key; // Reset to full key at root + } + }); + + // Hide files not in current view + allObjects.forEach(obj => { + if (!files.includes(obj)) { + obj.element.style.display = 'none'; + } + }); + + // Show empty state if no content + if (folders.length === 0 && files.length === 0) { + const emptyRow = document.createElement('tr'); + emptyRow.innerHTML = ` + +
+
+ + + +
+
Empty folder
+

This folder contains no objects.

+
+ + `; + objectsTableBody.appendChild(emptyRow); + } + + // Update select all checkbox state - deferred to ensure function is defined + if (typeof updateBulkDeleteState === 'function') { + updateBulkDeleteState(); + } + }; + + // Folder view initialization moved after updateBulkDeleteState is defined + // ========== End Folder Navigation ========== + const showMessage = ({ title = 'Notice', body = '', bodyHtml = null, variant = 'info', actionText = null, onAction = null }) => { if (!messageModal) { window.alert(body || title); @@ -1589,10 +1906,15 @@ bulkDeleteConfirm.disabled = selectedCount === 0 || bulkDeleting; } if (selectAllCheckbox) { - const total = rows.length; + // Count only visible rows in current folder view + const visibleRows = hasFolders + ? allObjects.filter(obj => obj.key.startsWith(currentPrefix) && !obj.key.slice(currentPrefix.length).includes('/')).map(obj => obj.element) + : Array.from(rows); + const total = visibleRows.filter(r => r.style.display !== 'none').length; + const visibleSelectedCount = visibleRows.filter(r => r.style.display !== 'none' && selectedRows.has(r.dataset.key)).length; selectAllCheckbox.disabled = total === 0; - selectAllCheckbox.checked = selectedCount > 0 && selectedCount === total && total > 0; - selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < total; + selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0; + selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total; } }; @@ -2206,15 +2528,49 @@ updateBulkDeleteState(); + // Initialize folder view if there are folders (must be after updateBulkDeleteState is defined) + if (hasFolders) { + renderBreadcrumb(''); + renderObjectsView(); + } + bulkDeleteButton?.addEventListener('click', () => openBulkDeleteModal()); bulkDeleteConfirm?.addEventListener('click', () => performBulkDelete()); document.getElementById('object-search')?.addEventListener('input', (event) => { const term = event.target.value.toLowerCase(); - rows.forEach((row) => { - const key = row.dataset.key.toLowerCase(); - row.style.display = key.includes(term) ? '' : 'none'; - }); + + if (hasFolders) { + // With folder navigation: re-render view with search filter + const { folders, files } = getFoldersAtPrefix(currentPrefix); + const tbody = objectsTableBody; + + // Clear and re-add matching content + tbody.innerHTML = ''; + + // Filter and add matching folders + folders.forEach(folderPath => { + const folderName = folderPath.slice(currentPrefix.length).replace(/\/$/, '').toLowerCase(); + if (folderName.includes(term)) { + tbody.appendChild(createFolderRow(folderPath)); + } + }); + + // Filter and add matching files + files.forEach(obj => { + const keyName = obj.key.slice(currentPrefix.length).toLowerCase(); + if (keyName.includes(term)) { + tbody.appendChild(obj.element); + obj.element.style.display = ''; + } + }); + } else { + // Original behavior without folders + rows.forEach((row) => { + const key = row.dataset.key.toLowerCase(); + row.style.display = key.includes(term) ? '' : 'none'; + }); + } }); refreshVersionsButton?.addEventListener('click', () => { @@ -2263,20 +2619,235 @@ }); if (uploadForm && uploadFileInput) { + const uploadSubmitBtn = document.getElementById('uploadSubmitBtn'); + const uploadCancelBtn = document.getElementById('uploadCancelBtn'); + const uploadBtnText = document.getElementById('uploadBtnText'); + const bulkUploadProgress = document.getElementById('bulkUploadProgress'); + const bulkUploadStatus = document.getElementById('bulkUploadStatus'); + const bulkUploadCounter = document.getElementById('bulkUploadCounter'); + const bulkUploadProgressBar = document.getElementById('bulkUploadProgressBar'); + const bulkUploadCurrentFile = document.getElementById('bulkUploadCurrentFile'); + const bulkUploadResults = document.getElementById('bulkUploadResults'); + const bulkUploadSuccessAlert = document.getElementById('bulkUploadSuccessAlert'); + const bulkUploadErrorAlert = document.getElementById('bulkUploadErrorAlert'); + const bulkUploadSuccessCount = document.getElementById('bulkUploadSuccessCount'); + const bulkUploadErrorCount = document.getElementById('bulkUploadErrorCount'); + const bulkUploadErrorList = document.getElementById('bulkUploadErrorList'); + const uploadKeyPrefix = document.getElementById('uploadKeyPrefix'); + const singleFileOptions = document.getElementById('singleFileOptions'); + let isUploading = false; + const refreshUploadDropLabel = () => { if (!uploadDropZoneLabel) return; const files = uploadFileInput.files; if (!files || files.length === 0) { uploadDropZoneLabel.textContent = 'No file selected'; + if (singleFileOptions) singleFileOptions.classList.remove('d-none'); return; } uploadDropZoneLabel.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`; + // Hide single file object key option when multiple files selected + if (singleFileOptions) { + singleFileOptions.classList.toggle('d-none', files.length > 1); + } + }; + + const updateUploadBtnText = () => { + if (!uploadBtnText) return; + const files = uploadFileInput.files; + if (!files || files.length <= 1) { + uploadBtnText.textContent = 'Upload'; + } else { + uploadBtnText.textContent = `Upload ${files.length} files`; + } + }; + + const resetUploadUI = () => { + if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none'); + if (bulkUploadResults) bulkUploadResults.classList.add('d-none'); + if (bulkUploadSuccessAlert) bulkUploadSuccessAlert.classList.remove('d-none'); + if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.add('d-none'); + if (bulkUploadErrorList) bulkUploadErrorList.innerHTML = ''; + if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; + if (uploadFileInput) uploadFileInput.disabled = false; + isUploading = false; + }; + + const uploadSingleFile = async (file, keyPrefix = '', metadata = null) => { + const formData = new FormData(); + formData.append('object', file); + const objectKey = keyPrefix ? `${keyPrefix}${file.name}` : file.name; + formData.append('object_key', objectKey); + if (metadata) { + formData.append('metadata', JSON.stringify(metadata)); + } + // Get CSRF token + const csrfToken = document.querySelector('input[name="csrf_token"]')?.value; + if (csrfToken) { + formData.append('csrf_token', csrfToken); + } + + const response = await fetch(uploadForm.action, { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok || data.status === 'error') { + throw new Error(data.message || 'Upload failed'); + } + return data; + }; + + const performBulkUpload = async (files) => { + if (isUploading || !files || files.length === 0) return; + + isUploading = true; + const keyPrefix = (uploadKeyPrefix?.value || '').trim(); + const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim(); + let metadata = null; + if (metadataRaw) { + try { + metadata = JSON.parse(metadataRaw); + } catch { + showMessage({ title: 'Invalid metadata', body: 'Metadata must be valid JSON.', variant: 'danger' }); + resetUploadUI(); + return; + } + } + + // Show progress UI + if (bulkUploadProgress) bulkUploadProgress.classList.remove('d-none'); + if (bulkUploadResults) bulkUploadResults.classList.add('d-none'); + if (uploadSubmitBtn) uploadSubmitBtn.disabled = true; + if (uploadFileInput) uploadFileInput.disabled = true; + + const successFiles = []; + const errorFiles = []; + const total = files.length; + + for (let i = 0; i < total; i++) { + const file = files[i]; + const current = i + 1; + + // Update progress + if (bulkUploadCounter) bulkUploadCounter.textContent = `${current}/${total}`; + if (bulkUploadCurrentFile) bulkUploadCurrentFile.textContent = `Uploading: ${file.name}`; + if (bulkUploadProgressBar) { + const percent = Math.round((current / total) * 100); + bulkUploadProgressBar.style.width = `${percent}%`; + } + + try { + await uploadSingleFile(file, keyPrefix, metadata); + successFiles.push(file.name); + } catch (error) { + errorFiles.push({ name: file.name, error: error.message || 'Unknown error' }); + } + } + + // Show results + if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none'); + if (bulkUploadResults) bulkUploadResults.classList.remove('d-none'); + + if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = successFiles.length; + if (successFiles.length === 0 && bulkUploadSuccessAlert) { + bulkUploadSuccessAlert.classList.add('d-none'); + } + + if (errorFiles.length > 0) { + if (bulkUploadErrorCount) bulkUploadErrorCount.textContent = errorFiles.length; + if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.remove('d-none'); + if (bulkUploadErrorList) { + bulkUploadErrorList.innerHTML = errorFiles + .map(f => `
  • ${escapeHtml(f.name)}: ${escapeHtml(f.error)}
  • `) + .join(''); + } + } + + isUploading = false; + + // Reload page if any files were uploaded successfully + if (successFiles.length > 0) { + // Keep button disabled and show uploading state until reload + if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...'; + // Short delay to show results, then reload + window.setTimeout(() => window.location.reload(), 800); + } else { + // Only re-enable if no success (all failed) + if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; + if (uploadFileInput) uploadFileInput.disabled = false; + } }; refreshUploadDropLabel(); - uploadFileInput.addEventListener('change', refreshUploadDropLabel); + uploadFileInput.addEventListener('change', () => { + refreshUploadDropLabel(); + updateUploadBtnText(); + resetUploadUI(); + }); uploadDropZone?.addEventListener('click', () => uploadFileInput?.click()); + // Handle form submission for bulk upload + uploadForm.addEventListener('submit', async (event) => { + const files = uploadFileInput.files; + if (!files || files.length === 0) return; + + const keyPrefix = (uploadKeyPrefix?.value || '').trim(); + + // For single file with custom object key and NO prefix, use default form submission + if (files.length === 1 && !keyPrefix) { + const customKey = uploadForm.querySelector('input[name="object_key"]')?.value?.trim(); + if (customKey) { + // Single file with custom key - let form submit normally + // Disable button immediately for feedback + if (uploadSubmitBtn) { + uploadSubmitBtn.disabled = true; + if (uploadBtnText) uploadBtnText.textContent = 'Uploading...'; + } + return; + } + } + + // Bulk upload or prefix specified - handle with JavaScript + event.preventDefault(); + + // Disable button immediately + if (uploadSubmitBtn) { + uploadSubmitBtn.disabled = true; + if (uploadBtnText) uploadBtnText.textContent = 'Uploading...'; + } + + await performBulkUpload(Array.from(files)); + }); + + // Pre-fill key prefix with current folder when modal opens + uploadModalEl?.addEventListener('show.bs.modal', () => { + if (hasFolders && currentPrefix) { + uploadKeyPrefix.value = currentPrefix; + // Auto-expand advanced options if there's a prefix + const advancedToggle = document.querySelector('[data-bs-target="#advancedUploadOptions"]'); + const advancedCollapse = document.getElementById('advancedUploadOptions'); + if (advancedToggle && advancedCollapse && !advancedCollapse.classList.contains('show')) { + new bootstrap.Collapse(advancedCollapse, { show: true }); + } + } else if (uploadKeyPrefix) { + // Clear prefix when at root + uploadKeyPrefix.value = ''; + } + }); + + // Reset UI when modal is closed + uploadModalEl?.addEventListener('hidden.bs.modal', () => { + resetUploadUI(); + uploadFileInput.value = ''; + refreshUploadDropLabel(); + updateUploadBtnText(); + }); + const preventDefaults = (event) => { event.preventDefault(); event.stopPropagation(); @@ -2344,14 +2915,34 @@ selectAllCheckbox?.addEventListener('change', (event) => { const shouldSelect = Boolean(event.target?.checked); - rows.forEach((row) => { - const checkbox = row.querySelector('[data-object-select]'); - if (!checkbox || checkbox.disabled) { - return; - } - checkbox.checked = shouldSelect; - toggleRowSelection(row, shouldSelect); - }); + + if (hasFolders) { + // Select all objects that start with current prefix (including inside subfolders) + const objectsInCurrentView = allObjects.filter(obj => obj.key.startsWith(currentPrefix)); + objectsInCurrentView.forEach(obj => { + const checkbox = obj.element.querySelector('[data-object-select]'); + if (checkbox && !checkbox.disabled) { + checkbox.checked = shouldSelect; + } + toggleRowSelection(obj.element, shouldSelect); + }); + + // Also toggle folder checkboxes + document.querySelectorAll('[data-folder-select]').forEach(cb => { + cb.checked = shouldSelect; + }); + } else { + // Original behavior without folders + rows.forEach((row) => { + if (row.style.display === 'none') return; + const checkbox = row.querySelector('[data-object-select]'); + if (!checkbox || checkbox.disabled) { + return; + } + checkbox.checked = shouldSelect; + toggleRowSelection(row, shouldSelect); + }); + } setTimeout(updateBulkDownloadState, 0); });