t Claude (claude-opus-4-5-thinking) commited on
Commit
520ce12
·
1 Parent(s): 106be3a

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 CHANGED
@@ -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
- conn.execute('DELETE FROM questions WHERE session_id = ?', (session_id,))
367
- questions_to_insert = []
368
  for q in questions:
369
- questions_to_insert.append((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', '')))
370
- conn.executemany('INSERT INTO questions (session_id, image_id, question_number, subject, status, marked_solution, actual_solution, time_taken, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', questions_to_insert)
 
 
 
 
 
 
 
 
 
 
 
 
 
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():
templates/question_entry_v2.html CHANGED
@@ -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
  }