Skip to content

Commit a2e77b0

Browse files
committed
feat: Add auto-save, session persistence, and UI improvements
- Add auto-save for settings with 1-second debounce on all form changes - Persist file queue to localStorage and restore on page reload with server verification - Restore active translation state when page reconnects to running job - Move Settings and Activity Log into collapsible panels to declutter UI - Auto-open Settings panel when LLM connection fails - Disable translate button until LLM is connected - Remove manual "Save Settings" button (replaced by auto-save) - Clean up verbose console.log statements across codebase
1 parent d52a770 commit a2e77b0

File tree

10 files changed

+509
-225
lines changed

10 files changed

+509
-225
lines changed

src/api/blueprints/security_routes.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,37 @@ def get_security_info():
193193
"upload_directory": str(secure_file_handler.upload_dir)
194194
})
195195

196+
@bp.route('/api/uploads/verify', methods=['POST'])
197+
def verify_uploaded_files():
198+
"""Verify which uploaded files still exist on the server"""
199+
try:
200+
data = request.json
201+
if not data or 'file_paths' not in data:
202+
return jsonify({"error": "No file paths provided"}), 400
203+
204+
file_paths = data['file_paths']
205+
if not isinstance(file_paths, list):
206+
return jsonify({"error": "Invalid file paths list"}), 400
207+
208+
existing_files = []
209+
missing_files = []
210+
211+
for file_path_str in file_paths:
212+
file_path = Path(file_path_str)
213+
if file_path.exists():
214+
existing_files.append(file_path_str)
215+
else:
216+
missing_files.append(file_path_str)
217+
218+
return jsonify({
219+
"existing": existing_files,
220+
"missing": missing_files
221+
})
222+
223+
except Exception as e:
224+
current_app.logger.error(f"Error verifying uploaded files: {str(e)}")
225+
return jsonify({"error": "Verification failed", "details": str(e)}), 500
226+
196227
@bp.route('/api/thumbnails/<path:filename>', methods=['GET'])
197228
def serve_thumbnail(filename):
198229
"""Serve EPUB cover thumbnail with security validation"""

src/web/static/js/core/api-client.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,18 @@ export const ApiClient = {
237237
});
238238
},
239239

240+
/**
241+
* Verify which uploaded files still exist on the server
242+
* @param {string[]} filePaths - Array of file paths to verify
243+
* @returns {Promise<Object>} Object with existing and missing file paths
244+
*/
245+
async verifyUploadedFiles(filePaths) {
246+
return await apiRequest('/api/uploads/verify', {
247+
method: 'POST',
248+
body: JSON.stringify({ file_paths: filePaths })
249+
});
250+
},
251+
240252
// ========================================
241253
// Model Management
242254
// ========================================
@@ -436,7 +448,3 @@ export const ApiClient = {
436448
}
437449
};
438450

439-
// Make API client available globally for debugging
440-
if (typeof window !== 'undefined') {
441-
window.__API_CLIENT__ = ApiClient;
442-
}

src/web/static/js/core/settings-manager.js

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ const STORAGE_KEY = 'tbl_user_preferences';
1818
*/
1919
let envModelApplied = false;
2020

