root commited on
Commit
77cff06
·
1 Parent(s): 7d4265e

feat: enhance session collections, automate classification sync, and overhaul manual classification wizard UI/UX

Browse files

- Automated sync of classified questions to NEETprep collections via sync_neetprep_collection helper.
- Standard sessions with classified questions can now be viewed directly as collections without duplication.
- Overhauled manual classification wizard in question_entry_v2 with Shift+1-4/~ subject shortcuts and Arrow cycling for AI suggestions.
- Improved 'Double Enter' flow for rapid classification.
- Added Zoology support to classification systems.
- Unified dashboard view with 'View as Collection' for applicable sessions.
- Fixed CSS leak in question_entry_v2.html.
- Fixed BuildError for collection view links.

classifier_routes.py CHANGED
@@ -1,6 +1,6 @@
1
  from flask import Blueprint, jsonify, current_app, render_template, request
2
  from flask_login import login_required, current_user
3
- from utils import get_db_connection
4
  import os
5
  import time
6
  import json
@@ -169,6 +169,12 @@ def update_question_classification_single():
169
  'UPDATE questions SET subject = ?, chapter = ? WHERE image_id = ?',
170
  (subject, chapter, image_id)
171
  )
 
 
 
 
 
 
172
  conn.commit()
173
  conn.close()
174
  return jsonify({'success': True})
@@ -262,6 +268,9 @@ def delete_classified_question(question_id):
262
  # Update the question to remove classification
263
  conn.execute('UPDATE questions SET subject = NULL, chapter = NULL WHERE id = ?', (question_id,))
264
 
 
 
 
265
  conn.commit()
266
  conn.close()
267
  return jsonify({'success': True})
@@ -298,6 +307,10 @@ def delete_many_classified_questions():
298
  update_placeholders = ','.join('?' for _ in owned_q_ids)
299
  conn.execute(f'UPDATE questions SET subject = NULL, chapter = NULL WHERE id IN ({update_placeholders})', owned_q_ids)
300
 
 
 
 
 
301
  conn.commit()
302
  conn.close()
303
  return jsonify({'success': True})
@@ -452,6 +465,10 @@ def extract_and_classify_all(session_id):
452
  current_app.logger.info("Waiting 5 seconds before next batch...")
453
  time.sleep(5)
454
 
 
 
 
 
455
  conn.close()
456
 
457
  return jsonify({'success': True, 'message': f'Successfully extracted and classified {total_questions} questions. Updated {total_update_count} entries in the database.'})
 
1
  from flask import Blueprint, jsonify, current_app, render_template, request
2
  from flask_login import login_required, current_user
3
+ from utils import get_db_connection, sync_neetprep_collection
4
  import os
5
  import time
6
  import json
 
169
  'UPDATE questions SET subject = ?, chapter = ? WHERE image_id = ?',
170
  (subject, chapter, image_id)
171
  )
172
+
173
+ # Auto-convert and sync neetprep collection
174
+ img_row = conn.execute('SELECT session_id FROM images WHERE id = ?', (image_id,)).fetchone()
175
+ if img_row:
176
+ sync_neetprep_collection(conn, img_row['session_id'], current_user.id)
177
+
178
  conn.commit()
179
  conn.close()
180
  return jsonify({'success': True})
 
268
  # Update the question to remove classification
269
  conn.execute('UPDATE questions SET subject = NULL, chapter = NULL WHERE id = ?', (question_id,))
270
 
271
+ # Remove bookmark too
272
+ conn.execute('DELETE FROM neetprep_bookmarks WHERE neetprep_question_id = ? AND question_type = ?', (str(question_id), 'classified'))
273
+
274
  conn.commit()
275
  conn.close()
276
  return jsonify({'success': True})
 
307
  update_placeholders = ','.join('?' for _ in owned_q_ids)
308
  conn.execute(f'UPDATE questions SET subject = NULL, chapter = NULL WHERE id IN ({update_placeholders})', owned_q_ids)
309
 
310
+ # Remove bookmarks too
311
+ owned_ids_str = [str(qid) for qid in owned_q_ids]
312
+ conn.execute(f"DELETE FROM neetprep_bookmarks WHERE neetprep_question_id IN ({update_placeholders}) AND question_type = 'classified'", owned_ids_str)
313
+
314
  conn.commit()
315
  conn.close()
316
  return jsonify({'success': True})
 
465
  current_app.logger.info("Waiting 5 seconds before next batch...")
466
  time.sleep(5)
467
 
468
+ # Auto-convert and sync neetprep collection
469
+ sync_neetprep_collection(conn, session_id, current_user.id)
470
+
471
+ conn.commit()
472
  conn.close()
473
 
474
  return jsonify({'success': True, 'message': f'Successfully extracted and classified {total_questions} questions. Updated {total_update_count} entries in the database.'})
dashboard.py CHANGED
@@ -137,58 +137,37 @@ def dashboard():
137
 
138
  conn = get_db_connection()
139
 
140
- # Build the base query
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  if filter_type == 'collections':
142
- # Only show neetprep collections
143
- sessions_rows = conn.execute("""
144
- SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name,
145
- 0 as page_count,
146
- COUNT(nb.id) as question_count
147
- FROM sessions s
148
- LEFT JOIN neetprep_bookmarks nb ON s.id = nb.session_id
149
- WHERE s.user_id = ? AND s.session_type = 'neetprep_collection'
150
- GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name
151
- ORDER BY s.created_at DESC
152
- """, (current_user.id,)).fetchall()
153
  elif filter_type == 'standard':
154
- # Only show standard sessions (exclude collections and final_pdf)
155
- sessions_rows = conn.execute("""
156
- SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name,
157
- COUNT(CASE WHEN i.image_type = 'original' THEN 1 END) as page_count,
158
- COUNT(CASE WHEN i.image_type = 'cropped' THEN 1 END) as question_count
159
- FROM sessions s
160
- LEFT JOIN images i ON s.id = i.session_id
161
- WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type NOT IN ('final_pdf', 'neetprep_collection'))
162
- GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name
163
- ORDER BY s.created_at DESC
164
- """, (current_user.id,)).fetchall()
165
- else:
166
- # Show all (both standard and collections, but not final_pdf)
167
- # First get standard sessions
168
- standard_sessions = conn.execute("""
169
- SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name,
170
- COUNT(CASE WHEN i.image_type = 'original' THEN 1 END) as page_count,
171
- COUNT(CASE WHEN i.image_type = 'cropped' THEN 1 END) as question_count
172
- FROM sessions s
173
- LEFT JOIN images i ON s.id = i.session_id
174
- WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type NOT IN ('final_pdf', 'neetprep_collection'))
175
- GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name
176
- """, (current_user.id,)).fetchall()
177
-
178
- # Then get neetprep collections
179
- collection_sessions = conn.execute("""
180
- SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name,
181
- 0 as page_count,
182
- COUNT(nb.id) as question_count
183
- FROM sessions s
184
- LEFT JOIN neetprep_bookmarks nb ON s.id = nb.session_id
185
- WHERE s.user_id = ? AND s.session_type = 'neetprep_collection'
186
- GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name
187
- """, (current_user.id,)).fetchall()
188
-
189
- # Combine and sort by created_at
190
- all_sessions = list(standard_sessions) + list(collection_sessions)
191
- sessions_rows = sorted(all_sessions, key=lambda x: x['created_at'], reverse=True)
192
 
193
  sessions = []
194
  for session in sessions_rows:
 
137
 
138
  conn = get_db_connection()
139
 
140
+ # Unified query for all session types with classified check
141
+ query = """
142
+ SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name,
143
+ COUNT(DISTINCT CASE WHEN i.image_type = 'original' THEN i.id END) as page_count,
144
+ (COUNT(DISTINCT CASE WHEN i.image_type = 'cropped' THEN i.id END) +
145
+ COUNT(DISTINCT nb.id)) as question_count,
146
+ EXISTS(
147
+ SELECT 1 FROM questions q
148
+ WHERE q.session_id = s.id
149
+ AND q.subject IS NOT NULL AND q.subject != 'Unclassified'
150
+ AND q.chapter IS NOT NULL AND q.chapter != 'Unclassified'
151
+ ) as has_classified
152
+ FROM sessions s
153
+ LEFT JOIN images i ON s.id = i.session_id
154
+ LEFT JOIN neetprep_bookmarks nb ON s.id = nb.session_id
155
+ WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type != 'final_pdf')
156
+ """
157
+
158
+ params = [current_user.id]
159
+
160
  if filter_type == 'collections':
161
+ query += " AND s.session_type = 'neetprep_collection'"
 
 
 
 
 
 
 
 
 
 
162
  elif filter_type == 'standard':
163
+ query += " AND (s.session_type IS NULL OR s.session_type = 'standard')"
164
+
165
+ query += """
166
+ GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name
167
+ ORDER BY s.created_at DESC
168
+ """
169
+
170
+ sessions_rows = conn.execute(query, params).fetchall()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
  sessions = []
173
  for session in sessions_rows:
neetprep.py CHANGED
@@ -1073,7 +1073,7 @@ def view_collection(session_id):
1073
  flash('Collection not found', 'danger')
1074
  return redirect(url_for('dashboard.dashboard', filter='collections'))
1075
 
1076
- # Fetch neetprep questions
1077
  neetprep_questions = conn.execute("""
1078
  SELECT nq.id, nq.question_text, nq.options, nq.correct_answer_index,
1079
  nq.level, nq.topic, nq.subject, b.created_at as bookmarked_at, 'neetprep' as question_type
@@ -1083,8 +1083,8 @@ def view_collection(session_id):
1083
  ORDER BY nq.topic, b.created_at
1084
  """, (session_id, current_user.id)).fetchall()
1085
 
1086
- # Fetch classified questions
1087
- classified_questions = conn.execute("""
1088
  SELECT q.id, q.question_text, NULL as options, q.actual_solution as correct_answer_index,
1089
  NULL as level, q.chapter as topic, q.subject, b.created_at as bookmarked_at, 'classified' as question_type,
1090
  i.processed_filename as image_filename, q.question_number
@@ -1095,16 +1095,44 @@ def view_collection(session_id):
1095
  ORDER BY q.chapter, b.created_at
1096
  """, (session_id, current_user.id)).fetchall()
1097
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1098
  conn.close()
1099
 
1100
- # Combine all questions
1101
  all_questions = []
 
 
 
1102
  for q in neetprep_questions:
1103
  qd = dict(q)
1104
  qd['image_filename'] = None
1105
  all_questions.append(qd)
1106
- for q in classified_questions:
1107
- all_questions.append(dict(q))
 
 
 
 
 
 
 
 
 
 
 
 
1108
 
1109
  # Group questions by topic
1110
  topics = {}
@@ -1203,7 +1231,38 @@ def collection_quiz(session_id):
1203
  ORDER BY b.created_at
1204
  """, (session_id, current_user.id)).fetchall()
1205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1206
  for q in neetprep_questions:
 
 
 
 
1207
  try:
1208
  html_content = f"""<html><head><meta charset="utf-8"></head><body>{q['question_text']}</body></html>"""
1209
  img_filename = f"neetprep_{q['id']}.jpg"
@@ -1224,18 +1283,12 @@ def collection_quiz(session_id):
1224
  except Exception as e:
1225
  current_app.logger.error(f"Failed to convert question {q['id']} to image: {e}")
1226
 
1227
- # Get classified bookmarked questions
1228
- classified_questions = conn.execute("""
1229
- SELECT q.id, q.actual_solution as correct_answer_index, q.marked_solution as user_answer_index,
1230
- q.chapter as topic, q.subject, i.processed_filename, i.note_filename
1231
- FROM neetprep_bookmarks b
1232
- JOIN questions q ON CAST(b.neetprep_question_id AS INTEGER) = q.id
1233
- LEFT JOIN images i ON q.image_id = i.id
1234
- WHERE b.session_id = ? AND b.user_id = ? AND b.question_type = 'classified'
1235
- ORDER BY b.created_at
1236
- """, (session_id, current_user.id)).fetchall()
1237
-
1238
- for q in classified_questions:
1239
  if q['processed_filename']:
1240
  all_questions.append({
1241
  'image_path': f"/processed/{q['processed_filename']}",
@@ -1251,7 +1304,13 @@ def collection_quiz(session_id):
1251
  }
1252
  })
1253
 
1254
- conn.close()
 
 
 
 
 
 
1255
 
1256
  if not all_questions:
1257
  from flask import redirect, flash
@@ -1283,8 +1342,8 @@ def generate_collection_pdf(session_id):
1283
  ORDER BY nq.topic, b.created_at
1284
  """, (session_id, current_user.id)).fetchall()
1285
 
1286
- # Get classified bookmarked questions
1287
- classified_questions = conn.execute("""
1288
  SELECT q.id, q.question_text, q.actual_solution as correct_answer_index,
1289
  q.chapter as topic, q.subject, i.processed_filename
1290
  FROM neetprep_bookmarks b
@@ -1294,15 +1353,32 @@ def generate_collection_pdf(session_id):
1294
  ORDER BY q.chapter, b.created_at
1295
  """, (session_id, current_user.id)).fetchall()
1296
 
 
 
 
 
 
 
 
 
 
 
 
1297
  conn.close()
1298
 
1299
- if not neetprep_questions and not classified_questions:
1300
  return jsonify({'error': 'No questions in this collection'}), 400
1301
 
1302
  data = request.json or {}
1303
  all_questions = []
 
1304
 
 
1305
  for q in neetprep_questions:
 
 
 
 
1306
  all_questions.append({
1307
  "id": q['id'],
1308
  "question_text": q['question_text'],
@@ -1318,7 +1394,12 @@ def generate_collection_pdf(session_id):
1318
  }
1319
  })
1320
 
1321
- for q in classified_questions:
 
 
 
 
 
1322
  if q['processed_filename']:
1323
  # Use absolute path for PDF generation
