feat: add options to remember web upload settings & rename ebooks to {title} - {author} (#2534)

This commit is contained in:
Víctor Fernández
2026-07-15 14:43:42 -04:00
committed by GitHub
parent f3cad2f9e1
commit 35a45f9c1e
2 changed files with 255 additions and 48 deletions
+11 -13
View File
@@ -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;
+243 -34
View File
@@ -1579,6 +1579,19 @@
<div id="convertInfo" style="display:none; margin-bottom: 10px; font-size: 0.9em;">
</div>
<div class="advanced-setting-row">
<div class="setting-label">
<div class="setting-title">Rename from Book Metadata</div>
<div class="setting-desc">Use Title - Author.epub when available</div>
</div>
<div class="setting-controls">
<label class="toggle-switch">
<input type="checkbox" id="renameFromMetadataToggle" onchange="updateUploadSettingsPersistence()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="convert-settings" id="convertSettings" style="display:block;">
<span>⚫ True-Grayscale</span>
<span id="convertSizeSummary">📏 Max 480×800px</span>
@@ -1675,7 +1688,19 @@
</div>
<div class="setting-controls">
<label class="toggle-switch">
<input type="checkbox" id="export-log-checkbox">
<input type="checkbox" id="export-log-checkbox" onchange="updateUploadSettingsPersistence()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="advanced-setting-row">
<div class="setting-label">
<div class="setting-title">Remember Settings</div>
<div class="setting-desc">Store these upload options in this browser</div>
</div>
<div class="setting-controls">
<label class="toggle-switch">
<input type="checkbox" id="rememberUploadSettings" onchange="updateUploadSettingsPersistence()">
<span class="toggle-slider"></span>
</label>
</div>
@@ -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
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 = '';