21+
/**
22+
* Debounce timer for auto-save
23+
*/
24+
let autoSaveTimer = null;
25+
const AUTO_SAVE_DELAY = 1000; // 1 second debounce
26+
27+
/**
28+
* Flag to prevent auto-save during initial load
29+
*/
30+
let isInitializing = true;
31+
2132
/**
2233
* Settings that are saved to localStorage (non-sensitive)
2334
*/
@@ -44,10 +55,82 @@ const ENV_SETTINGS_MAP = {
4455

4556
export const SettingsManager = {
4657
/**
47-
* Initialize settings manager - load saved preferences
58+
* Initialize settings manager - load saved preferences and setup auto-save
4859
*/
4960
initialize() {
5061
this.loadLocalPreferences();
62+
// Setup auto-save listeners after a short delay to avoid triggering during initial load
63+
setTimeout(() => {
64+
this._setupAutoSaveListeners();
65+
isInitializing = false;
66+
}, 500);
67+
},
68+
69+
/**
70+
* Setup event listeners for auto-save on all settings elements
71+
* @private
72+
*/
73+
_setupAutoSaveListeners() {
74+
// Elements that trigger auto-save
75+
const autoSaveElements = [
76+
// Provider and model
77+
{ id: 'llmProvider', event: 'change' },
78+
{ id: 'model', event: 'change' },
79+
// API endpoints
80+
{ id: 'apiEndpoint', event: 'change' },
81+
{ id: 'openaiEndpoint', event: 'change' },
82+
// API keys (save to .env)
83+
{ id: 'geminiApiKey', event: 'change' },
84+
{ id: 'openaiApiKey', event: 'change' },
85+
{ id: 'openrouterApiKey', event: 'change' },
86+
// Languages
87+
{ id: 'sourceLang', event: 'change' },
88+
{ id: 'targetLang', event: 'change' },
89+
{ id: 'customSourceLang', event: 'change' },
90+
{ id: 'customTargetLang', event: 'change' },
91+
// Checkboxes
92+
{ id: 'ttsEnabled', event: 'change' },
93+
{ id: 'textCleanup', event: 'change' },
94+
{ id: 'refineTranslation', event: 'change' }
95+
];
96+
97+
autoSaveElements.forEach(({ id, event }) => {
98+
const element = DomHelpers.getElement(id);
99+
if (element) {
100+
element.addEventListener(event, () => this._triggerAutoSave());
101+
}
102+
});
103+
104+
},
105+
106+
/**
107+
* Trigger auto-save with debounce
108+
* @private
109+
*/
110+
_triggerAutoSave() {
111+
if (isInitializing) return;
112+
113+
// Clear existing timer
114+
if (autoSaveTimer) {
115+
clearTimeout(autoSaveTimer);
116+
}
117+
118+
// Set new timer
119+
autoSaveTimer = setTimeout(async () => {
120+
await this._performAutoSave();
121+
}, AUTO_SAVE_DELAY);
122+
},
123+
124+
/**
125+
* Perform the actual auto-save
126+
* @private
127+
*/
128+
async _performAutoSave() {
129+
try {
130+
await this.saveAllSettings(true);
131+
} catch {
132+
// Auto-save failed silently
133+
}
51134
},
52135

53136
/**
@@ -58,8 +141,7 @@ export const SettingsManager = {
58141
try {
59142
const stored = localStorage.getItem(STORAGE_KEY);
60143
return stored ? JSON.parse(stored) : {};
61-
} catch (e) {
62-
console.error('Error reading preferences:', e);
144+
} catch {
63145
return {};
64146
}
65147
},
@@ -73,8 +155,8 @@ export const SettingsManager = {
73155
const current = this.getLocalPreferences();
74156
const updated = { ...current, ...prefs };
75157
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
76-
} catch (e) {
77-
console.error('Error saving preferences:', e);
158+
} catch {
159+
// Preference save failed silently
78160
}
79161
},
80162

@@ -253,9 +335,7 @@ export const SettingsManager = {
253335
return true;
254336
}
255337
return false;
256-
} catch (e) {
257-
console.error('Error saving API key:', e);
258-
MessageLogger.addLog(`Failed to save API key: ${e.message}`, 'error');
338+
} catch {
259339
return false;
260340
}
261341
},
@@ -311,7 +391,6 @@ export const SettingsManager = {
311391
this.resetEnvModelApplied();
312392
return { success: true, savedToEnv: result.saved_keys };
313393
} catch (e) {
314-
console.error('Error saving to .env:', e);
315394
return { success: false, error: e.message };
316395
}
317396
}
@@ -327,7 +406,6 @@ export const SettingsManager = {
327406
applyPendingModelSelection() {
328407
// Don't apply localStorage preference if .env model was already applied
329408
if (envModelApplied) {
330-
console.log('⏭️ Skipping localStorage model - .env model already applied');
331409
delete window.__pendingModelSelection;
332410
return;
333411
}
@@ -341,7 +419,6 @@ export const SettingsManager = {
341419
if (option.value === window.__pendingModelSelection) {
342420
modelSelect.value = window.__pendingModelSelection;
343421
found = true;
344-
console.log(`✅ Applied saved model preference: ${window.__pendingModelSelection}`);
345422
break;
346423
}
347424
}
@@ -358,7 +435,6 @@ export const SettingsManager = {
358435
*/
359436
markEnvModelApplied() {
360437
envModelApplied = true;
361-
console.log('🔒 .env default model locked in');
362438
},
363439

364440
/**
@@ -367,7 +443,6 @@ export const SettingsManager = {
367443
*/
368444
resetEnvModelApplied() {
369445
envModelApplied = false;
370-
console.log('🔓 .env model lock reset - user saved new settings');
371446
},
372447

373448
/**

src/web/static/js/files/file-upload.js

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { StateManager } from '../core/state-manager.js';
99
import { ApiClient } from '../core/api-client.js';
1010
import { MessageLogger } from '../ui/message-logger.js';
1111
import { DomHelpers } from '../ui/dom-helpers.js';
12+
import { StatusManager } from '../utils/status-manager.js';
13+
14+
const FILE_QUEUE_STORAGE_KEY = 'tbl_file_queue';
1215

1316
/**
1417
* Generate output filename based on pattern
@@ -58,7 +61,6 @@ function detectFileType(filename) {
5861
function setLanguageInSelect(selectId, languageValue) {
5962
const select = DomHelpers.getElement(selectId);
6063
if (!select) {
61-
console.error(`Select element '${selectId}' not found`);
6264
return false;
6365
}
6466

@@ -84,7 +86,6 @@ function setLanguageInSelect(selectId, languageValue) {
8486
return true;
8587
}
8688

87-
console.warn(`Language "${languageValue}" not found in select options`);
8889
return false;
8990
}
9091

@@ -95,6 +96,78 @@ export const FileUpload = {
9596
initialize() {
9697
this.setupDragDrop();
9798
this.setupFileInput();
99+
// Restore file queue from localStorage after a short delay
100+
// to ensure WebSocket is connected and can verify files
101+
setTimeout(() => this.restoreFileQueue(), 1000);
102+
},
103+
104+
/**
105+
* Save file queue to localStorage
106+
* @private
107+
*/
108+
_saveFileQueue() {
109+
try {
110+
const filesToProcess = StateManager.getState('files.toProcess') || [];
111+
// Save only serializable data (exclude File objects)
112+
const serializableFiles = filesToProcess.map(f => ({
113+
name: f.name,
114+
filePath: f.filePath,
115+
fileType: f.fileType,
116+
originalExtension: f.originalExtension,
117+
status: f.status,
118+
outputFilename: f.outputFilename,
119+
size: f.size,
120+
sourceLanguage: f.sourceLanguage,
121+
targetLanguage: f.targetLanguage,
122+
translationId: f.translationId,
123+
detectedLanguage: f.detectedLanguage,
124+
languageConfidence: f.languageConfidence,
125+
thumbnail: f.thumbnail
126+
}));
127+
localStorage.setItem(FILE_QUEUE_STORAGE_KEY, JSON.stringify(serializableFiles));
128+
} catch {
129+
// Failed to save file queue
130+
}
131+
},
132+
133+
/**
134+
* Restore file queue from localStorage and verify files exist
135+
*/
136+
async restoreFileQueue() {
137+
try {
138+
const stored = localStorage.getItem(FILE_QUEUE_STORAGE_KEY);
139+
if (!stored) return;
140+
141+
const savedFiles = JSON.parse(stored);
142+
if (!Array.isArray(savedFiles) || savedFiles.length === 0) return;
143+
144+
// Get file paths to verify
145+
const filePaths = savedFiles.map(f => f.filePath);
146+
147+
// Verify which files still exist on the server
148+
const verification = await ApiClient.verifyUploadedFiles(filePaths);
149+
150+
// Filter to only existing files
151+
const existingFilePaths = new Set(verification.existing || []);
152+
const restoredFiles = savedFiles.filter(f => existingFilePaths.has(f.filePath));
153+
154+
if (restoredFiles.length > 0) {
155+
// Restore files to state (reset status to Queued for non-completed files)
156+
const filesToRestore = restoredFiles.map(f => ({
157+
...f,
158+
status: f.status === 'Completed' ? 'Completed' : 'Queued'
159+
}));
160+
161+
StateManager.setState('files.toProcess', filesToRestore);
162+
this.notifyFileListChanged();
163+
}
164+
165+
// Update localStorage with only existing files
166+
this._saveFileQueue();
167+
168+
} catch {
169+
// Failed to restore file queue
170+
}
98171
},
99172

100173
/**
@@ -127,7 +200,6 @@ export const FileUpload = {
127200
setupDragDrop() {
128201
const uploadArea = DomHelpers.getElement('fileUpload');
129202
if (!uploadArea) {
130-
console.warn('File upload area not found');
131203
return;
132204
}
133205

@@ -359,10 +431,10 @@ export const FileUpload = {
359431
// Show file info section
360432
DomHelpers.show(fileInfo);
361433

362-
// Enable translate button if not batch active
434+
// Enable translate button if not batch active and LLM is connected
363435
const isBatchActive = StateManager.getState('translation.isBatchActive') || false;
364436
if (translateBtn) {
365-
translateBtn.disabled = isBatchActive;
437+
translateBtn.disabled = isBatchActive || !StatusManager.isConnected();
366438
}
367439
} else {
368440
// Hide file info section
@@ -382,6 +454,9 @@ export const FileUpload = {
382454
// Update display immediately
383455
this.updateFileDisplay();
384456

457+
// Persist to localStorage
458+
this._saveFileQueue();
459+
385460
// Emit event so other modules can react
386461
const event = new CustomEvent('fileListChanged');
387462
window.dispatchEvent(event);

0 commit comments

Comments
 (0)