1324
  abs_img_path = os.path.abspath(os.path.join(current_app.config['PROCESSED_FOLDER'], q['processed_filename']))
@@ -1336,6 +1417,14 @@ def generate_collection_pdf(session_id):
1336
  }
1337
  })
1338
 
 
 
 
 
 
 
 
 
1339
  topics = list(set(q['custom_fields']['topic'] for q in all_questions if q['custom_fields'].get('topic')))
1340
 
1341
  final_json_output = {
 
1073
  flash('Collection not found', 'danger')
1074
  return redirect(url_for('dashboard.dashboard', filter='collections'))
1075
 
1076
+ # Fetch neetprep questions from bookmarks
1077
  neetprep_questions = conn.execute("""
1078
  SELECT nq.id, nq.question_text, nq.options, nq.correct_answer_index,
1079
  nq.level, nq.topic, nq.subject, b.created_at as bookmarked_at, 'neetprep' as question_type
 
1083
  ORDER BY nq.topic, b.created_at
1084
  """, (session_id, current_user.id)).fetchall()
1085
 
1086
+ # Fetch explicitly bookmarked classified questions
1087
+ bookmarked_classified = conn.execute("""
1088
  SELECT q.id, q.question_text, NULL as options, q.actual_solution as correct_answer_index,
1089
  NULL as level, q.chapter as topic, q.subject, b.created_at as bookmarked_at, 'classified' as question_type,
1090
  i.processed_filename as image_filename, q.question_number
 
1095
  ORDER BY q.chapter, b.created_at
1096
  """, (session_id, current_user.id)).fetchall()
1097
 
1098
+ # Fetch ALL classified questions from the session (even if not bookmarked)
1099
+ # This enables "View as Collection" for any standard session
1100
+ session_classified = conn.execute("""
1101
+ SELECT q.id, q.question_text, NULL as options, q.actual_solution as correct_answer_index,
1102
+ NULL as level, q.chapter as topic, q.subject, q.id as bookmarked_at, 'classified' as question_type,
1103
+ i.processed_filename as image_filename, q.question_number
1104
+ FROM questions q
1105
+ LEFT JOIN images i ON q.image_id = i.id
1106
+ WHERE q.session_id = ? AND q.subject IS NOT NULL AND q.subject != 'Unclassified'
1107
+ AND q.chapter IS NOT NULL AND q.chapter != 'Unclassified'
1108
+ ORDER BY q.chapter, q.id
1109
+ """, (session_id,)).fetchall()
1110
+
1111
  conn.close()
1112
 
1113
+ # Combine all questions, ensuring uniqueness by question ID
1114
  all_questions = []
1115
+ seen_ids = set()
1116
+
1117
+ # Add bookmarked neetprep questions
1118
  for q in neetprep_questions:
1119
  qd = dict(q)
1120
  qd['image_filename'] = None
1121
  all_questions.append(qd)
1122
+
1123
+ # Add bookmarked classified questions
1124
+ for q in bookmarked_classified:
1125
+ qid = f"classified_{q['id']}"
1126
+ if qid not in seen_ids:
1127
+ all_questions.append(dict(q))
1128
+ seen_ids.add(qid)
1129
+
1130
+ # Add session's own classified questions (the "View as Collection" part)
1131
+ for q in session_classified:
1132
+ qid = f"classified_{q['id']}"
1133
+ if qid not in seen_ids:
1134
+ all_questions.append(dict(q))
1135
+ seen_ids.add(qid)
1136
 
1137
  # Group questions by topic
1138
  topics = {}
 
1231
  ORDER BY b.created_at
1232
  """, (session_id, current_user.id)).fetchall()
1233
 
1234
+ # Get explicitly bookmarked classified questions
1235
+ bookmarked_classified = conn.execute("""
1236
+ SELECT q.id, q.actual_solution as correct_answer_index, q.marked_solution as user_answer_index,
1237
+ q.chapter as topic, q.subject, i.processed_filename, i.note_filename
1238
+ FROM neetprep_bookmarks b
1239
+ JOIN questions q ON CAST(b.neetprep_question_id AS INTEGER) = q.id
1240
+ LEFT JOIN images i ON q.image_id = i.id
1241
+ WHERE b.session_id = ? AND b.user_id = ? AND b.question_type = 'classified'
1242
+ ORDER BY b.created_at
1243
+ """, (session_id, current_user.id)).fetchall()
1244
+
1245
+ # Get ALL classified questions from the session directly
1246
+ session_classified = conn.execute("""
1247
+ SELECT q.id, q.actual_solution as correct_answer_index, q.marked_solution as user_answer_index,
1248
+ q.chapter as topic, q.subject, i.processed_filename, i.note_filename
1249
+ FROM questions q
1250
+ LEFT JOIN images i ON q.image_id = i.id
1251
+ WHERE q.session_id = ? AND q.subject IS NOT NULL AND q.subject != 'Unclassified'
1252
+ AND q.chapter IS NOT NULL AND q.chapter != 'Unclassified'
1253
+ ORDER BY q.chapter, q.id
1254
+ """, (session_id,)).fetchall()
1255
+
1256
+ conn.close()
1257
+
1258
+ seen_ids = set()
1259
+
1260
+ # Process neetprep questions
1261
  for q in neetprep_questions:
1262
+ qid = f"neetprep_{q['id']}"
1263
+ if qid in seen_ids: continue
1264
+ seen_ids.add(qid)
1265
+
1266
  try:
1267
  html_content = f"""<html><head><meta charset="utf-8"></head><body>{q['question_text']}</body></html>"""
1268
  img_filename = f"neetprep_{q['id']}.jpg"
 
1283
  except Exception as e:
1284
  current_app.logger.error(f"Failed to convert question {q['id']} to image: {e}")
1285
 
1286
+ # Helper to add classified question
1287
+ def add_classified(q):
1288
+ qid = f"classified_{q['id']}"
1289
+ if qid in seen_ids: return
1290
+ seen_ids.add(qid)
1291
+
 
 
 
 
 
 
1292
  if q['processed_filename']:
1293
  all_questions.append({
1294
  'image_path': f"/processed/{q['processed_filename']}",
 
1304
  }
1305
  })
1306
 
1307
+ # Process bookmarked classified questions
1308
+ for q in bookmarked_classified:
1309
+ add_classified(q)
1310
+
1311
+ # Process session's own classified questions
1312
+ for q in session_classified:
1313
+ add_classified(q)
1314
 
1315
  if not all_questions:
1316
  from flask import redirect, flash
 
1342
  ORDER BY nq.topic, b.created_at
1343
  """, (session_id, current_user.id)).fetchall()
1344
 
1345
+ # Get explicitly bookmarked classified questions
1346
+ bookmarked_classified = conn.execute("""
1347
  SELECT q.id, q.question_text, q.actual_solution as correct_answer_index,
1348
  q.chapter as topic, q.subject, i.processed_filename
1349
  FROM neetprep_bookmarks b
 
1353
  ORDER BY q.chapter, b.created_at
1354
  """, (session_id, current_user.id)).fetchall()
1355
 
1356
+ # Get ALL classified questions from the session directly
1357
+ session_classified = conn.execute("""
1358
+ SELECT q.id, q.question_text, q.actual_solution as correct_answer_index,
1359
+ q.chapter as topic, q.subject, i.processed_filename
1360
+ FROM questions q
1361
+ LEFT JOIN images i ON q.image_id = i.id
1362
+ WHERE q.session_id = ? AND q.subject IS NOT NULL AND q.subject != 'Unclassified'
1363
+ AND q.chapter IS NOT NULL AND q.chapter != 'Unclassified'
1364
+ ORDER BY q.chapter, q.id
1365
+ """, (session_id,)).fetchall()
1366
+
1367
  conn.close()
1368
 
1369
+ if not neetprep_questions and not bookmarked_classified and not session_classified:
1370
  return jsonify({'error': 'No questions in this collection'}), 400
1371
 
1372
  data = request.json or {}
1373
  all_questions = []
1374
+ seen_ids = set()
1375
 
1376
+ # Process neetprep questions
1377
  for q in neetprep_questions:
1378
+ qid = f"neetprep_{q['id']}"
1379
+ if qid in seen_ids: continue
1380
+ seen_ids.add(qid)
1381
+
1382
  all_questions.append({
1383
  "id": q['id'],
1384
  "question_text": q['question_text'],
 
1394
  }
1395
  })
1396
 
1397
+ # Helper to add classified
1398
+ def add_classified(q):
1399
+ qid = f"classified_{q['id']}"
1400
+ if qid in seen_ids: return
1401
+ seen_ids.add(qid)
1402
+
1403
  if q['processed_filename']:
1404
  # Use absolute path for PDF generation
1405
  abs_img_path = os.path.abspath(os.path.join(current_app.config['PROCESSED_FOLDER'], q['processed_filename']))
 
1417
  }
1418
  })
1419
 
1420
+ # Process bookmarked classified
1421
+ for q in bookmarked_classified:
1422
+ add_classified(q)
1423
+
1424
+ # Process session classified
1425
+ for q in session_classified:
1426
+ add_classified(q)
1427
+
1428
  topics = list(set(q['custom_fields']['topic'] for q in all_questions if q['custom_fields'].get('topic')))
1429
 
1430
  final_json_output = {
routes/questions.py CHANGED
@@ -1,6 +1,7 @@
1
  import os
2
  from flask import current_app, request, jsonify, render_template, flash, redirect, url_for
3
  from .common import main_bp, get_db_connection, login_required, current_user
 
4
  from processing import resize_image_if_needed, call_nim_ocr_api, extract_question_number_from_ocr_result
5
  from strings import ROUTE_SAVE_QUESTIONS, ROUTE_EXTRACT_QUESTION_NUMBER, ROUTE_EXTRACT_ALL_QUESTION_NUMBERS, METHOD_POST
6
  import requests
@@ -329,6 +330,9 @@ def update_question_classification_single():
329
  VALUES (?, ?, '', ?, ?, 'unattempted')
330
  ''', (img_info['session_id'], image_id, subject, chapter))
331
 
 
 
 
332
  conn.commit(); conn.close()
333
  return jsonify({'success': True})
334
  except Exception as e:
@@ -381,6 +385,9 @@ def save_questions():
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
 
@@ -426,6 +433,9 @@ def autosave_question():
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})
@@ -544,8 +554,17 @@ def delete_question(image_id):
544
  conn.close(); return jsonify({'error': 'Unauthorized'}), 403
545
  image_info = conn.execute('SELECT session_id, filename, processed_filename FROM images WHERE id = ?', (image_id,)).fetchone()
546
  if not image_info: conn.close(); return jsonify({'error': 'Question not found'}), 404
 
 
 
 
 
547
  conn.execute('DELETE FROM questions WHERE image_id = ?', (image_id,))
548
  conn.execute('DELETE FROM images WHERE id = ?', (image_id,))
 
 
 
 
549
  conn.commit(); conn.close()
550
  return jsonify({'success': True})
551
  except Exception as e: return jsonify({'error': str(e)}), 500
 
1
  import os
2
  from flask import current_app, request, jsonify, render_template, flash, redirect, url_for
3
  from .common import main_bp, get_db_connection, login_required, current_user
4
+ from utils import sync_neetprep_collection
5
  from processing import resize_image_if_needed, call_nim_ocr_api, extract_question_number_from_ocr_result
6
  from strings import ROUTE_SAVE_QUESTIONS, ROUTE_EXTRACT_QUESTION_NUMBER, ROUTE_EXTRACT_ALL_QUESTION_NUMBERS, METHOD_POST
7
  import requests
 
330
  VALUES (?, ?, '', ?, ?, 'unattempted')
331
  ''', (img_info['session_id'], image_id, subject, chapter))
332
 
333
+ # Auto-convert and sync neetprep collection
334
+ sync_neetprep_collection(conn, session_id, current_user.id)
335
+
336
  conn.commit(); conn.close()
337
  return jsonify({'success': True})
338
  except Exception as e:
 
385
  VALUES (?, ?, ?, '', ?, ?, ?, ?, ?)
386
  ''', (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', '')))
387
 
388
+ # Auto-convert and sync neetprep collection
389
+ sync_neetprep_collection(conn, session_id, current_user.id)
390
+
391
  conn.commit(); conn.close()
392
  return jsonify({'success': True, 'message': 'Questions saved successfully.'})
393
 
 
433
  VALUES (?, ?, ?, '', ?, ?, ?, '', '')
434
  ''', (session_id, image_id, question_number, status, marked_solution, actual_solution))
435
 
436
+ # Auto-convert and sync neetprep collection
437
+ sync_neetprep_collection(conn, session_id, current_user.id)
438
+
439
  conn.commit()
440
  conn.close()
441
  return jsonify({'success': True})
 
554
  conn.close(); return jsonify({'error': 'Unauthorized'}), 403
555
  image_info = conn.execute('SELECT session_id, filename, processed_filename FROM images WHERE id = ?', (image_id,)).fetchone()
556
  if not image_info: conn.close(); return jsonify({'error': 'Question not found'}), 404
557
+ # Get question ID to delete bookmarks
558
+ q_row = conn.execute('SELECT id FROM questions WHERE image_id = ?', (image_id,)).fetchone()
559
+ if q_row:
560
+ conn.execute('DELETE FROM neetprep_bookmarks WHERE neetprep_question_id = ? AND question_type = ?', (str(q_row['id']), 'classified'))
561
+
562
  conn.execute('DELETE FROM questions WHERE image_id = ?', (image_id,))
563
  conn.execute('DELETE FROM images WHERE id = ?', (image_id,))
564
+
565
+ # Auto-convert and sync neetprep collection
566
+ sync_neetprep_collection(conn, image_info['session_id'], current_user.id)
567
+
568
  conn.commit(); conn.close()
569
  return jsonify({'success': True})
570
  except Exception as e: return jsonify({'error': str(e)}), 500
