Spaces:
Running
feat: add auto-save functionality to question entry form
Browse files- Add auto-save on blur for question_number, marked_solution, actual_solution fields
- Add auto-save on status button click (correct/wrong/unattempted)
- Add auto-save for PDF metadata fields (subject, tags, notes)
- Add /autosave_question route for saving single question data
- Add /autosave_session_metadata route for saving session metadata
- Show subtle "Saved" badge indicator on successful auto-save
- Update save_questions to use UPSERT pattern (preserves classification data)
- Remove DELETE + INSERT pattern that was losing subject/chapter data
This provides a much better UX - users no longer lose data if they forget
to click "Generate PDF". All changes are saved automatically as they type.
Co-Authored-By: Claude (claude-opus-4-5-thinking) <noreply@anthropic.com>
- routes/questions.py +107 -4
- templates/question_entry_v2.html +128 -2
|
@@ -363,14 +363,117 @@ def save_questions():
|
|
| 363 |
if not session_owner or session_owner['user_id'] != current_user.id:
|
| 364 |
conn.close(); return jsonify({'error': 'Unauthorized'}), 403
|
| 365 |
conn.execute('UPDATE sessions SET subject = ?, tags = ?, notes = ? WHERE id = ?', (data.get('pdf_subject', ''), data.get('pdf_tags', ''), data.get('pdf_notes', ''), session_id))
|
| 366 |
-
|
| 367 |
-
|
| 368 |
for q in questions:
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
conn.commit(); conn.close()
|
| 372 |
return jsonify({'success': True, 'message': 'Questions saved successfully.'})
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
@main_bp.route(ROUTE_EXTRACT_QUESTION_NUMBER, methods=[METHOD_POST])
|
| 375 |
@login_required
|
| 376 |
def extract_question_number():
|
|
|
|
| 363 |
if not session_owner or session_owner['user_id'] != current_user.id:
|
| 364 |
conn.close(); return jsonify({'error': 'Unauthorized'}), 403
|
| 365 |
conn.execute('UPDATE sessions SET subject = ?, tags = ?, notes = ? WHERE id = ?', (data.get('pdf_subject', ''), data.get('pdf_tags', ''), data.get('pdf_notes', ''), session_id))
|
| 366 |
+
|
| 367 |
+
# Use UPSERT to preserve subject and chapter data from classification
|
| 368 |
for q in questions:
|
| 369 |
+
existing = conn.execute('SELECT id, subject, chapter FROM questions WHERE image_id = ?', (q['image_id'],)).fetchone()
|
| 370 |
+
if existing:
|
| 371 |
+
# Update existing, preserve subject and chapter
|
| 372 |
+
conn.execute('''
|
| 373 |
+
UPDATE questions
|
| 374 |
+
SET question_number = ?, status = ?, marked_solution = ?, actual_solution = ?, tags = ?
|
| 375 |
+
WHERE image_id = ?
|
| 376 |
+
''', (q['question_number'], q['status'], q.get('marked_solution', ''), q.get('actual_solution', ''), data.get('pdf_tags', ''), q['image_id']))
|
| 377 |
+
else:
|
| 378 |
+
# Insert new
|
| 379 |
+
conn.execute('''
|
| 380 |
+
INSERT INTO questions (session_id, image_id, question_number, subject, status, marked_solution, actual_solution, time_taken, tags)
|
| 381 |
+
VALUES (?, ?, ?, '', ?, ?, ?, ?, ?)
|
| 382 |
+
''', (session_id, q['image_id'], q['question_number'], q['status'], q.get('marked_solution', ''), q.get('actual_solution', ''), q.get('time_taken', ''), data.get('pdf_tags', '')))
|
| 383 |
+
|
| 384 |
conn.commit(); conn.close()
|
| 385 |
return jsonify({'success': True, 'message': 'Questions saved successfully.'})
|
| 386 |
|
| 387 |
+
@main_bp.route('/autosave_question', methods=['POST'])
|
| 388 |
+
@login_required
|
| 389 |
+
def autosave_question():
|
| 390 |
+
"""Auto-save a single question's data without affecting other questions."""
|
| 391 |
+
data = request.json
|
| 392 |
+
session_id = data.get('session_id')
|
| 393 |
+
question = data.get('question', {})
|
| 394 |
+
|
| 395 |
+
if not session_id or not question.get('image_id'):
|
| 396 |
+
return jsonify({'error': 'Missing session_id or image_id'}), 400
|
| 397 |
+
|
| 398 |
+
conn = get_db_connection()
|
| 399 |
+
try:
|
| 400 |
+
# Verify session ownership
|
| 401 |
+
session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone()
|
| 402 |
+
if not session_owner or session_owner['user_id'] != current_user.id:
|
| 403 |
+
conn.close()
|
| 404 |
+
return jsonify({'error': 'Unauthorized'}), 403
|
| 405 |
+
|
| 406 |
+
image_id = question['image_id']
|
| 407 |
+
question_number = question.get('question_number', '')
|
| 408 |
+
status = question.get('status', 'unattempted')
|
| 409 |
+
marked_solution = question.get('marked_solution', '')
|
| 410 |
+
actual_solution = question.get('actual_solution', '')
|
| 411 |
+
|
| 412 |
+
# Check if question already exists
|
| 413 |
+
existing = conn.execute('SELECT id, subject, chapter, tags FROM questions WHERE image_id = ?', (image_id,)).fetchone()
|
| 414 |
+
|
| 415 |
+
if existing:
|
| 416 |
+
# Update existing question, preserving subject and chapter
|
| 417 |
+
conn.execute('''
|
| 418 |
+
UPDATE questions
|
| 419 |
+
SET question_number = ?, status = ?, marked_solution = ?, actual_solution = ?
|
| 420 |
+
WHERE image_id = ?
|
| 421 |
+
''', (question_number, status, marked_solution, actual_solution, image_id))
|
| 422 |
+
else:
|
| 423 |
+
# Insert new question
|
| 424 |
+
conn.execute('''
|
| 425 |
+
INSERT INTO questions (session_id, image_id, question_number, subject, status, marked_solution, actual_solution, time_taken, tags)
|
| 426 |
+
VALUES (?, ?, ?, '', ?, ?, ?, '', '')
|
| 427 |
+
''', (session_id, image_id, question_number, status, marked_solution, actual_solution))
|
| 428 |
+
|
| 429 |
+
conn.commit()
|
| 430 |
+
conn.close()
|
| 431 |
+
return jsonify({'success': True})
|
| 432 |
+
|
| 433 |
+
except Exception as e:
|
| 434 |
+
conn.close()
|
| 435 |
+
current_app.logger.error(f"Auto-save question error: {e}")
|
| 436 |
+
return jsonify({'error': str(e)}), 500
|
| 437 |
+
|
| 438 |
+
@main_bp.route('/autosave_session_metadata', methods=['POST'])
|
| 439 |
+
@login_required
|
| 440 |
+
def autosave_session_metadata():
|
| 441 |
+
"""Auto-save session metadata (PDF subject, tags, notes)."""
|
| 442 |
+
data = request.json
|
| 443 |
+
session_id = data.get('session_id')
|
| 444 |
+
|
| 445 |
+
if not session_id:
|
| 446 |
+
return jsonify({'error': 'Missing session_id'}), 400
|
| 447 |
+
|
| 448 |
+
conn = get_db_connection()
|
| 449 |
+
try:
|
| 450 |
+
# Verify session ownership
|
| 451 |
+
session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone()
|
| 452 |
+
if not session_owner or session_owner['user_id'] != current_user.id:
|
| 453 |
+
conn.close()
|
| 454 |
+
return jsonify({'error': 'Unauthorized'}), 403
|
| 455 |
+
|
| 456 |
+
# Update session metadata
|
| 457 |
+
conn.execute('''
|
| 458 |
+
UPDATE sessions
|
| 459 |
+
SET subject = ?, tags = ?, notes = ?
|
| 460 |
+
WHERE id = ?
|
| 461 |
+
''', (
|
| 462 |
+
data.get('pdf_subject', ''),
|
| 463 |
+
data.get('pdf_tags', ''),
|
| 464 |
+
data.get('pdf_notes', ''),
|
| 465 |
+
session_id
|
| 466 |
+
))
|
| 467 |
+
|
| 468 |
+
conn.commit()
|
| 469 |
+
conn.close()
|
| 470 |
+
return jsonify({'success': True})
|
| 471 |
+
|
| 472 |
+
except Exception as e:
|
| 473 |
+
conn.close()
|
| 474 |
+
current_app.logger.error(f"Auto-save metadata error: {e}")
|
| 475 |
+
return jsonify({'error': str(e)}), 500
|
| 476 |
+
|
| 477 |
@main_bp.route(ROUTE_EXTRACT_QUESTION_NUMBER, methods=[METHOD_POST])
|
| 478 |
@login_required
|
| 479 |
def extract_question_number():
|
|
@@ -1166,7 +1166,119 @@
|
|
| 1166 |
showStatus('Error: ' + e.message, 'danger');
|
| 1167 |
}
|
| 1168 |
}
|
| 1169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1170 |
async function saveNotes() {
|
| 1171 |
if (!canvas || !currentNoteImageId) return;
|
| 1172 |
|
|
@@ -1325,12 +1437,23 @@
|
|
| 1325 |
document.getElementById('practice_mode').addEventListener('change', handlePracticeModeChange);
|
| 1326 |
document.querySelectorAll('input[id^="question_number_"]').forEach(input => {
|
| 1327 |
input.addEventListener('change', handleQuestionNumberChange);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1328 |
});
|
| 1329 |
document.addEventListener('keydown', handleShortcuts);
|
| 1330 |
document.querySelectorAll('.status-buttons').forEach(setupStatusButtons);
|
| 1331 |
document.getElementById('questions-form').addEventListener('submit', handleFormSubmit);
|
| 1332 |
document.getElementById('preview-btn').addEventListener('click', handlePreview);
|
| 1333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1334 |
document.getElementById('add-misc-question').addEventListener('click', handleAddMiscQuestion);
|
| 1335 |
|
| 1336 |
document.getElementById('extract-classify-all').addEventListener('click', async () => {
|
|
@@ -1487,6 +1610,9 @@
|
|
| 1487 |
buttons.forEach(b => b.classList.remove('active'));
|
| 1488 |
btn.classList.add('active');
|
| 1489 |
select.value = btn.dataset.status;
|
|
|
|
|
|
|
|
|
|
| 1490 |
});
|
| 1491 |
});
|
| 1492 |
}
|
|
|
|
| 1166 |
showStatus('Error: ' + e.message, 'danger');
|
| 1167 |
}
|
| 1168 |
}
|
| 1169 |
+
|
| 1170 |
+
// --- AUTO-SAVE FUNCTIONALITY ---
|
| 1171 |
+
let autoSaveTimeout = null;
|
| 1172 |
+
let pendingSaves = new Set();
|
| 1173 |
+
|
| 1174 |
+
// Debounced auto-save for a single question
|
| 1175 |
+
async function autoSaveQuestion(fieldset) {
|
| 1176 |
+
if (!fieldset) return;
|
| 1177 |
+
|
| 1178 |
+
const imageId = fieldset.querySelector('input[name^="image_id_"]')?.value;
|
| 1179 |
+
if (!imageId) return;
|
| 1180 |
+
|
| 1181 |
+
// Skip if already pending
|
| 1182 |
+
if (pendingSaves.has(imageId)) return;
|
| 1183 |
+
pendingSaves.add(imageId);
|
| 1184 |
+
|
| 1185 |
+
const questionData = {
|
| 1186 |
+
image_id: imageId,
|
| 1187 |
+
question_number: fieldset.querySelector('input[name^="question_number_"]')?.value || '',
|
| 1188 |
+
status: fieldset.querySelector('select[name^="status_"]')?.value || 'unattempted',
|
| 1189 |
+
marked_solution: fieldset.querySelector('input[name^="marked_solution_"]')?.value || '',
|
| 1190 |
+
actual_solution: fieldset.querySelector('input[name^="actual_solution_"]')?.value || ''
|
| 1191 |
+
};
|
| 1192 |
+
|
| 1193 |
+
try {
|
| 1194 |
+
const response = await fetch('/autosave_question', {
|
| 1195 |
+
method: 'POST',
|
| 1196 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1197 |
+
body: JSON.stringify({
|
| 1198 |
+
session_id: sessionId,
|
| 1199 |
+
question: questionData
|
| 1200 |
+
})
|
| 1201 |
+
});
|
| 1202 |
+
|
| 1203 |
+
const result = await response.json();
|
| 1204 |
+
if (result.success) {
|
| 1205 |
+
// Show subtle save indicator on the fieldset
|
| 1206 |
+
showAutoSaveIndicator(fieldset, true);
|
| 1207 |
+
} else {
|
| 1208 |
+
showAutoSaveIndicator(fieldset, false);
|
| 1209 |
+
}
|
| 1210 |
+
} catch (e) {
|
| 1211 |
+
console.error('Auto-save failed:', e);
|
| 1212 |
+
showAutoSaveIndicator(fieldset, false);
|
| 1213 |
+
} finally {
|
| 1214 |
+
pendingSaves.delete(imageId);
|
| 1215 |
+
}
|
| 1216 |
+
}
|
| 1217 |
+
|
| 1218 |
+
// Auto-save session metadata (PDF subject, tags, notes)
|
| 1219 |
+
async function autoSaveSessionMetadata() {
|
| 1220 |
+
try {
|
| 1221 |
+
const response = await fetch('/autosave_session_metadata', {
|
| 1222 |
+
method: 'POST',
|
| 1223 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1224 |
+
body: JSON.stringify({
|
| 1225 |
+
session_id: sessionId,
|
| 1226 |
+
pdf_subject: document.getElementById('pdf_subject')?.value || '',
|
| 1227 |
+
pdf_tags: document.getElementById('pdf_tags')?.value || '',
|
| 1228 |
+
pdf_notes: document.getElementById('pdf_notes')?.value || '',
|
| 1229 |
+
pdf_name: document.getElementById('pdf_name')?.value || ''
|
| 1230 |
+
})
|
| 1231 |
+
});
|
| 1232 |
+
|
| 1233 |
+
const result = await response.json();
|
| 1234 |
+
if (result.success) {
|
| 1235 |
+
// Show subtle indicator near the generate PDF section
|
| 1236 |
+
const legend = document.querySelector('legend.h4');
|
| 1237 |
+
if (legend && !legend.querySelector('.autosave-badge')) {
|
| 1238 |
+
const badge = document.createElement('span');
|
| 1239 |
+
badge.className = 'autosave-badge badge bg-success ms-2';
|
| 1240 |
+
badge.innerHTML = '<i class="bi bi-check"></i> Saved';
|
| 1241 |
+
legend.appendChild(badge);
|
| 1242 |
+
setTimeout(() => badge.remove(), 2000);
|
| 1243 |
+
}
|
| 1244 |
+
}
|
| 1245 |
+
} catch (e) {
|
| 1246 |
+
console.error('Metadata auto-save failed:', e);
|
| 1247 |
+
}
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
// Show a subtle save indicator on the fieldset
|
| 1251 |
+
function showAutoSaveIndicator(fieldset, success) {
|
| 1252 |
+
// Remove any existing indicator
|
| 1253 |
+
const existing = fieldset.querySelector('.autosave-indicator');
|
| 1254 |
+
if (existing) existing.remove();
|
| 1255 |
+
|
| 1256 |
+
const indicator = document.createElement('span');
|
| 1257 |
+
indicator.className = 'autosave-indicator position-absolute';
|
| 1258 |
+
indicator.style.cssText = 'top: 5px; right: 100px; font-size: 0.75rem;';
|
| 1259 |
+
|
| 1260 |
+
if (success) {
|
| 1261 |
+
indicator.innerHTML = '<span class="badge bg-success"><i class="bi bi-check"></i> Saved</span>';
|
| 1262 |
+
} else {
|
| 1263 |
+
indicator.innerHTML = '<span class="badge bg-danger"><i class="bi bi-x"></i> Error</span>';
|
| 1264 |
+
}
|
| 1265 |
+
|
| 1266 |
+
// Position relative to the legend
|
| 1267 |
+
const legend = fieldset.querySelector('legend');
|
| 1268 |
+
if (legend) {
|
| 1269 |
+
legend.style.position = 'relative';
|
| 1270 |
+
legend.appendChild(indicator);
|
| 1271 |
+
}
|
| 1272 |
+
|
| 1273 |
+
// Remove after 2 seconds
|
| 1274 |
+
setTimeout(() => indicator.remove(), 2000);
|
| 1275 |
+
}
|
| 1276 |
+
|
| 1277 |
+
// Auto-save status when changed via status buttons
|
| 1278 |
+
function autoSaveOnStatusChange(fieldset) {
|
| 1279 |
+
autoSaveQuestion(fieldset);
|
| 1280 |
+
}
|
| 1281 |
+
|
| 1282 |
async function saveNotes() {
|
| 1283 |
if (!canvas || !currentNoteImageId) return;
|
| 1284 |
|
|
|
|
| 1437 |
document.getElementById('practice_mode').addEventListener('change', handlePracticeModeChange);
|
| 1438 |
document.querySelectorAll('input[id^="question_number_"]').forEach(input => {
|
| 1439 |
input.addEventListener('change', handleQuestionNumberChange);
|
| 1440 |
+
input.addEventListener('blur', () => autoSaveQuestion(input.closest('fieldset')));
|
| 1441 |
+
});
|
| 1442 |
+
// Auto-save on marked_solution and actual_solution change
|
| 1443 |
+
document.querySelectorAll('input[id^="marked_solution_"], input[id^="actual_solution_"]').forEach(input => {
|
| 1444 |
+
input.addEventListener('blur', () => autoSaveQuestion(input.closest('fieldset')));
|
| 1445 |
});
|
| 1446 |
document.addEventListener('keydown', handleShortcuts);
|
| 1447 |
document.querySelectorAll('.status-buttons').forEach(setupStatusButtons);
|
| 1448 |
document.getElementById('questions-form').addEventListener('submit', handleFormSubmit);
|
| 1449 |
document.getElementById('preview-btn').addEventListener('click', handlePreview);
|
| 1450 |
+
|
| 1451 |
+
// Auto-save PDF metadata fields on blur
|
| 1452 |
+
['pdf_subject', 'pdf_tags', 'pdf_notes', 'pdf_name'].forEach(fieldId => {
|
| 1453 |
+
const field = document.getElementById(fieldId);
|
| 1454 |
+
if (field) field.addEventListener('blur', autoSaveSessionMetadata);
|
| 1455 |
+
});
|
| 1456 |
+
|
| 1457 |
document.getElementById('add-misc-question').addEventListener('click', handleAddMiscQuestion);
|
| 1458 |
|
| 1459 |
document.getElementById('extract-classify-all').addEventListener('click', async () => {
|
|
|
|
| 1610 |
buttons.forEach(b => b.classList.remove('active'));
|
| 1611 |
btn.classList.add('active');
|
| 1612 |
select.value = btn.dataset.status;
|
| 1613 |
+
// Auto-save when status changes
|
| 1614 |
+
const fieldset = group.closest('fieldset');
|
| 1615 |
+
if (fieldset) autoSaveOnStatusChange(fieldset);
|
| 1616 |
});
|
| 1617 |
});
|
| 1618 |
}
|