feat: add options to remember web upload settings & rename ebooks to {title} - {author} (#2534)
This commit is contained in:
@@ -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
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user