templates/_revision_notes.html CHANGED
@@ -1,581 +1,213 @@
1
- {#
2
- Revision Notes Modal - Final Fix
3
- Fixes: Stylus Logic (via Native Pointer Capture), Object Eraser, UX
4
- #}
5
-
6
- {# ===== STYLES ===== #}
7
  <style>
8
- /* --- Layout --- */
9
- #notesModal .modal-body {
10
- padding: 0;
11
- background-color: #f0f2f5;
12
- overflow: hidden;
13
- user-select: none;
14
- }
15
-
16
- #notes-canvas-wrapper {
17
- width: 100%;
18
- height: 100%;
19
- background-color: #ffffff;
20
- background-image: radial-gradient(#ced4da 1px, transparent 1px);
21
- background-size: 24px 24px;
22
- /* CRITICAL: Disables browser zooming/scrolling so Pen works */
23
- touch-action: none;
24
- cursor: crosshair;
25
- position: relative;
26
- }
27
-
28
- /* --- Toolbar --- */
29
- .notes-toolbar {
30
- position: absolute;
31
- top: 24px;
32
- left: 50%;
33
- transform: translateX(-50%);
34
- display: flex;
35
- align-items: center;
36
- gap: 8px;
37
- padding: 8px 16px;
38
- border-radius: 100px;
39
- z-index: 1060;
40
- background: rgba(33, 37, 41, 0.9);
41
- backdrop-filter: blur(12px);
42
- -webkit-backdrop-filter: blur(12px);
43
- border: 1px solid rgba(255, 255, 255, 0.15);
44
- box-shadow: 0 10px 30px rgba(0,0,0,0.3);
45
- }
46
-
47
- /* --- Buttons --- */
48
- .tool-btn {
49
- width: 42px;
50
- height: 42px;
51
- border-radius: 50%;
52
- border: none;
53
- background: transparent;
54
- color: rgba(255,255,255,0.6);
55
- font-size: 1.2rem;
56
- display: flex;
57
- align-items: center;
58
- justify-content: center;
59
- transition: all 0.2s;
60
- }
61
-
62
- .tool-btn:hover {
63
- background: rgba(255,255,255,0.15);
64
- color: #fff;
65
- transform: translateY(-2px);
66
- }
67
-
68
- .tool-btn.active {
69
- background: var(--accent-primary, #0d6efd);
70
- color: white;
71
- box-shadow: 0 4px 15px rgba(13, 110, 253, 0.4);
72
- transform: scale(1.1);
73
- }
74
-
75
- /* Stylus Mode Active State */
76
- #btn-stylus.active {
77
- background: #198754; /* Green */
78
- box-shadow: 0 4px 15px rgba(25, 135, 84, 0.4);
79
- color: white;
80
- }
81
-
82
- .sep { width: 1px; height: 24px; background: rgba(255,255,255,0.2); margin: 0 6px; }
83
-
84
- .color-dot {
85
- width: 26px; height: 26px; border-radius: 50%; border: 2px solid transparent; cursor: pointer;
86
- }
87
- .color-dot.active { border-color: #fff; transform: scale(1.2); }
88
-
89
- /* --- Ref Panel --- */
90
- #ref-panel {
91
- position: absolute; bottom: 20px; right: 20px; width: 280px;
92
- background: #2b3035; border-radius: 12px; padding: 10px;
93
- z-index: 1050; box-shadow: 0 10px 30px rgba(0,0,0,0.3);
94
- transition: transform 0.3s ease;
95
- }
96
- #ref-panel.collapsed { transform: translateY(150%); }
97
- #ref-panel img { width: 100%; border-radius: 8px; border: 1px solid #495057; }
98
-
99
- .status-badge {
100
- position: absolute; bottom: 20px; left: 20px;
101
- background: rgba(0,0,0,0.7); color: white;
102
- padding: 5px 12px; border-radius: 20px;
103
- font-family: monospace; font-size: 0.85rem; pointer-events: none;
104
- }
105
  </style>
106
 
107
- {# ===== HTML ===== #}
108
  <div class="modal fade" id="notesModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
109
- <div class="modal-dialog modal-fullscreen">
110
- <div class="modal-content bg-dark">
111
- <div class="modal-body">
112
-
113
- <!-- Toolbar -->
114
- <div class="notes-toolbar">
115
- <button class="tool-btn active" id="btn-pencil" onclick="setTool('pencil')" title="Pencil">
116
- <i class="fas fa-pencil-alt"></i>
117
- </button>
118
- <button class="tool-btn" id="btn-highlighter" onclick="setTool('highlighter')" title="Highlighter">
119
- <i class="fas fa-highlighter"></i>
120
- </button>
121
- <button class="tool-btn" id="btn-eraser" onclick="setTool('eraser')" title="Object Eraser">
122
- <i class="fas fa-eraser"></i>
123
- </button>
124
-
125
- <div class="sep"></div>
126
-
127
- <div class="d-flex gap-2 mx-1">
128
- <div class="color-dot active" style="background:#212529" onclick="setColor('#212529', this)"></div>
129
- <div class="color-dot" style="background:#dc3545" onclick="setColor('#dc3545', this)"></div>
130
- <div class="color-dot" style="background:#0d6efd" onclick="setColor('#0d6efd', this)"></div>
131
- </div>
132
-
133
- <div class="sep"></div>
134
-
135
- <div class="dropdown">
136
- <button class="tool-btn" data-bs-toggle="dropdown"><i class="fas fa-shapes"></i></button>
137
- <ul class="dropdown-menu dropdown-menu-dark">
138
- <li><button class="dropdown-item" onclick="addShape('rect')">Rectangle</button></li>
139
- <li><button class="dropdown-item" onclick="addShape('circle')">Circle</button></li>
140
- <li><button class="dropdown-item" onclick="addShape('arrow')">Arrow</button></li>
141
- <li><button class="dropdown-item" onclick="addText()">Text</button></li>
142
- </ul>
143
- </div>
144
-
145
- <button class="tool-btn" id="btn-select" onclick="setTool('select')"><i class="fas fa-mouse-pointer"></i></button>
146
-
147
- <div class="sep"></div>
148
-
149
- <!-- Stylus Toggle -->
150
- <button class="tool-btn" id="btn-stylus" onclick="toggleStylus()" title="Stylus Only Mode">
151
- <i class="fas fa-pen-nib"></i>
152
- </button>
153
-
154
- <div class="sep"></div>
155
-
156
- <button class="tool-btn" onclick="undo()"><i class="fas fa-undo"></i></button>
157
- <button class="tool-btn text-success" onclick="saveNotes()"><i class="fas fa-check"></i></button>
158
- <button class="tool-btn text-secondary" data-bs-dismiss="modal"><i class="fas fa-times"></i></button>
159
- </div>
160
-
161
- <!-- Canvas Wrapper -->
162
- <div id="notes-canvas-wrapper">
163
- <canvas id="notes-canvas"></canvas>
164
- </div>
165
-
166
- <!-- Ref Panel -->
167
- <div id="ref-panel">
168
- <div class="d-flex justify-content-between align-items-center mb-1">
169
- <small class="text-white-50">Reference</small>
170
- <button class="btn btn-sm btn-link text-white-50 p-0" onclick="document.getElementById('ref-panel').classList.add('collapsed')"><i class="fas fa-chevron-down"></i></button>
171
- </div>
172
- <img id="notes-ref-img" src="">
173
- </div>
174
-
175
- <button class="btn btn-dark rounded-circle shadow position-absolute bottom-0 end-0 m-3" onclick="document.getElementById('ref-panel').classList.remove('collapsed')">
176
- <i class="fas fa-image"></i>
177
- </button>
178
-
179
- <div class="status-badge">
180
- <span id="debug-pointer">Mode: Touch Draw</span>
181
- </div>
182
- </div>
183
- </div>
184
- </div>
185
  </div>
 
 
 
 
 
 
 
 
 
 
 
186
 
187
- {# ===== JAVASCRIPT ===== #}
188
  <script>
189
- let canvas;
190
- let isStylusMode = false;
191
- let currentTool = 'pencil';
192
- let currentColor = '#212529';
193
- let activeImageId = null;
194
- let historyStack = [];
195
-
196
- // NATIVE POINTER TRACKING (The Fix)
197
- let lastPointerType = 'mouse';
198
- let isFingerPanning = false; // Track if we're currently panning with finger
199
-
200
- function openNotesModal(imageId, refUrl) {
201
- activeImageId = imageId;
202
- document.getElementById('notes-ref-img').src = refUrl;
203
- document.getElementById('ref-panel').classList.remove('collapsed');
204
-
205
- const modal = new bootstrap.Modal(document.getElementById('notesModal'));
206
- modal.show();
207
-
208
- // Init Fabric after modal is shown
209
- document.getElementById('notesModal').addEventListener('shown.bs.modal', () => {
210
- initFabric();
211
- }, { once: true });
212
- }
213
-
214
- function initFabric() {
215
- if (canvas) canvas.dispose();
216
- const wrapper = document.getElementById('notes-canvas-wrapper');
217
-
218
- // 1. Attach NATIVE Pointer Listener to wrapper
219
- // This runs before Fabric and tells us exactly what hardware is being used
220
- wrapper.addEventListener('pointerdown', (e) => {
221
- lastPointerType = e.pointerType; // 'mouse', 'pen', or 'touch'
222
- console.log("Hardware Detected:", lastPointerType);
223
-
224
- // If Eraser mode, we can handle deletion here for better responsiveness
225
- if (currentTool === 'eraser' && !canvas.isDragging) {
226
- // Find object under native event coordinates
227
- // We let Fabric's mouse:down handle it to ensure coordinates are transformed correctly
228
- }
229
- }, true);
230
-
231
- canvas = new fabric.Canvas('notes-canvas', {
232
- width: wrapper.clientWidth,
233
- height: wrapper.clientHeight,
234
- backgroundColor: '#ffffff',
235
- isDrawingMode: true,
236
- selection: false,
237
- preserveObjectStacking: true,
238
- perPixelTargetFind: true // Critical for Object Eraser precision
239
- });
240
-
241
- setupFabricEvents();
242
- setupZoom();
243
- setTool('pencil');
244
-
245
- // Always try to load existing note from server (in case page state is stale)
246
- loadNoteJson();
247
-
248
- // Handle Resize
249
- window.addEventListener('resize', () => {
250
- canvas.setWidth(wrapper.clientWidth);
251
- canvas.setHeight(wrapper.clientHeight);
252
- });
253
- }
254
-
255
- async function loadNoteJson() {
256
- try {
257
- console.log('Loading note for image:', activeImageId);
258
- const response = await fetch('/get_note_json/' + activeImageId);
259
- console.log('Response status:', response.status);
260
- if (response.ok) {
261
- const data = await response.json();
262
- console.log('Response data:', data);
263
- if (data.success && data.json_data) {
264
- const jsonData = typeof data.json_data === 'string' ? JSON.parse(data.json_data) : data.json_data;
265
- console.log('Parsed JSON:', jsonData);
266
- canvas.loadFromJSON(jsonData, () => {
267
- canvas.renderAll();
268
- console.log('Canvas loaded successfully');
269
- saveState();
270
- });
271
- return;
272
- }
273
- }
274
- } catch (e) {
275
- console.log('Error loading note:', e);
276
- }
277
- saveState();
278
- }
279
-
280
- function setupFabricEvents() {
281
- // Intercept path creation - in stylus mode, only keep paths from pen
282
- canvas.on('path:created', function(opt) {
283
- if (isStylusMode && lastPointerType !== 'pen') {
284
- // Remove paths created by finger/touch in stylus mode
285
- canvas.remove(opt.path);
286
- canvas.requestRenderAll();
287
- return;
288
- }
289
- if (isFingerPanning) {
290
- // Also remove if we're still in finger panning state
291
- canvas.remove(opt.path);
292
- canvas.requestRenderAll();
293
- return;
294
- }
295
- });
296
-
297
- canvas.on('mouse:down', function(opt) {
298
- const evt = opt.e;
299
-
300
- // --- STYLUS LOGIC ---
301
- if (isStylusMode) {
302
- // We ignore Fabric's event type and check our global 'lastPointerType'
303
- if (lastPointerType === 'touch') {
304
- // It is a finger -> PAN ONLY
305
- isFingerPanning = true;
306
- this.isDrawingMode = false;
307
- this.selection = false;
308
- this.isDragging = true;
309
- this.lastPosX = evt.clientX || (evt.touches && evt.touches[0]?.clientX) || 0;
310
- this.lastPosY = evt.clientY || (evt.touches && evt.touches[0]?.clientY) || 0;
311
-
312
- // Cancel any in-progress drawing
313
- if (this._isCurrentlyDrawing) {
314
- this._isCurrentlyDrawing = false;
315
- }
316
- return; // Stop here
317
- }
318
-
319
- if (lastPointerType === 'pen') {
320
- // It is a pen -> DRAW
321
- isFingerPanning = false;
322
- if (currentTool === 'pencil' || currentTool === 'highlighter') {
323
- this.isDrawingMode = true;
324
- }
325
- }
326
- }
327
- // --- TOUCH MODE (Default) ---
328
- else {
329
- isFingerPanning = false;
330
- // Multi-touch always pans
331
- if (evt.touches && evt.touches.length > 1) {
332
- this.isDrawingMode = false;
333
- this.isDragging = true;
334
- return;
335
- }
336
- }
337
-
338
- // --- ERASER LOGIC (Object) ---
339
- if (currentTool === 'eraser') {
340
- this.isDrawingMode = false;
341
- this.isErasing = true;
342
- deleteObjectUnderPointer(opt.e);
343
- }
344
- });
345
-
346
- canvas.on('mouse:move', function(opt) {
347
- if (this.isDragging) {
348
- const e = opt.e;
349
- const vpt = this.viewportTransform;
350
- const clientX = e.clientX || (e.touches && e.touches[0]?.clientX) || this.lastPosX;
351
- const clientY = e.clientY || (e.touches && e.touches[0]?.clientY) || this.lastPosY;
352
- vpt[4] += clientX - this.lastPosX;
353
- vpt[5] += clientY - this.lastPosY;
354
- this.requestRenderAll();
355
- this.lastPosX = clientX;
356
- this.lastPosY = clientY;
357
- }
358
- if (this.isErasing) {
359
- deleteObjectUnderPointer(opt.e);
360
- }
361
- });
362
-
363
- canvas.on('mouse:up', function() {
364
- const wasPanning = isFingerPanning;
365
- this.isDragging = false;
366
- this.isErasing = false;
367
- isFingerPanning = false;
368
-
369
- // Restore drawing mode if needed (only if not just finished finger panning)
370
- if ((currentTool === 'pencil' || currentTool === 'highlighter') && !wasPanning) {
371
- if (!isStylusMode || lastPointerType === 'pen') {
372
- canvas.isDrawingMode = true;
373
- }
374
- }
375
-
376
- // In stylus mode, keep drawing disabled until next pen touch
377
- if (isStylusMode && lastPointerType === 'touch') {
378
- canvas.isDrawingMode = false;
379
- }
380
-
381
- saveState();
382
- });
383
- }
384
-
385
- function deleteObjectUnderPointer(e) {
386
- // findTarget requires pointer event coordinates
387
- const target = canvas.findTarget(e, false);
388
- if (target) {
389
- canvas.remove(target);
390
- canvas.requestRenderAll();
391
- }
392
- }
393
-
394
- function setTool(name) {
395
- currentTool = name;
396
- document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
397
- document.getElementById(`btn-${name}`)?.classList.add('active');
398
-
399
- // Reset
400
- canvas.isDrawingMode = false;
401
- canvas.selection = false;
402
- canvas.defaultCursor = 'default';
403
- canvas.getObjects().forEach(o => { o.selectable = false; o.evented = false; });
404
-
405
- if (name === 'pencil') {
406
- // In stylus mode, keep drawing disabled until pen touch
407
- // In touch mode, enable immediately
408
- canvas.isDrawingMode = !isStylusMode;
409
- canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
410
- canvas.freeDrawingBrush.color = currentColor;
411
- canvas.freeDrawingBrush.width = 3;
412
- }
413
- else if (name === 'highlighter') {
414
- // In stylus mode, keep drawing disabled until pen touch
415
- canvas.isDrawingMode = !isStylusMode;
416
- canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
417
- const c = hexToRgb(currentColor);
418
- canvas.freeDrawingBrush.color = `rgba(${c.r},${c.g},${c.b},0.3)`;
419
- canvas.freeDrawingBrush.width = 20;
420
- }
421
- else if (name === 'eraser') {
422
- canvas.defaultCursor = 'crosshair';
423
- // Objects must be evented to be found by findTarget
424
- canvas.getObjects().forEach(o => o.evented = true);
425
- }
426
- else if (name === 'select') {
427
- canvas.selection = true;
428
- canvas.defaultCursor = 'move';
429
- canvas.getObjects().forEach(o => { o.selectable = true; o.evented = true; });
430
- }
431
- }
432
-
433
- function toggleStylus() {
434
- isStylusMode = !isStylusMode;
435
- const btn = document.getElementById('btn-stylus');
436
- const debug = document.getElementById('debug-pointer');
437
-
438
- if (isStylusMode) {
439
- btn.classList.add('active');
440
- debug.textContent = "Mode: Stylus Only (Fingers Pan)";
441
- debug.style.color = "#20c997";
442
- // Disable drawing mode - will be enabled on pen touch
443
- if (currentTool === 'pencil' || currentTool === 'highlighter') {
444
- canvas.isDrawingMode = false;
445
- }
446
- } else {
447
- btn.classList.remove('active');
448
- debug.textContent = "Mode: Touch Draw";
449
- debug.style.color = "white";
450
- // Re-enable drawing mode for touch
451
- if (currentTool === 'pencil' || currentTool === 'highlighter') {
452
- canvas.isDrawingMode = true;
453
- }
454
- }
455
- }
456
-
457
- function addShape(type) {
458
- setTool('select');
459
- const center = canvas.getVpCenter();
460
- const opts = { left:center.x, top:center.y, fill:'transparent', stroke:currentColor, strokeWidth:3, originX:'center', originY:'center' };
461
- let obj;
462
- if(type==='rect') obj = new fabric.Rect({...opts, width:100, height:80});
463
- if(type==='circle') obj = new fabric.Circle({...opts, radius:40});
464
- if(type==='arrow') obj = new fabric.Path('M 0 0 L 100 0 M 90 -10 L 100 0 L 90 10', {...opts, fill:null});
465
-
466
- if(obj) { canvas.add(obj); canvas.setActiveObject(obj); saveState(); }
467
- }
468
-
469
- function addText() {
470
- setTool('select');
471
- const center = canvas.getVpCenter();
472
- const t = new fabric.IText('Text', { left:center.x, top:center.y, fontSize:24, fill:currentColor });
473
- canvas.add(t); canvas.setActiveObject(t); t.enterEditing(); saveState();
474
- }
475
-
476
- function setColor(hex, el) {
477
- currentColor = hex;
478
- document.querySelectorAll('.color-dot').forEach(d => d.classList.remove('active'));
479
- if(el) el.classList.add('active');
480
- if(['pencil','highlighter'].includes(currentTool)) setTool(currentTool);
481
- }
482
-
483
- function setupZoom() {
484
- canvas.on('mouse:wheel', function(opt) {
485
- const delta = opt.e.deltaY;
486
- let zoom = canvas.getZoom();
487
- zoom *= 0.999 ** delta;
488
- if (zoom > 5) zoom = 5; if (zoom < 0.2) zoom = 0.2;
489
- canvas.zoomToPoint({ x: opt.e.offsetX, y: opt.e.offsetY }, zoom);
490
- opt.e.preventDefault(); opt.e.stopPropagation();
491
- });
492
- }
493
-
494
- function undo() {
495
- if(historyStack.length <= 1) return;
496
- historyStack.pop();
497
- canvas.loadFromJSON(historyStack[historyStack.length-1], canvas.renderAll.bind(canvas));
498
- }
499
-
500
- function saveState() {
501
- if(historyStack.length>10) historyStack.shift();
502
- historyStack.push(JSON.stringify(canvas));
503
- }
504
-
505
- async function saveNotes() {
506
- // Save as JSON AND rasterized PNG (needed for PDF/quiz display)
507
- const jsonData = JSON.stringify(canvas.toJSON());
508
-
509
- // Rasterize canvas to PNG data URL for PDF/quiz rendering
510
- const imageData = canvas.toDataURL({ format: 'png', multiplier: 2 });
511
-
512
- try {
513
- const response = await fetch('/save_note_json', {
514
- method: 'POST',
515
- headers: { 'Content-Type': 'application/json' },
516
- body: JSON.stringify({
517
- image_id: activeImageId,
518
- session_id: '{{ session_id }}',
519
- json_data: jsonData,
520
- image_data: imageData
521
- })
522
- });
523
-
524
- const result = await response.json();
525
- if (result.success) {
526
- // Close modal
527
- const modal = bootstrap.Modal.getInstance(document.getElementById('notesModal'));
528
- modal.hide();
529
- showStatus('Notes saved!', 'success');
530
- // Reload page to update the UI state
531
- setTimeout(() => location.reload(), 500);
532
- } else {
533
- showStatus('Error saving notes: ' + result.error, 'danger');
534
- }
535
- } catch (e) {
536
- showStatus('Error: ' + e.message, 'danger');
537
- }
538
- }
539
-
540
- function hexToRgb(hex) {
541
- const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
542
- return {r,g,b};
543
- }
544
-
545
- async function toggleNoteInPdf(imageId, include) {
546
- try {
547
- const response = await fetch('/toggle_note_in_pdf', {
548
- method: 'POST',
549
- headers: { 'Content-Type': 'application/json' },
550
- body: JSON.stringify({ image_id: imageId, include: include })
551
- });
552
- const result = await response.json();
553
- if (!result.success) {
554
- showStatus('Failed to update setting: ' + result.error, 'danger');
555
- }
556
- } catch (e) {
557
- showStatus('Error: ' + e.message, 'danger');
558
- }
559
- }
560
-
561
- async function deleteNote(imageId) {
562
- if (!confirm('Delete this note? This cannot be undone.')) return;
563
-
564
- try {
565
- const response = await fetch('/delete_note', {
566
- method: 'POST',
567
- headers: { 'Content-Type': 'application/json' },
568
- body: JSON.stringify({ image_id: imageId })
569
- });
570
- const result = await response.json();
571
- if (result.success) {
572
- showStatus('Note deleted', 'success');
573
- location.reload();
574
- } else {
575
- showStatus('Failed to delete note: ' + result.error, 'danger');
576
- }
577
- } catch (e) {
578
- showStatus('Error: ' + e.message, 'danger');
579
- }
580
- }
581
  </script>
 
1
+ {# Revision Notes Modal - v3 Compact #}
 
 
 
 
 
2
  <style>
3
+ #notesModal .modal-body{padding:0;overflow:hidden;user-select:none}
4
+ #ncw{width:100%;height:100%;background:#fff;background-image:radial-gradient(#ced4da 1px,transparent 1px);background-size:24px 24px;touch-action:none;cursor:crosshair;position:relative}
5
+ .ntb{position:absolute;top:16px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:6px;padding:6px 14px;border-radius:99px;z-index:1060;background:rgba(33,37,41,.92);backdrop-filter:blur(10px);box-shadow:0 8px 24px rgba(0,0,0,.3)}
6
+ .tb{width:38px;height:38px;border-radius:50%;border:0;background:0 0;color:rgba(255,255,255,.6);font-size:1.1rem;display:flex;align-items:center;justify-content:center;transition:.15s}
7
+ .tb:hover{background:rgba(255,255,255,.15);color:#fff}.tb.on{background:var(--ap,#0d6efd);color:#fff;box-shadow:0 3px 12px rgba(13,110,253,.4);transform:scale(1.08)}
8
+ .tb.sty{background:#198754!important;box-shadow:0 3px 12px rgba(25,135,84,.4)!important}
9
+ .sp{width:1px;height:22px;background:rgba(255,255,255,.2);margin:0 4px}
10
+ .cd{width:24px;height:24px;border-radius:50%;border:2px solid transparent;cursor:pointer;transition:.15s}.cd.on{border-color:#fff;transform:scale(1.2)}
11
+ #rpnl{position:absolute;bottom:16px;right:16px;width:260px;background:#2b3035;border-radius:10px;padding:8px;z-index:1050;box-shadow:0 8px 24px rgba(0,0,0,.3);transition:transform .25s}.rpnl-h{transform:translateY(150%)}
12
+ #rpnl img{width:100%;border-radius:6px;border:1px solid #495057}
13
+ #dbg{position:absolute;bottom:16px;left:16px;background:rgba(0,0,0,.7);color:#fff;padding:4px 10px;border-radius:16px;font:.8rem monospace;pointer-events:none}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </style>
15
 
 
16
  <div class="modal fade" id="notesModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
17
+ <div class="modal-dialog modal-fullscreen"><div class="modal-content bg-dark"><div class="modal-body">
18
+ <div class="ntb">
19
+ <button class="tb on" data-t="pencil" title="Pencil"><i class="fas fa-pencil-alt"></i></button>
20
+ <button class="tb" data-t="highlighter" title="Highlighter"><i class="fas fa-highlighter"></i></button>
21
+ <button class="tb" data-t="eraser" title="Eraser"><i class="fas fa-eraser"></i></button>
22
+ <div class="sp"></div>
23
+ <div class="d-flex gap-1">
24
+ <div class="cd on" data-c="#212529" style="background:#212529"></div>
25
+ <div class="cd" data-c="#dc3545" style="background:#dc3545"></div>
26
+ <div class="cd" data-c="#0d6efd" style="background:#0d6efd"></div>
27
+ </div>
28
+ <div class="sp"></div>
29
+ <div class="dropdown">
30
+ <button class="tb" data-bs-toggle="dropdown"><i class="fas fa-shapes"></i></button>
31
+ <ul class="dropdown-menu dropdown-menu-dark">
32
+ <li><button class="dropdown-item" data-sh="rect">Rectangle</button></li>
33
+ <li><button class="dropdown-item" data-sh="circle">Circle</button></li>
34
+ <li><button class="dropdown-item" data-sh="arrow">Arrow</button></li>
35
+ <li><button class="dropdown-item" data-sh="text">Text</button></li>
36
+ </ul>
37
+ </div>
38
+ <button class="tb" data-t="select"><i class="fas fa-mouse-pointer"></i></button>
39
+ <div class="sp"></div>
40
+ <button class="tb" id="bsty" title="Stylus Mode"><i class="fas fa-pen-nib"></i></button>
41
+ <div class="sp"></div>
42
+ <button class="tb" id="bundo"><i class="fas fa-undo"></i></button>
43
+ <button class="tb text-success" id="bsave"><i class="fas fa-check"></i></button>
44
+ <button class="tb text-secondary" data-bs-dismiss="modal"><i class="fas fa-times"></i></button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  </div>
46
+ <div id="ncw"><canvas id="nc"></canvas></div>
47
+ <div id="rpnl">
48
+ <div class="d-flex justify-content-between align-items-center mb-1">
49
+ <small class="text-white-50">Reference</small>
50
+ <button class="btn btn-sm btn-link text-white-50 p-0" id="rpnl-hide"><i class="fas fa-chevron-down"></i></button>
51
+ </div>
52
+ <img id="nref" src="">
53
+ </div>
54
+ <button class="btn btn-dark rounded-circle shadow position-absolute bottom-0 end-0 m-3" id="rpnl-show"><i class="fas fa-image"></i></button>
55
+ <div id="dbg">Touch Draw</div>
56
+ </div></div></div></div>
57
 
 
58
  <script>
59
+ (function(){
60
+ const $ = s => document.querySelector(s), $$ = s => document.querySelectorAll(s);
61
+ let C, tool='pencil', color='#212529', imgId, hist=[], styMode=false, ptrType='mouse', fingerPan=false;
62
+
63
+ window.openNotesModal = function(id, ref) {
64
+ imgId = id; $('#nref').src = ref;
65
+ $('#rpnl').classList.remove('rpnl-h');
66
+ const m = new bootstrap.Modal($('#notesModal'));
67
+ m.show();
68
+ $('#notesModal').addEventListener('shown.bs.modal', init, {once:true});
69
+ };
70
+
71
+ function init() {
72
+ C && C.dispose();
73
+ const w = $('#ncw');
74
+ w.addEventListener('pointerdown', e => { ptrType = e.pointerType; }, true);
75
+ C = new fabric.Canvas('nc', {
76
+ width: w.clientWidth, height: w.clientHeight,
77
+ backgroundColor:'#fff', isDrawingMode:true, selection:false,
78
+ preserveObjectStacking:true, perPixelTargetFind:true
79
+ });
80
+ wire(); zoom(); setT('pencil'); load();
81
+ window.onresize = () => { C.setWidth(w.clientWidth); C.setHeight(w.clientHeight); };
82
+ }
83
+
84
+ async function load() {
85
+ try {
86
+ const r = await fetch('/get_note_json/'+imgId);
87
+ if(r.ok){ const d=await r.json(); if(d.success&&d.json_data){
88
+ const j = typeof d.json_data==='string'?JSON.parse(d.json_data):d.json_data;
89
+ C.loadFromJSON(j,()=>{ C.renderAll(); snap(); }); return;
90
+ }}
91
+ } catch(e){}
92
+ snap();
93
+ }
94
+
95
+ function wire() {
96
+ C.on('path:created', o => {
97
+ if((styMode && ptrType!=='pen') || fingerPan){ C.remove(o.path); C.requestRenderAll(); }
98
+ });
99
+ C.on('mouse:down', function(o){
100
+ const e = o.e;
101
+ if(styMode && ptrType==='touch'){
102
+ fingerPan=true; this.isDrawingMode=false; this.selection=false;
103
+ this.isDragging=true; this.lastPosX=e.clientX||0; this.lastPosY=e.clientY||0; return;
104
+ }
105
+ if(styMode && ptrType==='pen' && (tool==='pencil'||tool==='highlighter')) this.isDrawingMode=true;
106
+ if(!styMode && e.touches && e.touches.length>1){ this.isDrawingMode=false; this.isDragging=true; return; }
107
+ if(tool==='eraser'){ this.isDrawingMode=false; this.isErasing=true; eraseAt(e); }
108
+ });
109
+ C.on('mouse:move', function(o){
110
+ if(this.isDragging){
111
+ const e=o.e, v=this.viewportTransform,
112
+ x=e.clientX||(e.touches&&e.touches[0]?.clientX)||this.lastPosX,
113
+ y=e.clientY||(e.touches&&e.touches[0]?.clientY)||this.lastPosY;
114
+ v[4]+=x-this.lastPosX; v[5]+=y-this.lastPosY;
115
+ this.requestRenderAll(); this.lastPosX=x; this.lastPosY=y;
116
+ }
117
+ if(this.isErasing) eraseAt(o.e);
118
+ });
119
+ C.on('mouse:up', function(){
120
+ const wasPan=fingerPan;
121
+ this.isDragging=false; this.isErasing=false; fingerPan=false;
122
+ if((tool==='pencil'||tool==='highlighter')&&!wasPan&&(!styMode||ptrType==='pen')) C.isDrawingMode=true;
123
+ if(styMode&&ptrType==='touch') C.isDrawingMode=false;
124
+ snap();
125
+ });
126
+ }
127
+
128
+ function eraseAt(e){ const t=C.findTarget(e,false); if(t){ C.remove(t); C.requestRenderAll(); }}
129
+
130
+ function setT(t){
131
+ tool=t;
132
+ $$('.tb[data-t]').forEach(b=>b.classList.toggle('on',b.dataset.t===t));
133
+ C.isDrawingMode=false; C.selection=false; C.defaultCursor='default';
134
+ C.getObjects().forEach(o=>{ o.selectable=false; o.evented=false; });
135
+ if(t==='pencil'||t==='highlighter'){
136
+ C.isDrawingMode=!styMode;
137
+ C.freeDrawingBrush=new fabric.PencilBrush(C);
138
+ if(t==='highlighter'){ const c=h2r(color); C.freeDrawingBrush.color=`rgba(${c.r},${c.g},${c.b},.3)`; C.freeDrawingBrush.width=20; }
139
+ else{ C.freeDrawingBrush.color=color; C.freeDrawingBrush.width=3; }
140
+ } else if(t==='eraser'){
141
+ C.defaultCursor='crosshair'; C.getObjects().forEach(o=>o.evented=true);
142
+ } else if(t==='select'){
143
+ C.selection=true; C.defaultCursor='move'; C.getObjects().forEach(o=>{ o.selectable=true; o.evented=true; });
144
+ }
145
+ }
146
+
147
+ function setC(hex,el){ color=hex; $$('.cd').forEach(d=>d.classList.toggle('on',d===el)); if(tool==='pencil'||tool==='highlighter') setT(tool); }
148
+
149
+ function togSty(){
150
+ styMode=!styMode;
151
+ const b=$('#bsty'), d=$('#dbg');
152
+ b.classList.toggle('on',styMode); b.classList.toggle('sty',styMode);
153
+ d.textContent=styMode?'Stylus Only':'Touch Draw'; d.style.color=styMode?'#20c997':'#fff';
154
+ if(tool==='pencil'||tool==='highlighter') C.isDrawingMode=!styMode;
155
+ }
156
+
157
+ function addSh(type){
158
+ setT('select'); const p=C.getVpCenter(),
159
+ o={left:p.x,top:p.y,fill:'transparent',stroke:color,strokeWidth:3,originX:'center',originY:'center'};
160
+ let s;
161
+ if(type==='rect') s=new fabric.Rect({...o,width:100,height:80});
162
+ if(type==='circle') s=new fabric.Circle({...o,radius:40});
163
+ if(type==='arrow') s=new fabric.Path('M0 0L100 0M90-10L100 0L90 10',{...o,fill:null});
164
+ if(type==='text'){ s=new fabric.IText('Text',{left:p.x,top:p.y,fontSize:24,fill:color}); C.add(s); C.setActiveObject(s); s.enterEditing(); snap(); return; }
165
+ if(s){ C.add(s); C.setActiveObject(s); snap(); }
166
+ }
167
+
168
+ function zoom(){
169
+ C.on('mouse:wheel',function(o){
170
+ let z=C.getZoom()*(.999**o.e.deltaY);
171
+ z=Math.min(5,Math.max(.2,z));
172
+ C.zoomToPoint({x:o.e.offsetX,y:o.e.offsetY},z);
173
+ o.e.preventDefault(); o.e.stopPropagation();
174
+ });
175
+ }
176
+
177
+ function snap(){ if(hist.length>20) hist.shift(); hist.push(JSON.stringify(C)); }
178
+ function undo(){ if(hist.length<=1) return; hist.pop(); C.loadFromJSON(hist[hist.length-1],C.renderAll.bind(C)); }
179
+
180
+ async function save(){
181
+ const json=JSON.stringify(C.toJSON()), img=C.toDataURL({format:'png',multiplier:2});
182
+ try{
183
+ const r=await fetch('/save_note_json',{method:'POST',headers:{'Content-Type':'application/json'},
184
+ body:JSON.stringify({image_id:imgId,session_id:'{{ session_id }}',json_data:json,image_data:img})});
185
+ const d=await r.json();
186
+ if(d.success){ bootstrap.Modal.getInstance($('#notesModal')).hide(); showStatus('Saved!','success'); setTimeout(()=>location.reload(),400); }
187
+ else showStatus('Error: '+d.error,'danger');
188
+ }catch(e){ showStatus('Error: '+e.message,'danger'); }
189
+ }
190
+
191
+ function h2r(h){ return{r:parseInt(h.slice(1,3),16),g:parseInt(h.slice(3,5),16),b:parseInt(h.slice(5,7),16)}; }
192
+
193
+ // Event delegation
194
+ document.addEventListener('click', e => {
195
+ const t=e.target.closest('[data-t]'); if(t&&C){ setT(t.dataset.t); return; }
196
+ const c=e.target.closest('.cd'); if(c&&C){ setC(c.dataset.c,c); return; }
197
+ const s=e.target.closest('[data-sh]'); if(s&&C){ addSh(s.dataset.sh); return; }
198
+ if(e.target.closest('#bsty')){ togSty(); return; }
199
+ if(e.target.closest('#bundo')){ undo(); return; }
200
+ if(e.target.closest('#bsave')){ save(); return; }
201
+ if(e.target.closest('#rpnl-hide')){ $('#rpnl').classList.add('rpnl-h'); return; }
202
+ if(e.target.closest('#rpnl-show')){ $('#rpnl').classList.remove('rpnl-h'); return; }
203
+ });
204
+
205
+ window.toggleNoteInPdf = async(id,inc)=>{
206
+ try{ const r=await fetch('/toggle_note_in_pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({image_id:id,include:inc})}); const d=await r.json(); if(!d.success) showStatus('Error: '+d.error,'danger'); }catch(e){ showStatus(e.message,'danger'); }
207
+ };
208
+ window.deleteNote = async id=>{
209
+ if(!confirm('Delete this note?')) return;
210
+ try{ const r=await fetch('/delete_note',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({image_id:id})}); const d=await r.json(); if(d.success){ showStatus('Deleted','success'); location.reload(); } else showStatus(d.error,'danger'); }catch(e){ showStatus(e.message,'danger'); }
211
+ };
212
+ })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  </script>
templates/dashboard.html CHANGED
@@ -113,23 +113,23 @@
113
  <td>
114
  <div class="btn-group" role="group">
115
  {% if session.session_type == 'neetprep_collection' %}
116
- <a href="{{ url_for('neetprep_bp.view_collection', session_id=session.id) }}" class="btn btn-sm btn-warning text-dark"><i class="bi bi-bookmark-fill me-1"></i>View</a>
117
- <button class="btn btn-sm btn-success generate-collection-pdf-btn" data-session-id="{{ session.id }}"><i class="bi bi-file-pdf me-1"></i>PDF</button>
118
- <button class="btn btn-sm btn-info toggle-persist-btn">Toggle Persist</button>
119
  {% elif session.session_type == 'color_rm' %}
120
  <a href="{{ url_for('main.color_rm_interface', session_id=session.id, image_index=0) }}" class="btn btn-sm btn-warning text-dark">Color RM</a>
121
  <button class="btn btn-sm btn-info toggle-persist-btn">Toggle Persist</button>
122
- {% if show_size %}
123
- <button class="btn btn-sm btn-warning reduce-space-btn">Reduce Space</button>
124
- {% endif %}
125
  {% else %}
126
- <a href="{{ url_for('main.question_entry_v2', session_id=session.id) }}" class="btn btn-sm btn-primary">View</a>
127
- <a href="{{ url_for('main.crop_interface_v2', session_id=session.id, image_index=0) }}" class="btn btn-sm btn-secondary">Crop</a>
128
- <button class="btn btn-sm btn-outline-success duplicate-session-btn" data-session-id="{{ session.id }}" title="Duplicate as NEETprep Collection"><i class="bi bi-copy"></i></button>
129
- <button class="btn btn-sm btn-info toggle-persist-btn">Toggle Persist</button>
130
- {% if show_size %}
131
- <button class="btn btn-sm btn-warning reduce-space-btn">Reduce Space</button>
 
132
  {% endif %}
 
 
133
  {% endif %}
134
  </div>
135
  </td>
@@ -387,35 +387,6 @@ $(document).ready(function() {
387
  }
388
  });
389
  });
390
-
391
- // Duplicate session as collection
392
- $('.duplicate-session-btn').on('click', function() {
393
- const button = $(this);
394
- const sessionId = button.data('session-id');
395
-
396
- if (confirm('Duplicate this session as a NEETprep collection? Only classified questions (subject + topic) will be included.')) {
397
- button.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
398
-
399
- $.ajax({
400
- url: `/neetprep/collections/duplicate/${sessionId}`,
401
- type: 'POST',
402
- success: function(response) {
403
- if (response.success) {
404
- alert(`Successfully duplicated! Created collection "${response.name}" with ${response.count} questions.`);
405
- location.reload();
406
- } else {
407
- alert('Error duplicating session: ' + response.error);
408
- button.prop('disabled', false).html('<i class="bi bi-copy"></i>');
409
- }
410
- },
411
- error: function(xhr) {
412
- const err = xhr.responseJSON ? xhr.responseJSON.error : 'Unknown error';
413
- alert('Error duplicating session: ' + err);
414
- button.prop('disabled', false).html('<i class="bi bi-copy"></i>');
415
- }
416
- });
417
- }
418
- });
419
  });
420
  </script>
421
  {% endblock %}
 
113
  <td>
114
  <div class="btn-group" role="group">
115
  {% if session.session_type == 'neetprep_collection' %}
116
+ <a href="{{ url_for('neetprep_bp.view_collection', session_id=session.id) }}" class="btn btn-sm btn-warning text-dark" title="View Collection"><i class="bi bi-eye-fill me-1"></i>View Collection</a>
117
+ <a href="{{ url_for('main.question_entry_v2', session_id=session.id) }}" class="btn btn-sm btn-primary" title="View Questions"><i class="bi bi-list-check me-1"></i>View</a>
118
+ <button class="btn btn-sm btn-info toggle-persist-btn" title="Toggle Persistence"><i class="bi bi-shield-lock"></i></button>
119
  {% elif session.session_type == 'color_rm' %}
120
  <a href="{{ url_for('main.color_rm_interface', session_id=session.id, image_index=0) }}" class="btn btn-sm btn-warning text-dark">Color RM</a>
121
  <button class="btn btn-sm btn-info toggle-persist-btn">Toggle Persist</button>
 
 
 
122
  {% else %}
123
+ <a href="{{ url_for('main.question_entry_v2', session_id=session.id) }}" class="btn btn-sm btn-primary" title="View Questions"><i class="bi bi-list-check me-1"></i>View</a>
124
+ <a href="{{ url_for('main.crop_interface_v2', session_id=session.id, image_index=0) }}" class="btn btn-sm btn-secondary" title="Crop"><i class="bi bi-pencil"></i></a>
125
+
126
+ {% if session.has_classified %}
127
+ <a href="{{ url_for('neetprep_bp.view_collection', session_id=session.id) }}" class="btn btn-sm btn-warning fw-bold text-dark shadow-sm border-0" style="background: linear-gradient(135deg, #ffcc33 0%, #ffb300 100%);" title="View as Collection">
128
+ <i class="bi bi-star-fill me-1"></i>Collection
129
+ </a>
130
  {% endif %}
131
+
132
+ <button class="btn btn-sm btn-info toggle-persist-btn" title="Toggle Persistence"><i class="bi bi-shield-lock"></i></button>
133
  {% endif %}
134
  </div>
135
  </td>
 
387
  }
388
  });
