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 = '';