From 35a45f9c1edfc07a3ef25d9ed676b3a520f410b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez?= Date: Wed, 15 Jul 2026 20:43:42 +0200 Subject: [PATCH] feat: add options to remember web upload settings & rename ebooks to `{title} - {author}` (#2534) --- src/network/CrossPointWebServer.cpp | 24 ++- src/network/html/FilesPage.html | 279 ++++++++++++++++++++++++---- 2 files changed, 255 insertions(+), 48 deletions(-) diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index fbee83da..5624b76a 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -671,17 +671,15 @@ void CrossPointWebServer::handleUpload(UploadState& state) const { LOG_DBG("WEB", "[UPLOAD] START: %s to path: %s", state.fileName.c_str(), state.path.c_str()); LOG_DBG("WEB", "[UPLOAD] Free heap: %d bytes", ESP.getFreeHeap()); - // Create file path String filePath = state.path; if (!filePath.endsWith("/")) filePath += "/"; filePath += state.fileName; - // Check if file already exists - SD operations can be slow esp_task_wdt_reset(); if (Storage.exists(filePath.c_str())) { - LOG_DBG("WEB", "[UPLOAD] Overwriting existing file: %s", filePath.c_str()); - esp_task_wdt_reset(); - Storage.remove(filePath.c_str()); + state.error = "File already exists: " + state.fileName; + LOG_DBG("WEB", "[UPLOAD] Collision: %s", filePath.c_str()); + return; } // Open file for writing - this can be slow due to FAT cluster allocation @@ -749,7 +747,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const { LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime, writePercent); - // Clear epub cache to prevent stale metadata issues when overwriting files + // Clear epub cache after uploading the file String filePath = state.path; if (!filePath.endsWith("/")) filePath += "/"; filePath += state.fileName; @@ -1624,20 +1622,20 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1); } - // Build file path String filePath = wsUploadPath; if (!filePath.endsWith("/")) filePath += "/"; filePath += wsUploadFileName; - LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize, - filePath.c_str()); - - // Check if file exists and remove it esp_task_wdt_reset(); if (Storage.exists(filePath.c_str())) { - Storage.remove(filePath.c_str()); + LOG_DBG("WS", "Upload collision: %s", filePath.c_str()); + wsServer->sendTXT(num, "ERROR:File already exists: " + wsUploadFileName); + return; } + LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize, + filePath.c_str()); + // Open file for writing esp_task_wdt_reset(); if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) { @@ -1721,7 +1719,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* LOG_DBG("WS", "Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)", wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); - // Clear epub cache to prevent stale metadata issues when overwriting files + // Clear epub cache after uploading the file String filePath = wsUploadPath; if (!filePath.endsWith("/")) filePath += "/"; filePath += wsUploadFileName; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 23881a7e..5f2ceba3 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -1579,6 +1579,19 @@ +
+
+
Rename from Book Metadata
+
Use Title - Author.epub when available
+
+
+ +
+
+
⚫ True-Grayscale 📏 Max 480×800px @@ -1675,7 +1688,19 @@
+
+ +
+
+
Remember Settings
+
Store these upload options in this browser
+
+
+
@@ -2085,12 +2110,8 @@ // Modal functions function openUploadModal() { - // Reset converter variables to defaults - ENABLE_GRAYSCALE = true; - JPEG_QUALITY = 85; - HANDEDNESS = 'right'; - OVERLAP_PERCENT = 5; imageStates = {}; + restoreUploadSettingsFromStorage(); // Hide convert options when opening modal (no files selected initially) const convertOptions = document.getElementById('convertOptions'); @@ -2098,10 +2119,6 @@ convertOptions.style.display = 'none'; } - // Reset rotation and overlap UI - setHandedness('right'); - setOverlap(5); - // Hide log section from previous session const logSection = document.getElementById('log-section'); if (logSection) logSection.classList.remove('visible'); @@ -2160,7 +2177,8 @@ document.getElementById('progress-container').style.display = 'none'; document.getElementById('progress-fill').style.width = '0%'; document.getElementById('progress-fill').style.backgroundColor = '#27ae60'; - document.getElementById('convertBeforeUpload').checked = false; + const convertOptions = document.getElementById('convertOptions'); + if (convertOptions) convertOptions.style.display = 'none'; document.getElementById('convertInfo').style.display = 'none'; document.getElementById('convertWarning').style.display = 'none'; // Clear image picker cache and reset layout @@ -2183,15 +2201,7 @@ advancedOptionsToggle.style.opacity = '0.5'; advancedOptionsToggle.style.pointerEvents = 'none'; } - // Reset to defaults - document.getElementById('qualitySlider').value = 85; - document.getElementById('qualityInput').value = 85; - const autoCropToggle = document.getElementById('autoCropToggle'); - if (autoCropToggle) autoCropToggle.checked = false; - setHandedness('right'); - setOverlap(5); - // Update converter variables - updateQualitySettings(); + applyUploadSettings(); } function updateBatchModeUI(isBatch) { @@ -2228,6 +2238,7 @@ advancedOptionsToggle.style.opacity = checked ? '1' : '0.5'; advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none'; } + updateUploadSettingsPersistence(); } function toggleAdvancedOptions() { @@ -2265,12 +2276,8 @@ function setQualityPreset(value) { document.getElementById('qualitySlider').value = value; document.getElementById('qualityInput').value = value; - // Update active preset document.querySelectorAll('.quality-preset').forEach(btn => { - btn.classList.remove('active'); - if (parseInt(btn.dataset.value, 10) === value) { - btn.classList.add('active'); - } + btn.classList.toggle('active', parseInt(btn.dataset.value, 10) === value); }); updateQualitySettings(); } @@ -2292,6 +2299,7 @@ if (isImagePickerVisible()) { renderImageGrid(); } + updateUploadSettingsPersistence(); } function setHandedness(value) { @@ -2304,6 +2312,7 @@ if (isImagePickerVisible()) { renderImageGrid(); } + updateUploadSettingsPersistence(); } function setOverlap(value) { @@ -2312,6 +2321,7 @@ document.querySelectorAll('.overlap-btn').forEach(btn => { btn.classList.toggle('active', parseInt(btn.dataset.value) === value); }); + updateUploadSettingsPersistence(); } function isImagePickerVisible() { @@ -2980,7 +2990,12 @@ if (qualitySlider && qualityInput) { // Initialize converter variables with UI default values - updateQualitySettings(); + suppressUploadSettingsSave = true; + try { + updateQualitySettings(); + } finally { + suppressUploadSettingsSave = false; + } // Deselect all presets when slider is manually changed const deselectPresets = function() { @@ -3213,15 +3228,13 @@ const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub')); if (files.length > 0 && hasEpub) { convertOptions.style.display = 'block'; + toggleConvertOptions(); } else { convertOptions.style.display = 'none'; - // Clear stale checkbox state so the "Optimize & Upload" button doesn't linger - // when the user re-picks a non-EPUB after having ticked Optimize for an EPUB. - const cb = document.getElementById('convertBeforeUpload'); - if (cb && cb.checked) { - cb.checked = false; - toggleConvertOptions(); - } + uploadBtn.textContent = 'Upload'; + uploadBtn.classList.remove('optimize'); + document.getElementById('convertInfo').style.display = 'none'; + document.getElementById('convertWarning').style.display = 'none'; if (files.length === 0) clearImagePicker(); } @@ -3303,6 +3316,75 @@ let ENABLE_GRAYSCALE = DEFAULT_ENABLE_GRAYSCALE; let ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP; let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed) let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%) +const UPLOAD_SETTINGS_STORAGE_KEY = 'crosspoint.files.uploadSettings.v1'; +const DEFAULT_UPLOAD_SETTINGS = Object.freeze({ + convertBeforeUpload: false, + renameFromMetadata: false, + quality: DEFAULT_JPEG_QUALITY, + autoCrop: DEFAULT_ENABLE_AUTO_CROP, + deviceTarget: 'auto', + handedness: 'right', + overlap: 5, + exportLog: false +}); +let suppressUploadSettingsSave = false; + +function getCurrentUploadSettings() { + return { + convertBeforeUpload: !!document.getElementById('convertBeforeUpload')?.checked, + renameFromMetadata: !!document.getElementById('renameFromMetadataToggle')?.checked, + quality: parseInt(document.getElementById('qualitySlider')?.value || JPEG_QUALITY, 10), + autoCrop: !!document.getElementById('autoCropToggle')?.checked, + deviceTarget: DEVICE_TARGET, + handedness: HANDEDNESS, + overlap: OVERLAP_PERCENT, + exportLog: !!document.getElementById('export-log-checkbox')?.checked + }; +} + +function applyUploadSettings(settings = {}) { + const merged = { ...DEFAULT_UPLOAD_SETTINGS, ...settings }; + suppressUploadSettingsSave = true; + try { + document.getElementById('convertBeforeUpload').checked = !!merged.convertBeforeUpload; + document.getElementById('renameFromMetadataToggle').checked = !!merged.renameFromMetadata; + document.getElementById('autoCropToggle').checked = !!merged.autoCrop; + document.getElementById('export-log-checkbox').checked = !!merged.exportLog; + document.getElementById('rememberUploadSettings').checked = !!settings.rememberSettings; + + setQualityPreset(Math.max(1, Math.min(95, parseInt(merged.quality, 10) || DEFAULT_JPEG_QUALITY))); + setDeviceTarget(['auto', 'X3', 'X4'].includes(merged.deviceTarget) ? merged.deviceTarget : 'auto'); + setHandedness(merged.handedness === 'left' ? 'left' : 'right'); + setOverlap([5, 10, 15].includes(Number(merged.overlap)) ? Number(merged.overlap) : 5); + toggleConvertOptions(); + } finally { + suppressUploadSettingsSave = false; + } +} + +function restoreUploadSettingsFromStorage() { + try { + const saved = JSON.parse(localStorage.getItem(UPLOAD_SETTINGS_STORAGE_KEY) || 'null'); + applyUploadSettings(saved?.rememberSettings ? saved : undefined); + } catch (e) { + console.warn('Could not read remembered upload settings:', e); + applyUploadSettings(); + } +} + +function updateUploadSettingsPersistence() { + if (suppressUploadSettingsSave) return; + try { + if (!document.getElementById('rememberUploadSettings')?.checked) { + localStorage.removeItem(UPLOAD_SETTINGS_STORAGE_KEY); + return; + } + const settings = { ...getCurrentUploadSettings(), rememberSettings: true }; + localStorage.setItem(UPLOAD_SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + } catch (e) { + console.warn('Could not save upload settings:', e); + } +} // ============================================================================ // Image Picker State Management @@ -3380,6 +3462,7 @@ function applyDeviceTarget() { function setDeviceTarget(value) { DEVICE_TARGET = value; applyDeviceTarget(); + updateUploadSettingsPersistence(); } // Batch logging system for multiple files @@ -3788,6 +3871,104 @@ async function findOPFPath(zip) { return fallback; } +function sanitizeMetadataFilenamePart(value) { + const text = String(value || '').replace(/\s+/g, ' ').trim(); + return (text.normalize ? text.normalize('NFC') : text) + .replace(/[<>:"/\\|?*\x00-\x1F]/g, ' ') + .replace(/\s+/g, ' ') + .replace(/^[. ]+/g, '') + .replace(/[. ]+$/g, '') + .trim(); +} + +function buildMetadataFilename(title, author) { + title = sanitizeMetadataFilenamePart(title); + author = sanitizeMetadataFilenamePart(author); + if (!title) return ''; + let base = author ? `${title} - ${author}` : title; + if (base.length > 180) base = base.substring(0, 180).replace(/\s+\S*$/g, '').trim() || base.substring(0, 180).trim(); + return `${base}.epub`; +} + +async function getMetadataFilenameForEpub(file) { + if (typeof JSZip === 'undefined') return ''; + const zip = await JSZip.loadAsync(file); + const opfPath = await findOPFPath(zip); + if (!opfPath || !zip.files[opfPath]) return ''; + + const doc = new DOMParser().parseFromString(await safeReadText(zip.files[opfPath]), 'application/xml'); + if (doc.querySelector('parsererror')) return ''; + + const title = doc.getElementsByTagNameNS('*', 'title')[0]?.textContent || ''; + const creators = Array.from(doc.getElementsByTagNameNS('*', 'creator')); + const getCreatorRole = el => ( + el.getAttribute('role') || + el.getAttribute('opf:role') || + el.getAttributeNS('http://www.idpf.org/2007/opf', 'role') || + '' + ).toLowerCase(); + const authorEl = creators.find(el => getCreatorRole(el) === 'aut') || + creators.find(el => !getCreatorRole(el)); + const authorText = authorEl?.textContent?.trim(); + const author = authorText || + authorEl?.getAttribute('file-as') || + authorEl?.getAttribute('opf:file-as') || + authorEl?.getAttributeNS('http://www.idpf.org/2007/opf', 'file-as') || + ''; + return buildMetadataFilename(title, author); +} + +async function maybeRenameEbookFile(file) { + const renameToggle = document.getElementById('renameFromMetadataToggle'); + if (!renameToggle || !renameToggle.checked || !file.name.toLowerCase().endsWith('.epub')) { + return file; + } + + try { + const metadataName = await getMetadataFilenameForEpub(file); + if (!metadataName || metadataName === file.name) return file; + return new File([file], metadataName, { + type: file.type || 'application/epub+zip', + lastModified: file.lastModified + }); + } catch (e) { + console.warn('Could not rename EPUB from metadata:', e); + return file; + } +} + +function reserveAvailableUploadFilename(fileName, usedFileNames) { + const normalize = name => name.toLowerCase(); + if (!usedFileNames.has(normalize(fileName))) { + usedFileNames.add(normalize(fileName)); + return fileName; + } + + const dotIndex = fileName.lastIndexOf('.'); + const extensionIndex = dotIndex > 0 ? dotIndex : fileName.length; + const baseName = fileName.substring(0, extensionIndex); + const extension = fileName.substring(extensionIndex); + let nextSuffix = 2; + + let candidateName; + do { + candidateName = `${baseName} (${nextSuffix})${extension}`; + nextSuffix++; + } while (usedFileNames.has(normalize(candidateName))); + + usedFileNames.add(normalize(candidateName)); + return candidateName; +} + +async function fetchExistingUploadNames() { + const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now()); + if (!response.ok) { + throw new Error(response.status + ' ' + response.statusText); + } + const entries = await response.json(); + return new Set(entries.map(entry => entry.name.toLowerCase())); +} + /** * Resolve a relative href against a base file path. * Handles multiple ../, ./, absolute /, and bare relative paths. @@ -5219,7 +5400,7 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) { }); } -function uploadFile() { +async function uploadFile() { if (isUploadInProgress) return; const fileInput = document.getElementById('fileInput'); @@ -5231,8 +5412,17 @@ function uploadFile() { return; } - // Prevent modal close during upload isUploadInProgress = true; + let usedFileNames; + try { + usedFileNames = await fetchExistingUploadNames(); + } catch (error) { + isUploadInProgress = false; + alert('Failed to check existing files: ' + error.message); + return; + } + + // Prevent modal close during upload uploadGeneration++; const myGeneration = uploadGeneration; document.getElementById('uploadModalClose').classList.add('disabled'); @@ -5323,6 +5513,25 @@ function uploadFile() { let convOriginalSize = 0; // Picked-file size; 0 unless conversion succeeded let convNewSize = 0; // Generated blob size; 0 unless conversion succeeded + if (isEpub && document.getElementById('renameFromMetadataToggle').checked) { + const originalName = file.name; + progressText.style.color = ''; + progressText.textContent = `Reading metadata for ${file.name} (${currentIndex + 1}/${files.length})...`; + file = await maybeRenameEbookFile(file); + if (file.name !== originalName) { + console.log(`[Upload] Renamed from metadata: ${originalName} -> ${file.name}`); + } + } + + const availableName = reserveAvailableUploadFilename(file.name, usedFileNames); + if (availableName !== file.name) { + console.log(`[Upload] Renamed to avoid collision: ${file.name} -> ${availableName}`); + file = new File([file], availableName, { + type: file.type, + lastModified: file.lastModified + }); + } + const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; const stageText = needsConversion ? 'Converting & uploading' : 'Uploading'; progressText.style.color = '';