389
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  });
391
  </script>
392
  {% endblock %}
templates/question_entry_v2.html CHANGED
@@ -95,8 +95,13 @@
95
  color: #fff;
96
  }
97
 
98
- .ts-dropdown .option:hover, .ts-dropdown .active {
99
- background: var(--border-subtle);
 
 
 
 
 
100
  }
101
 
102
  /* --- UNIFIED NOTE CARD STYLES --- */
@@ -172,15 +177,8 @@
172
 
173
  <form id="questions-form">
174
 
175
-
176
- {% if not nvidia_nim_available %}
177
- <div class="alert alert-warning alert-dismissible fade show" role="alert">
178
- <i class="bi bi-exclamation-triangle me-2"></i>
179
- NVIDIA NIM OCR feature is not available. To enable automatic question number extraction, please set the <code>NVIDIA_API_KEY</code> environment variable.
180
- <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
181
- </div>
182
- {% else %}
183
- <div class="mb-3">
184
  <button id="auto-extract-all" class="btn btn-primary">
185
  <span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
186
  <span class="extract-text">Auto Extract All Question Numbers</span>
@@ -189,11 +187,12 @@
189
  <span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
190
  <span class="extract-text">Extract & Classify All</span>
191
  </button>
192
- <button type="button" class="btn btn-warning ms-2" data-bs-toggle="modal" data-bs-target="#manualClassificationModal">
193
- <i class="bi bi-list-check"></i> Manual Classification
 
 
194
  </button>
195
  </div>
196
- {% endif %}
197
 
198
  {% for image in images %}
199
  <fieldset class="row mb-4 border-bottom pb-3" data-question-index="{{ loop.index0 }}" id="question-fieldset-{{ image.id }}">
@@ -529,10 +528,11 @@
529
  <div class="mb-3">
530
  <label class="form-label small text-muted mb-2">Subject</label>
531
  <div id="subject_pills" class="d-flex flex-wrap gap-2">
532
- <button type="button" class="btn btn-sm btn-outline-success subject-pill" data-subject="Biology">Biology</button>
533
- <button type="button" class="btn btn-sm btn-outline-warning subject-pill" data-subject="Chemistry">Chemistry</button>
534
- <button type="button" class="btn btn-sm btn-outline-info subject-pill" data-subject="Physics">Physics</button>
535
- <button type="button" class="btn btn-sm btn-outline-danger subject-pill" data-subject="Mathematics">Maths</button>
 
536
  </div>
537
  </div>
538
 
@@ -541,6 +541,9 @@
541
  <label class="form-label small text-muted mb-2">Chapter / Topic</label>
542
  <div class="input-group input-group-lg">
543
  <input type="text" id="topic_input" class="form-control bg-dark text-white border-secondary" placeholder="Start typing or use AI...">
 
 
 
544
  <button class="btn btn-info" type="button" id="btn_get_suggestion" onclick="getAiSuggestion()">
545
  <span class="spinner-border spinner-border-sm d-none" role="status"></span>
546
  <i class="bi bi-stars"></i>
@@ -550,6 +553,7 @@
550
 
551
  <!-- Suggestions -->
552
  <div id="ai_suggestion_container" class="d-none">
 
553
  <div id="ai_suggestion_chips" class="d-flex flex-wrap gap-2"></div>
554
  </div>
555
  <div id="ai_error_msg" class="alert alert-danger d-none mt-2 py-2"></div>
@@ -1347,6 +1351,15 @@
1347
  let selectedRangeMode = 'all'; // 'all', 'unclassified', 'wrong', 'unattempted', 'custom'
1348
  let customRanges = []; // Array of {start, end, subject} for custom slider ranges
1349
  let questionSubjectMap = new Map(); // Maps question index to pre-selected subject
 
 
 
 
 
 
 
 
 
1350
 
1351
  // Get question image URL by index
1352
  function getQuestionImageUrl(index) {
@@ -1512,7 +1525,8 @@
1512
 
1513
  // Subject detection keywords
1514
  const SUBJECT_KEYWORDS = {
1515
- 'Biology': ['cell', 'dna', 'rna', 'protein', 'enzyme', 'mitosis', 'meiosis', 'chromosome', 'gene', 'photosynthesis', 'respiration', 'nucleus', 'cytoplasm', 'membrane', 'tissue', 'organ', 'species', 'evolution', 'ecology', 'bacteria', 'virus', 'plant', 'animal', 'blood', 'heart', 'kidney', 'liver', 'neuron', 'hormone', 'digestion', 'reproduction', 'inheritance', 'mutation', 'allele', 'phenotype', 'genotype', 'ecosystem', 'biodiversity', 'nitrogen cycle', 'carbon cycle', 'food chain', 'lymph', 'antibody', 'antigen', 'vaccine', 'pathogen', 'biomolecule', 'carbohydrate', 'lipid', 'amino acid', 'nucleotide', 'atp', 'chlorophyll', 'stomata', 'xylem', 'phloem', 'transpiration', 'pollination', 'fertilization', 'embryo', 'zygote', 'gamete', 'ovary', 'testis', 'sperm', 'ovum', 'menstrual', 'placenta', 'umbilical', 'gestation', 'lactation', 'immunology', 'homeostasis'],
 
1516
  'Chemistry': ['atom', 'molecule', 'ion', 'electron', 'proton', 'neutron', 'orbital', 'bond', 'covalent', 'ionic', 'metallic', 'oxidation', 'reduction', 'redox', 'acid', 'base', 'ph', 'salt', 'solution', 'concentration', 'mole', 'molarity', 'stoichiometry', 'equilibrium', 'catalyst', 'reaction', 'organic', 'inorganic', 'hydrocarbon', 'alkane', 'alkene', 'alkyne', 'alcohol', 'aldehyde', 'ketone', 'carboxylic', 'ester', 'amine', 'amide', 'polymer', 'isomer', 'electrolysis', 'electrochemical', 'thermodynamics', 'enthalpy', 'entropy', 'gibbs', 'periodic table', 'atomic number', 'mass number', 'isotope', 'valence', 'hybridization', 'resonance', 'aromaticity', 'benzene', 'phenol', 'ether', 'haloalkane', 'grignard', 'nucleophile', 'electrophile', 'sn1', 'sn2', 'elimination', 'addition', 'substitution', 'coordination', 'ligand', 'crystal field', 'lanthanide', 'actinide', 'd-block', 'p-block', 's-block', 'f-block', 'buffer', 'titration', 'indicator', 'solubility', 'precipitation', 'colligative', 'osmotic', 'vapour pressure', 'raoult', 'henry', 'nernst', 'faraday', 'electrochemistry', 'galvanic', 'electrolytic'],
1517
  'Physics': ['force', 'mass', 'acceleration', 'velocity', 'momentum', 'energy', 'work', 'power', 'newton', 'gravity', 'friction', 'tension', 'torque', 'angular', 'rotational', 'oscillation', 'wave', 'frequency', 'wavelength', 'amplitude', 'sound', 'light', 'optics', 'lens', 'mirror', 'reflection', 'refraction', 'diffraction', 'interference', 'polarization', 'electric', 'current', 'voltage', 'resistance', 'capacitor', 'inductor', 'magnetic', 'electromagnetic', 'induction', 'transformer', 'generator', 'motor', 'circuit', 'ohm', 'kirchhoff', 'coulomb', 'gauss', 'ampere', 'faraday', 'lenz', 'maxwell', 'photoelectric', 'quantum', 'photon', 'electron', 'nucleus', 'radioactive', 'decay', 'fission', 'fusion', 'relativity', 'thermodynamics', 'heat', 'temperature', 'entropy', 'carnot', 'adiabatic', 'isothermal', 'isobaric', 'isochoric', 'kinetic theory', 'ideal gas', 'real gas', 'semiconductor', 'diode', 'transistor', 'logic gate', 'communication', 'modulation', 'satellite', 'doppler', 'spectrum', 'laser', 'holography', 'fibre optic', 'ray optics', 'wave optics', 'young', 'single slit', 'double slit', 'grating', 'brewster', 'malus', 'huygen'],
1518
  'Mathematics': ['equation', 'function', 'derivative', 'integral', 'limit', 'matrix', 'vector', 'determinant', 'polynomial', 'quadratic', 'linear', 'differential', 'probability', 'statistics', 'mean', 'median', 'variance', 'standard deviation', 'permutation', 'combination', 'trigonometry', 'sine', 'cosine', 'tangent', 'logarithm', 'exponential', 'complex number', 'real number', 'set', 'relation', 'sequence', 'series', 'arithmetic progression', 'geometric progression', 'binomial', 'conic', 'parabola', 'ellipse', 'hyperbola', 'circle', 'straight line', 'plane', 'three dimensional', 'coordinate', 'calculus', 'continuity', 'differentiability', 'maxima', 'minima', 'area under curve', 'definite integral', 'indefinite integral', 'inverse trigonometric', 'mathematical induction', 'boolean algebra']
@@ -1521,6 +1535,7 @@
1521
  // All NCERT chapters for autocomplete
1522
  const ALL_CHAPTERS = {
1523
  'Biology': ['The Living World', 'Biological Classification', 'Plant Kingdom', 'Animal Kingdom', 'Morphology of Flowering Plants', 'Anatomy of Flowering Plants', 'Structural Organisation in Animals', 'Cell: The Unit of Life', 'Biomolecules', 'Cell Cycle and Cell Division', 'Photosynthesis in Higher Plants', 'Respiration in Plants', 'Plant Growth and Development', 'Breathing and Exchange of Gases', 'Body Fluids and Circulation', 'Excretory Products and their Elimination', 'Locomotion and Movement', 'Neural Control and Coordination', 'Chemical Coordination and Integration', 'Reproduction in Organisms', 'Sexual Reproduction in Flowering Plants', 'Human Reproduction', 'Reproductive Health', 'Principles of Inheritance and Variation', 'Molecular Basis of Inheritance', 'Evolution', 'Human Health and Disease', 'Strategies for Enhancement in Food Production', 'Microbes in Human Welfare', 'Biotechnology: Principles and Processes', 'Biotechnology and its Applications', 'Organisms and Populations', 'Ecosystem', 'Biodiversity and Conservation', 'Environmental Issues', 'Transport in Plants', 'Mineral Nutrition', 'Digestion and Absorption'],
 
1524
  'Chemistry': ['Some Basic Concepts of Chemistry', 'Structure of Atom', 'Classification of Elements and Periodicity in Properties', 'Chemical Bonding and Molecular Structure', 'Thermodynamics', 'Equilibrium', 'Redox Reactions', 'Organic Chemistry – Some Basic Principles and Techniques (GOC)', 'Hydrocarbons', 'Hydrogen', 'The s-Block Elements', 'The p-Block Elements', 'The d- and f-Block Elements', 'Coordination Compounds', 'Haloalkanes and Haloarenes', 'Alcohols, Phenols and Ethers', 'Aldehydes, Ketones and Carboxylic Acids', 'Amines', 'Biomolecules', 'Polymers', 'Chemistry in Everyday Life', 'Electrochemistry', 'Chemical Kinetics', 'Surface Chemistry', 'General Principles and Processes of Isolation of Elements', 'Solutions', 'Solid State', 'States of Matter'],
1525
  'Physics': ['Physical World', 'Units and Measurements', 'Motion in a Straight Line', 'Motion in a Plane', 'Laws of Motion', 'Work, Energy and Power', 'System of Particles and Rotational Motion', 'Gravitation', 'Mechanical Properties of Solids', 'Mechanical Properties of Fluids', 'Thermal Properties of Matter', 'Thermodynamics', 'Kinetic Theory', 'Oscillations', 'Waves', 'Electric Charges and Fields', 'Electrostatic Potential and Capacitance', 'Current Electricity', 'Moving Charges and Magnetism', 'Magnetism and Matter', 'Electromagnetic Induction', 'Alternating Current', 'Electromagnetic Waves', 'Ray Optics and Optical Instruments', 'Wave Optics', 'Dual Nature of Radiation and Matter', 'Atoms', 'Nuclei', 'Semiconductor Electronics', 'Communication Systems'],
1526
  'Mathematics': ['Sets', 'Relations and Functions', 'Trigonometric Functions', 'Complex Numbers and Quadratic Equations', 'Linear Inequalities', 'Permutations and Combinations', 'Binomial Theorem', 'Sequences and Series', 'Straight Lines', 'Conic Sections', 'Introduction to Three Dimensional Geometry', 'Limits and Derivatives', 'Statistics', 'Probability', 'Matrices', 'Determinants', 'Continuity and Differentiability', 'Applications of Derivatives', 'Integrals', 'Applications of Integrals', 'Differential Equations', 'Vector Algebra', 'Three Dimensional Geometry', 'Linear Programming', 'Inverse Trigonometric Functions', 'Mathematical Reasoning', 'Principle of Mathematical Induction']
@@ -1554,11 +1569,17 @@
1554
  function setActiveSubject(subject) {
1555
  manualSubject = subject;
1556
  document.querySelectorAll('.subject-pill').forEach(pill => {
1557
- pill.classList.remove('active', 'btn-success', 'btn-warning', 'btn-info', 'btn-danger');
1558
- pill.classList.add('btn-outline-success', 'btn-outline-warning', 'btn-outline-info', 'btn-outline-danger');
1559
  if (pill.dataset.subject === subject) {
1560
- pill.classList.remove('btn-outline-success', 'btn-outline-warning', 'btn-outline-info', 'btn-outline-danger');
1561
- const colorMap = { 'Biology': 'btn-success', 'Chemistry': 'btn-warning', 'Physics': 'btn-info', 'Mathematics': 'btn-danger' };
 
 
 
 
 
 
1562
  pill.classList.add('active', colorMap[subject] || 'btn-primary');
1563
  }
1564
  });
@@ -1871,10 +1892,16 @@
1871
  document.getElementById('ai_suggestion_chips').innerHTML = '';
1872
  document.getElementById('ai_error_msg').classList.add('d-none');
1873
 
1874
- // Reset button
1875
- const btn = document.getElementById('btn_get_suggestion');
1876
- btn.disabled = false;
1877
- btn.innerHTML = `<span class="spinner-border spinner-border-sm d-none" role="status"></span> <i class="bi bi-stars"></i>`;
 
 
 
 
 
 
1878
 
1879
  // Auto-load suggestions if topic is empty
1880
  if (!document.getElementById('topic_input').value) {
@@ -1937,6 +1964,12 @@
1937
  const chipsContainer = document.getElementById('ai_suggestion_chips');
1938
  const errorDiv = document.getElementById('ai_error_msg');
1939
 
 
 
 
 
 
 
1940
  btn.disabled = true;
1941
  btn.querySelector('.spinner-border').classList.remove('d-none');
1942
  errorDiv.classList.add('d-none');
@@ -1994,15 +2027,25 @@
1994
  result.suggestions.forEach(suggestion => {
1995
  const chip = document.createElement('button');
1996
  chip.className = 'btn btn-outline-info btn-sm rounded-1';
1997
- chip.innerText = suggestion;
1998
  chip.onclick = () => {
1999
  document.getElementById('topic_input').value = suggestion;
 
 
 
 
 
 
2000
  };
2001
  chipsContainer.appendChild(chip);
2002
  });
2003
 
2004
- // Auto-fill input if empty and we have a primary suggestion
2005
- const currentVal = document.getElementById('topic_input').value;
 
 
 
 
2006
  if (!currentVal && result.suggestions.length > 0) {
2007
  document.getElementById('topic_input').value = result.suggestions[0];
2008
  }
@@ -2024,6 +2067,87 @@
2024
  loadSettings();
2025
  setupEventListeners();
2026
  setupRangeToggles();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2027
  });
2028
  </script>
2029
  {% endblock %}
 
95
  color: #fff;
96
  }
97
 
98
+ /* Highlighted chip for keyboard navigation */
99
+ #ai_suggestion_chips button.highlighted {
100
+ background-color: var(--accent-info);
101
+ color: var(--bg-dark);
102
+ border-color: #fff;
103
+ box-shadow: 0 0 8px var(--accent-info);
104
+ transform: translateY(-2px);
105
  }
106
 
107
  /* --- UNIFIED NOTE CARD STYLES --- */
 
177
 
178
  <form id="questions-form">
179
 
180
+ <div class="mb-3 d-flex flex-wrap gap-2">
181
+ {% if nvidia_nim_available %}
 
 
 
 
 
 
 
182
  <button id="auto-extract-all" class="btn btn-primary">
183
  <span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
184
  <span class="extract-text">Auto Extract All Question Numbers</span>
 
187
  <span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
188
  <span class="extract-text">Extract & Classify All</span>
189
  </button>
190
+ {% endif %}
191
+
192
+ <button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#manualClassificationModal">
193
+ <i class="bi bi-list-check me-1"></i> Manual Classification
194
  </button>
195
  </div>
 
196
 
197
  {% for image in images %}
198
  <fieldset class="row mb-4 border-bottom pb-3" data-question-index="{{ loop.index0 }}" id="question-fieldset-{{ image.id }}">
 
528
  <div class="mb-3">
529
  <label class="form-label small text-muted mb-2">Subject</label>
530
  <div id="subject_pills" class="d-flex flex-wrap gap-2">
531
+ <button type="button" class="btn btn-sm btn-outline-info subject-pill" data-subject="Physics">Physics (⇧1)</button>
532
+ <button type="button" class="btn btn-sm btn-outline-warning subject-pill" data-subject="Chemistry">Chemistry (⇧2)</button>
533
+ <button type="button" class="btn btn-sm btn-outline-success subject-pill" data-subject="Biology">Biology (⇧3)</button>
534
+ <button type="button" class="btn btn-sm btn-outline-primary subject-pill" data-subject="Zoology">Zoology (⇧4)</button>
535
+ <button type="button" class="btn btn-sm btn-outline-danger subject-pill" data-subject="Mathematics">Maths (⇧~)</button>
536
  </div>
537
  </div>
538
 
 
541
  <label class="form-label small text-muted mb-2">Chapter / Topic</label>
542
  <div class="input-group input-group-lg">
543
  <input type="text" id="topic_input" class="form-control bg-dark text-white border-secondary" placeholder="Start typing or use AI...">
544
+ <button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('topic_input').value=''; document.getElementById('topic_input').focus();">
545
+ <i class="bi bi-x-lg"></i>
546
+ </button>
547
  <button class="btn btn-info" type="button" id="btn_get_suggestion" onclick="getAiSuggestion()">
548
  <span class="spinner-border spinner-border-sm d-none" role="status"></span>
549
  <i class="bi bi-stars"></i>
 
553
 
554
  <!-- Suggestions -->
555
  <div id="ai_suggestion_container" class="d-none">
556
+ <label class="form-label d-block small text-info mb-2 mt-1"><i class="bi bi-robot me-1"></i>AI Suggested:</label>
557
  <div id="ai_suggestion_chips" class="d-flex flex-wrap gap-2"></div>
558
  </div>
559
  <div id="ai_error_msg" class="alert alert-danger d-none mt-2 py-2"></div>
 
1351
  let selectedRangeMode = 'all'; // 'all', 'unclassified', 'wrong', 'unattempted', 'custom'
1352
  let customRanges = []; // Array of {start, end, subject} for custom slider ranges
1353
  let questionSubjectMap = new Map(); // Maps question index to pre-selected subject
1354
+ let activeSuggestionIndex = -1; // -1 means focus is in input
1355
+
1356
+ function updateHighlightedSuggestion() {
1357
+ const chips = document.querySelectorAll('#ai_suggestion_chips button');
1358
+ chips.forEach((chip, idx) => {
1359
+ if (idx === activeSuggestionIndex) chip.classList.add('highlighted');
1360
+ else chip.classList.remove('highlighted');
1361
+ });
1362
+ }
1363
 
1364
  // Get question image URL by index
1365
  function getQuestionImageUrl(index) {
 
1525
 
1526
  // Subject detection keywords
1527
  const SUBJECT_KEYWORDS = {
1528
+ 'Biology': ['cell', 'dna', 'rna', 'protein', 'enzyme', 'mitosis', 'meiosis', 'chromosome', 'gene', 'photosynthesis', 'respiration', 'nucleus', 'cytoplasm', 'membrane', 'tissue', 'organ', 'species', 'evolution', 'ecology', 'bacteria', 'virus', 'plant', 'animal', 'blood', 'heart', 'kidney', 'liver', 'neuron', 'hormone', 'digestion', 'reproduction', 'inheritance', 'mutation', 'allele', 'phenotype', 'genotype', 'ecosystem', 'biodiversity', 'nitrogen cycle', 'carbon cycle', 'food chain', 'lymph', 'antibody', 'antigen', 'vaccine', 'pathogen', 'biomolecule', 'carbohydrate', 'lipid', 'amino acid', 'nucleotide', 'atp', 'chlorophyll', 'stomata', 'xylem', 'phloem', 'transpiration', 'pollination', 'fertilization', 'embryo', 'zygote', 'gamete', 'ovary', 'testis', ' sperm', 'ovum', 'menstrual', 'placenta', 'umbilical', 'gestation', 'lactation', 'immunology', 'homeostasis'],
1529
+ 'Zoology': ['animal', 'human', 'mammal', 'reproduction', 'nervous system', 'muscular', 'skeletal', 'circulatory', 'digestive', 'respiratory', 'excretory', 'endocrine', 'evolution', 'genetics', 'biotechnology', 'health', 'disease', 'immunity', 'blood', 'heart', 'brain', 'hormones'],
1530
  'Chemistry': ['atom', 'molecule', 'ion', 'electron', 'proton', 'neutron', 'orbital', 'bond', 'covalent', 'ionic', 'metallic', 'oxidation', 'reduction', 'redox', 'acid', 'base', 'ph', 'salt', 'solution', 'concentration', 'mole', 'molarity', 'stoichiometry', 'equilibrium', 'catalyst', 'reaction', 'organic', 'inorganic', 'hydrocarbon', 'alkane', 'alkene', 'alkyne', 'alcohol', 'aldehyde', 'ketone', 'carboxylic', 'ester', 'amine', 'amide', 'polymer', 'isomer', 'electrolysis', 'electrochemical', 'thermodynamics', 'enthalpy', 'entropy', 'gibbs', 'periodic table', 'atomic number', 'mass number', 'isotope', 'valence', 'hybridization', 'resonance', 'aromaticity', 'benzene', 'phenol', 'ether', 'haloalkane', 'grignard', 'nucleophile', 'electrophile', 'sn1', 'sn2', 'elimination', 'addition', 'substitution', 'coordination', 'ligand', 'crystal field', 'lanthanide', 'actinide', 'd-block', 'p-block', 's-block', 'f-block', 'buffer', 'titration', 'indicator', 'solubility', 'precipitation', 'colligative', 'osmotic', 'vapour pressure', 'raoult', 'henry', 'nernst', 'faraday', 'electrochemistry', 'galvanic', 'electrolytic'],
1531
  'Physics': ['force', 'mass', 'acceleration', 'velocity', 'momentum', 'energy', 'work', 'power', 'newton', 'gravity', 'friction', 'tension', 'torque', 'angular', 'rotational', 'oscillation', 'wave', 'frequency', 'wavelength', 'amplitude', 'sound', 'light', 'optics', 'lens', 'mirror', 'reflection', 'refraction', 'diffraction', 'interference', 'polarization', 'electric', 'current', 'voltage', 'resistance', 'capacitor', 'inductor', 'magnetic', 'electromagnetic', 'induction', 'transformer', 'generator', 'motor', 'circuit', 'ohm', 'kirchhoff', 'coulomb', 'gauss', 'ampere', 'faraday', 'lenz', 'maxwell', 'photoelectric', 'quantum', 'photon', 'electron', 'nucleus', 'radioactive', 'decay', 'fission', 'fusion', 'relativity', 'thermodynamics', 'heat', 'temperature', 'entropy', 'carnot', 'adiabatic', 'isothermal', 'isobaric', 'isochoric', 'kinetic theory', 'ideal gas', 'real gas', 'semiconductor', 'diode', 'transistor', 'logic gate', 'communication', 'modulation', 'satellite', 'doppler', 'spectrum', 'laser', 'holography', 'fibre optic', 'ray optics', 'wave optics', 'young', 'single slit', 'double slit', 'grating', 'brewster', 'malus', 'huygen'],
1532
  'Mathematics': ['equation', 'function', 'derivative', 'integral', 'limit', 'matrix', 'vector', 'determinant', 'polynomial', 'quadratic', 'linear', 'differential', 'probability', 'statistics', 'mean', 'median', 'variance', 'standard deviation', 'permutation', 'combination', 'trigonometry', 'sine', 'cosine', 'tangent', 'logarithm', 'exponential', 'complex number', 'real number', 'set', 'relation', 'sequence', 'series', 'arithmetic progression', 'geometric progression', 'binomial', 'conic', 'parabola', 'ellipse', 'hyperbola', 'circle', 'straight line', 'plane', 'three dimensional', 'coordinate', 'calculus', 'continuity', 'differentiability', 'maxima', 'minima', 'area under curve', 'definite integral', 'indefinite integral', 'inverse trigonometric', 'mathematical induction', 'boolean algebra']
 
1535
  // All NCERT chapters for autocomplete
1536
  const ALL_CHAPTERS = {
1537
  'Biology': ['The Living World', 'Biological Classification', 'Plant Kingdom', 'Animal Kingdom', 'Morphology of Flowering Plants', 'Anatomy of Flowering Plants', 'Structural Organisation in Animals', 'Cell: The Unit of Life', 'Biomolecules', 'Cell Cycle and Cell Division', 'Photosynthesis in Higher Plants', 'Respiration in Plants', 'Plant Growth and Development', 'Breathing and Exchange of Gases', 'Body Fluids and Circulation', 'Excretory Products and their Elimination', 'Locomotion and Movement', 'Neural Control and Coordination', 'Chemical Coordination and Integration', 'Reproduction in Organisms', 'Sexual Reproduction in Flowering Plants', 'Human Reproduction', 'Reproductive Health', 'Principles of Inheritance and Variation', 'Molecular Basis of Inheritance', 'Evolution', 'Human Health and Disease', 'Strategies for Enhancement in Food Production', 'Microbes in Human Welfare', 'Biotechnology: Principles and Processes', 'Biotechnology and its Applications', 'Organisms and Populations', 'Ecosystem', 'Biodiversity and Conservation', 'Environmental Issues', 'Transport in Plants', 'Mineral Nutrition', 'Digestion and Absorption'],
1538
+ 'Zoology': ['Animal Kingdom', 'Structural Organisation in Animals', 'Biomolecules', 'Breathing and Exchange of Gases', 'Body Fluids and Circulation', 'Excretory Products and their Elimination', 'Locomotion and Movement', 'Neural Control and Coordination', 'Chemical Coordination and Integration', 'Human Reproduction', 'Reproductive Health', 'Principles of Inheritance and Variation', 'Molecular Basis of Inheritance', 'Evolution', 'Human Health and Disease', 'Strategies for Enhancement in Food Production', 'Microbes in Human Welfare', 'Biotechnology: Principles and Processes', 'Biotechnology and its Applications', 'Organisms and Populations', 'Ecosystem', 'Biodiversity and Conservation', 'Environmental Issues'],
1539
  'Chemistry': ['Some Basic Concepts of Chemistry', 'Structure of Atom', 'Classification of Elements and Periodicity in Properties', 'Chemical Bonding and Molecular Structure', 'Thermodynamics', 'Equilibrium', 'Redox Reactions', 'Organic Chemistry – Some Basic Principles and Techniques (GOC)', 'Hydrocarbons', 'Hydrogen', 'The s-Block Elements', 'The p-Block Elements', 'The d- and f-Block Elements', 'Coordination Compounds', 'Haloalkanes and Haloarenes', 'Alcohols, Phenols and Ethers', 'Aldehydes, Ketones and Carboxylic Acids', 'Amines', 'Biomolecules', 'Polymers', 'Chemistry in Everyday Life', 'Electrochemistry', 'Chemical Kinetics', 'Surface Chemistry', 'General Principles and Processes of Isolation of Elements', 'Solutions', 'Solid State', 'States of Matter'],
1540
  'Physics': ['Physical World', 'Units and Measurements', 'Motion in a Straight Line', 'Motion in a Plane', 'Laws of Motion', 'Work, Energy and Power', 'System of Particles and Rotational Motion', 'Gravitation', 'Mechanical Properties of Solids', 'Mechanical Properties of Fluids', 'Thermal Properties of Matter', 'Thermodynamics', 'Kinetic Theory', 'Oscillations', 'Waves', 'Electric Charges and Fields', 'Electrostatic Potential and Capacitance', 'Current Electricity', 'Moving Charges and Magnetism', 'Magnetism and Matter', 'Electromagnetic Induction', 'Alternating Current', 'Electromagnetic Waves', 'Ray Optics and Optical Instruments', 'Wave Optics', 'Dual Nature of Radiation and Matter', 'Atoms', 'Nuclei', 'Semiconductor Electronics', 'Communication Systems'],
1541
  'Mathematics': ['Sets', 'Relations and Functions', 'Trigonometric Functions', 'Complex Numbers and Quadratic Equations', 'Linear Inequalities', 'Permutations and Combinations', 'Binomial Theorem', 'Sequences and Series', 'Straight Lines', 'Conic Sections', 'Introduction to Three Dimensional Geometry', 'Limits and Derivatives', 'Statistics', 'Probability', 'Matrices', 'Determinants', 'Continuity and Differentiability', 'Applications of Derivatives', 'Integrals', 'Applications of Integrals', 'Differential Equations', 'Vector Algebra', 'Three Dimensional Geometry', 'Linear Programming', 'Inverse Trigonometric Functions', 'Mathematical Reasoning', 'Principle of Mathematical Induction']
 
1569
  function setActiveSubject(subject) {
1570
  manualSubject = subject;
1571
  document.querySelectorAll('.subject-pill').forEach(pill => {
1572
+ pill.classList.remove('active', 'btn-success', 'btn-warning', 'btn-info', 'btn-danger', 'btn-primary');
1573
+ pill.classList.add('btn-outline-success', 'btn-outline-warning', 'btn-outline-info', 'btn-outline-danger', 'btn-outline-primary');
1574
  if (pill.dataset.subject === subject) {
1575
+ pill.classList.remove('btn-outline-success', 'btn-outline-warning', 'btn-outline-info', 'btn-outline-danger', 'btn-outline-primary');
1576
+ const colorMap = {
1577
+ 'Biology': 'btn-success',
1578
+ 'Chemistry': 'btn-warning',
1579
+ 'Physics': 'btn-info',
1580
+ 'Zoology': 'btn-primary',
1581
+ 'Mathematics': 'btn-danger'
1582
+ };
1583
  pill.classList.add('active', colorMap[subject] || 'btn-primary');
1584
  }
1585
  });
 
1892
  document.getElementById('ai_suggestion_chips').innerHTML = '';
1893
  document.getElementById('ai_error_msg').classList.add('d-none');
1894
 
1895
+ // Reset keyboard navigation
1896
+ activeSuggestionIndex = -1;
1897
+ updateHighlightedSuggestion();
1898
+
1899
+ // Focus input after a small delay
1900
+ setTimeout(() => {
1901
+ const input = document.getElementById('topic_input');
1902
+ input.focus();
1903
+ input.select();
1904
+ }, 100);
1905
 
1906
  // Auto-load suggestions if topic is empty
1907
  if (!document.getElementById('topic_input').value) {
 
1964
  const chipsContainer = document.getElementById('ai_suggestion_chips');
1965
  const errorDiv = document.getElementById('ai_error_msg');
1966
 
1967
+ {% if not nvidia_nim_available %}
1968
+ container.classList.remove('d-none');
1969
+ chipsContainer.innerHTML = '<span class="text-muted small"><i class="bi bi-info-circle me-1"></i>AI suggestions unavailable (No API key). Use manual entry below.</span>';
1970
+ return;
1971
+ {% endif %}
1972
+
1973
  btn.disabled = true;
1974
  btn.querySelector('.spinner-border').classList.remove('d-none');
1975
  errorDiv.classList.add('d-none');
 
2027
  result.suggestions.forEach(suggestion => {
2028
  const chip = document.createElement('button');
2029
  chip.className = 'btn btn-outline-info btn-sm rounded-1';
2030
+ chip.innerHTML = `<i class="bi bi-plus-circle me-1"></i>${suggestion}`;
2031
  chip.onclick = () => {
2032
  document.getElementById('topic_input').value = suggestion;
2033
+ // Clear suggestion highlighting and focus input
2034
+ activeSuggestionIndex = -1;
2035
+ updateHighlightedSuggestion();
2036
+ const input = document.getElementById('topic_input');
2037
+ input.focus();
2038
+ // We don't call nextTopicQuestion() here to allow review/editing
2039
  };
2040
  chipsContainer.appendChild(chip);
2041
  });
2042
 
2043
+ // Reset selection
2044
+ activeSuggestionIndex = -1;
2045
+ updateHighlightedSuggestion();
2046
+
2047
+ // ONLY auto-fill input if it's currently empty
2048
+ const currentVal = document.getElementById('topic_input').value.trim();
2049
  if (!currentVal && result.suggestions.length > 0) {
2050
  document.getElementById('topic_input').value = result.suggestions[0];
2051
  }
 
2067
  loadSettings();
2068
  setupEventListeners();
2069
  setupRangeToggles();
2070
+
2071
+ // Keyboard navigation for Topic Wizard
2072
+ let lastEnterTime = 0;
2073
+ document.addEventListener('keydown', (e) => {
2074
+ const modal = document.getElementById('topicSelectionModal');
2075
+ if (!modal.classList.contains('show')) return;
2076
+
2077
+ const chips = document.querySelectorAll('#ai_suggestion_chips button');
2078
+ const input = document.getElementById('topic_input');
2079
+
2080
+ // --- Suggestion Cycling Logic ---
2081
+ if (e.key === 'ArrowDown') {
2082
+ if (activeSuggestionIndex === -1 && chips.length > 0) {
2083
+ e.preventDefault();
2084
+ activeSuggestionIndex = 0;
2085
+ updateHighlightedSuggestion();
2086
+ }
2087
+ } else if (e.key === 'ArrowUp') {
2088
+ if (activeSuggestionIndex !== -1) {
2089
+ e.preventDefault();
2090
+ activeSuggestionIndex = -1;
2091
+ updateHighlightedSuggestion();
2092
+ input.focus();
2093
+ }
2094
+ } else if (e.key === 'ArrowRight' && activeSuggestionIndex !== -1) {
2095
+ e.preventDefault();
2096
+ activeSuggestionIndex = (activeSuggestionIndex + 1) % chips.length;
2097
+ updateHighlightedSuggestion();
2098
+ } else if (e.key === 'ArrowLeft' && activeSuggestionIndex !== -1) {
2099
+ e.preventDefault();
2100
+ activeSuggestionIndex = (activeSuggestionIndex - 1 + chips.length) % chips.length;
2101
+ updateHighlightedSuggestion();
2102
+ }
2103
+
2104
+ // Enter to save and next
2105
+ if (e.key === 'Enter') {
2106
+ e.preventDefault();
2107
+ const now = Date.now();
2108
+
2109
+ // Double Enter Fallback: If pressed twice within 300ms, always go next
2110
+ if (now - lastEnterTime < 300) {
2111
+ nextTopicQuestion();
2112
+ lastEnterTime = 0;
2113
+ return;
2114
+ }
2115
+ lastEnterTime = now;
2116
+
2117
+ if (activeSuggestionIndex !== -1 && chips[activeSuggestionIndex]) {
2118
+ // Enter 1: Select the suggestion (triggers the click handler we modified)
2119
+ chips[activeSuggestionIndex].click();
2120
+ } else {
2121
+ // Enter 2 (or just Enter if in input): Save and move to next question
2122
+ nextTopicQuestion();
2123
+ }
2124
+ }
2125
+
2126
+ // Shift + Number shortcuts for subjects (Android compatible)
2127
+ if (e.shiftKey && !e.ctrlKey && !e.altKey) {
2128
+ let targetSubject = null;
2129
+ if (e.key === '1' || e.key === '!') targetSubject = 'Physics';
2130
+ else if (e.key === '2' || e.key === '@') targetSubject = 'Chemistry';
2131
+ else if (e.key === '3' || e.key === '#') targetSubject = 'Biology';
2132
+ else if (e.key === '4' || e.key === '$') targetSubject = 'Zoology';
2133
+ else if (e.key === '`' || e.key === '~') targetSubject = 'Mathematics';
2134
+
2135
+ if (targetSubject) {
2136
+ e.preventDefault();
2137
+ setActiveSubject(targetSubject);
2138
+ // Clear and fetch fresh suggestions for the new subject
2139
+ input.value = '';
2140
+ input.focus();
2141
+ getAiSuggestion();
2142
+ }
2143
+ }
2144
+
2145
+ // Alt + S to trigger AI suggestion
2146
+ if (e.altKey && (e.key === 's' || e.key === 'S')) {
2147
+ e.preventDefault();
2148
+ getAiSuggestion();
2149
+ }
2150
+ });
2151
  });
2152
  </script>
2153
  {% endblock %}
templates/templates/question_entry_v2.html CHANGED
@@ -37,14 +37,7 @@
37
 
38
  <form id="questions-form">
39
 
40
-
41
- {% if not nvidia_nim_available %}
42
- <div class="alert alert-warning alert-dismissible fade show" role="alert">
43
- <i class="bi bi-exclamation-triangle me-2"></i>
44
- NVIDIA NIM OCR feature is not available. To enable automatic question number extraction, please set the <code>NVIDIA_API_KEY</code> environment variable.
45
- <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
46
- </div>
47
- {% else %}
48
  <div class="mb-3">
49
  <button id="auto-extract-all" class="btn btn-primary">
50
  <span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
 
37
 
38
  <form id="questions-form">
39
 
40
+ {% if nvidia_nim_available %}
 
 
 
 
 
 
 
41
  <div class="mb-3">
42
  <button id="auto-extract-all" class="btn btn-primary">
43
  <span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
utils.py CHANGED
@@ -12,6 +12,37 @@ def get_db_connection():
12
  conn.row_factory = sqlite3.Row
13
  return conn
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  def get_or_download_font(font_path="arial.ttf", font_size=50):
16
  if not os.path.exists(font_path):
17
  try:
 
12
  conn.row_factory = sqlite3.Row
13
  return conn
14
 
15
+ def sync_neetprep_collection(conn, session_id, user_id):
16
+ """
17
+ Ensures all classified questions in a session are bookmarked for the neetprep view.
18
+ """
19
+ # Check if session has any classified questions (subject AND chapter set)
20
+ classified_qs = conn.execute('''
21
+ SELECT id FROM questions
22
+ WHERE session_id = ? AND subject IS NOT NULL AND subject != 'Unclassified'
23
+ AND chapter IS NOT NULL AND chapter != 'Unclassified'
24
+ ''', (session_id,)).fetchall()
25
+
26
+ if not classified_qs:
27
+ return False
28
+
29
+ # Sync bookmarks for all classified questions
30
+ for q in classified_qs:
31
+ q_id = str(q['id'])
32
+ # Check if already bookmarked
33
+ exists = conn.execute('''
34
+ SELECT id FROM neetprep_bookmarks
35
+ WHERE user_id = ? AND neetprep_question_id = ? AND question_type = 'classified'
36
+ ''', (user_id, q_id)).fetchone()
37
+
38
+ if not exists:
39
+ conn.execute('''
40
+ INSERT INTO neetprep_bookmarks (user_id, neetprep_question_id, session_id, question_type)
41
+ VALUES (?, ?, ?, 'classified')
42
+ ''', (user_id, q_id, session_id))
43
+
44
+ return True
45
+
46
  def get_or_download_font(font_path="arial.ttf", font_size=50):
47
  if not os.path.exists(font_path):
48
  try: