shayekh commited on
Commit
84e279c
Β·
verified Β·
1 Parent(s): aea3e3c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +530 -46
app.py CHANGED
@@ -65,6 +65,12 @@ def set_stop_thinking():
65
  print(f"[STOP-THINK] set_stop_thinking CALLED! Flag is now: {global_stop_thinking[0]}")
66
  return gr.update(value="⚑ Forcing generation...")
67
 
 
 
 
 
 
 
68
  def set_kill_threads():
69
  global_kill_threads[0] = True
70
  print(f"[KILL] set_kill_threads CALLED! Flag is now: {global_kill_threads[0]}")
@@ -443,13 +449,26 @@ CRITICAL: Do NOT overthink. Do NOT deliberate over conditions, edge cases, or re
443
  thread2 = Thread(target=run_generation, args=(inputs2, streamer2, local_stop))
444
  thread2.start()
445
 
 
446
  for new_text2 in streamer2:
447
  output_text += new_text2
448
  yield output_text, None
449
 
450
- # Allow user to force again if model still isn't producing JSON
451
- if global_stop_thinking[0] or global_kill_threads[0]:
452
- print("[STOP-THINK] Flag detected in forced generation loop! Killing...")
 
 
 
 
 
 
 
 
 
 
 
 
453
  local_stop[0] = True
454
  while not streamer2.text_queue.empty():
455
  try:
@@ -1081,11 +1100,8 @@ def process_pdf(pdf_file, url_input, audio_file_input, yt_url_input, yt_cookies_
1081
  </html>
1082
  """
1083
 
1084
- import html
1085
- safe_srcdoc = html.escape(iframe_html)
1086
-
1087
- # Return the iframe containing the whole SPA
1088
- yield f'<iframe srcdoc="{safe_srcdoc}" style="width: 100%; height: 650px; border: none; overflow-y: auto;"></iframe>', current_source_hash, vocab_list, stream_text, content_text, images, extracted_audio_path
1089
 
1090
  LANGUAGE_DATA = """Indo-European Bengali, English, French, Portuguese, German, Romanian, Swedish, Danish, Bulgarian, Russian, Czech, Greek, Ukrainian, Spanish, Dutch, Slovak, Croatian, Polish, Lithuanian, Norwegian BokmΓ₯l, Norwegian Nynorsk, Persian, Slovenian, Gujarati, Latvian, Italian, Occitan, Nepali, Marathi, Belarusian, Serbian, Luxembourgish, Venetian, Assamese, Welsh, Silesian, Asturian, Chhattisgarhi, Awadhi, Maithili, Bhojpuri, Sindhi, Irish, Faroese, Hindi, Punjabi, Oriya, Tajik, Eastern Yiddish, Lombard, Ligurian, Sicilian, Friulian, Sardinian, Galician, Catalan, Icelandic, Tosk Albanian, Limburgish, Dari, Afrikaans, Macedonian, Sinhala, Urdu, Magahi, Bosnian, Armenian, Latgalian, Scottish Gaelic, Central Kurdish, Northern Kurdish, Southern Pashto, Sanskrit, Dhundari, Marwari, Ahirani, Bagheli, Bagri, Bundeli, Braj, Kumaoni, Kashmiri
1091
  Sino-Tibetan Chinese (Simplified), Chinese (Traditional), Cantonese, Burmese, Standard Tibetan, Meitei
@@ -1191,20 +1207,451 @@ def process_pdf_force(partial_text, pdf_file, url_input, translit_lang, translit
1191
  item["audio_uri"] = None
1192
 
1193
  progress(1.0, desc="Rendering flashcards...")
1194
-
1195
- from jinja2 import Environment, BaseLoader
1196
- import json
1197
-
1198
- env = Environment(loader=BaseLoader())
1199
- template = env.from_string(html_template)
1200
- html_output = template.render(
1201
- vocab_list=vocab_list,
1202
- translit_lang=translit_lang,
1203
- target_lang=target_lang
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1204
  )
1205
-
1206
- safe_srcdoc = html_output.replace('"', '&quot;')
1207
- yield f'<iframe srcdoc="{safe_srcdoc}" style="width: 100%; height: 650px; border: none; overflow-y: auto;"></iframe>', current_source_hash, vocab_list, stream_text, content_text, images, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1208
 
1209
 
1210
  def create_demo():
@@ -1324,64 +1771,86 @@ def create_demo():
1324
  with gr.Column(scale=1):
1325
  with gr.Tabs() as input_tabs:
1326
  with gr.Tab("Website URL", id="tab_url") as tab_url:
1327
- url_input = gr.Textbox(label="Enter a Website URL 🌐",
1328
- placeholder=r"e.g. https://www.bbc.com/korean/articles/cn0p7rkvxdgo",
1329
  value=r"https://www.bbc.com/korean/articles/cn0p7rkvxdgo")
1330
-
1331
  with gr.Tab("Upload PDF", id="tab_pdf") as tab_pdf:
1332
  pdf_input = gr.File(label="Upload Book PDF πŸ“š", file_types=[".pdf"], value=example_pdf)
1333
-
1334
  with gr.Tab("Upload Audio", id="tab_audio") as tab_audio:
1335
  audio_file_input = gr.File(label="Upload Audio File 🎡", file_types=[".wav", ".mp3", ".m4a", ".ogg", ".flac", ".opus", ".webm"], value=example_audio)
1336
  gr.Markdown("*Upload a Korean audio file. It will be transcribed using Cohere ASR and vocabulary will be extracted from the transcript.*", elem_classes=["hint-text"])
1337
-
1338
  with gr.Tab("YouTube Link", id="tab_yt") as tab_yt:
1339
- yt_url_input = gr.Textbox(label="Enter a YouTube Link 🎬",
1340
- placeholder=r"e.g. https://www.youtube.com/watch?v=...",
1341
  value="https://www.youtube.com/watch?v=9Nj7l73PBWE",
1342
  info="Audio from the first 5 minutes will be transcribed using Cohere ASR")
1343
  yt_cookies_input = gr.File(label="YouTube Cookies (cookies.txt)", file_types=[".txt"], value=None,
1344
  type="filepath")
1345
  gr.Markdown("*Optional. Helps bypass YouTube bot detection. Install the [cookies.txt](https://addons.mozilla.org/firefox/addon/cookies-txt/) extension, go to youtube.com while logged in, click the extension β†’ 'Current Site' to export.*", elem_classes=["hint-text"])
1346
-
 
 
 
 
 
 
 
1347
  # Track active tab
1348
  tab_url.select(fn=lambda: "Website URL", inputs=None, outputs=active_tab)
1349
  tab_pdf.select(fn=lambda: "Upload PDF", inputs=None, outputs=active_tab)
1350
  tab_audio.select(fn=lambda: "Upload Audio", inputs=None, outputs=active_tab)
1351
  tab_yt.select(fn=lambda: "YouTube Link", inputs=None, outputs=active_tab)
1352
-
 
1353
  gr.Markdown("### βš™οΈ Customization Settings")
1354
  max_text_char_input = gr.Slider(minimum=1000, maximum=30000, step=1000, value=1500, label="Max Input Text Length (Characters)")
1355
  repetition_penalty_input = gr.Slider(minimum=0.1, maximum=2.0, step=0.1, value=1.2, label="Repetition Penalty")
1356
  auto_force_chars_input = gr.Slider(minimum=1_000, maximum=10_000, step=100, value=4_000, label="Auto-force JSON after (chars of thinking)")
1357
-
1358
  with gr.Accordion("πŸ”§ Advanced", open=False):
1359
  translit_lang = gr.Dropdown(
1360
- label="Word Transliteration Language",
1361
- choices=LANGUAGE_CHOICES,
1362
  value="Indo-European - English"
1363
  )
1364
  translit_format = gr.Dropdown(label="Transliteration Format", choices=["dashed syllable", "regular word with space"], value="dashed syllable")
1365
  target_lang = gr.Dropdown(
1366
- label="Target Language (Full App)",
1367
- choices=LANGUAGE_CHOICES,
1368
  value="Indo-European - English"
1369
  )
1370
-
1371
  with gr.Row():
1372
  submit_btn = gr.Button("✨ Generate Flashcards ✨", variant="primary")
1373
  stop_thinking_btn = gr.Button("⚑ Stop thinking, Generate now", variant="secondary")
1374
  stop_btn = gr.Button("πŸ›‘ Stop Generation", variant="stop")
1375
-
1376
  with gr.Column(scale=2):
1377
- output_html = gr.HTML(label="Flashcards will appear here")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1378
  stream_box = gr.Textbox(label="Live Model Generation 🧠", lines=10, max_lines=20, interactive=False, autoscroll=True, elem_id="stream_box")
1379
-
1380
  with gr.Accordion("πŸ“„ Extracted Source Content", open=True):
1381
  extracted_text_box = gr.Textbox(label="Extracted Text", lines=10, max_lines=15, interactive=False)
1382
  extracted_images_gallery = gr.Gallery(label="Extracted Images", columns=4, height="auto", object_fit="contain")
1383
  extracted_audio_player = gr.Audio(label="Extracted Audio (YouTube / Uploaded)", type="filepath", interactive=False)
1384
-
1385
  last_source_state = gr.State(None)
1386
  last_korean_words_state = gr.State(None)
1387
 
@@ -1391,15 +1860,30 @@ def create_demo():
1391
  submit_btn.click(fn=reset_btn_text, inputs=None, outputs=[stop_thinking_btn, stop_btn], queue=False)
1392
 
1393
  generate_event = submit_btn.click(
1394
- fn=process_pdf,
1395
- inputs=[pdf_input, url_input, audio_file_input, yt_url_input, yt_cookies_input, translit_lang, translit_format, target_lang, max_text_char_input, repetition_penalty_input, auto_force_chars_input, last_source_state, last_korean_words_state, active_tab],
1396
  outputs=[output_html, last_source_state, last_korean_words_state, stream_box, extracted_text_box, extracted_images_gallery, extracted_audio_player]
1397
  )
1398
-
1399
- stop_thinking_btn.click(fn=set_stop_thinking, inputs=None, outputs=stop_thinking_btn, queue=False)
1400
-
 
1401
  stop_btn.click(fn=set_kill_threads, inputs=None, outputs=stop_btn, queue=False)
1402
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1403
  # Force autoscroll using Custom JS
1404
  stream_box.change(
1405
  fn=None,
 
65
  print(f"[STOP-THINK] set_stop_thinking CALLED! Flag is now: {global_stop_thinking[0]}")
66
  return gr.update(value="⚑ Forcing generation...")
67
 
68
+ def reset_stop_thinking_after_delay():
69
+ """Wait 5 seconds then restore the button text so the user can click it again."""
70
+ import time
71
+ time.sleep(5)
72
+ return gr.update(value="⚑ Stop thinking, Generate now")
73
+
74
  def set_kill_threads():
75
  global_kill_threads[0] = True
76
  print(f"[KILL] set_kill_threads CALLED! Flag is now: {global_kill_threads[0]}")
 
449
  thread2 = Thread(target=run_generation, args=(inputs2, streamer2, local_stop))
450
  thread2.start()
451
 
452
+ HARD_FORCE_CHARS = 10_000
453
  for new_text2 in streamer2:
454
  output_text += new_text2
455
  yield output_text, None
456
 
457
+ # Hard auto-force: if total output exceeds 10K chars and
458
+ # the text after the last ```json prefix has no closing ] or },
459
+ # the model is still rambling β€” force again automatically.
460
+ last_json_start = output_text.rfind('```json')
461
+ text_after_json = output_text[last_json_start:] if last_json_start >= 0 else ''
462
+ has_json_end = (']' in text_after_json or '}' in text_after_json) if text_after_json else False
463
+ should_hard_force = (
464
+ len(output_text) > HARD_FORCE_CHARS
465
+ and not has_json_end
466
+ )
467
+
468
+ # Allow user to force again OR hard auto-force kicks in
469
+ if global_stop_thinking[0] or global_kill_threads[0] or should_hard_force:
470
+ reason = "hard auto-force (>10K chars)" if should_hard_force and not global_stop_thinking[0] else "user/kill flag"
471
+ print(f"[STOP-THINK] Flag detected in forced generation loop ({reason})! Killing...")
472
  local_stop[0] = True
473
  while not streamer2.text_queue.empty():
474
  try:
 
1100
  </html>
1101
  """
1102
 
1103
+ fc_html = build_flashcard_html(vocab_list)
1104
+ yield fc_html, current_source_hash, vocab_list, stream_text, content_text, images, extracted_audio_path
 
 
 
1105
 
1106
  LANGUAGE_DATA = """Indo-European Bengali, English, French, Portuguese, German, Romanian, Swedish, Danish, Bulgarian, Russian, Czech, Greek, Ukrainian, Spanish, Dutch, Slovak, Croatian, Polish, Lithuanian, Norwegian BokmΓ₯l, Norwegian Nynorsk, Persian, Slovenian, Gujarati, Latvian, Italian, Occitan, Nepali, Marathi, Belarusian, Serbian, Luxembourgish, Venetian, Assamese, Welsh, Silesian, Asturian, Chhattisgarhi, Awadhi, Maithili, Bhojpuri, Sindhi, Irish, Faroese, Hindi, Punjabi, Oriya, Tajik, Eastern Yiddish, Lombard, Ligurian, Sicilian, Friulian, Sardinian, Galician, Catalan, Icelandic, Tosk Albanian, Limburgish, Dari, Afrikaans, Macedonian, Sinhala, Urdu, Magahi, Bosnian, Armenian, Latgalian, Scottish Gaelic, Central Kurdish, Northern Kurdish, Southern Pashto, Sanskrit, Dhundari, Marwari, Ahirani, Bagheli, Bagri, Bundeli, Braj, Kumaoni, Kashmiri
1107
  Sino-Tibetan Chinese (Simplified), Chinese (Traditional), Cantonese, Burmese, Standard Tibetan, Meitei
 
1207
  item["audio_uri"] = None
1208
 
1209
  progress(1.0, desc="Rendering flashcards...")
1210
+ fc_html = build_flashcard_html(vocab_list)
1211
+ yield fc_html, current_source_hash, vocab_list, stream_text, content_text, images, None
1212
+
1213
+
1214
+ def build_flashcard_html(vocab_list):
1215
+ """Build the flashcard SPA with spaced repetition (SM-2 lite via localStorage)."""
1216
+ import html as _html
1217
+ cards_json = json.dumps(vocab_list).replace("</", "<\\/")
1218
+
1219
+ iframe_html = f"""<!DOCTYPE html>
1220
+ <html>
1221
+ <head>
1222
+ <meta charset="utf-8">
1223
+ <link rel='stylesheet' href='https://cdn-uicons.flaticon.com/uicons-regular-rounded/css/uicons-regular-rounded.css'>
1224
+ <style>
1225
+ * {{ box-sizing:border-box; margin:0; padding:0; }}
1226
+ body {{ background:transparent; font-family:'Outfit','Inter',sans-serif; color:#f8fafc; padding:12px 8px; }}
1227
+ .sr-header {{ display:flex; align-items:center; gap:10px; margin-bottom:12px; }}
1228
+ .sr-bar-wrap {{ flex:1; height:7px; background:rgba(255,255,255,.1); border-radius:4px; overflow:hidden; }}
1229
+ .sr-bar-fill {{ height:100%; background:linear-gradient(90deg,#22c55e,#3b82f6); border-radius:4px; transition:width .5s ease; }}
1230
+ .sr-stats-txt {{ font-size:12px; color:#94a3b8; white-space:nowrap; }}
1231
+ .due-btn {{ padding:5px 13px; border-radius:20px; border:1px solid rgba(56,189,248,.4); background:rgba(56,189,248,.1); color:#38bdf8; font-size:12px; font-weight:700; cursor:pointer; transition:all .2s; white-space:nowrap; }}
1232
+ .due-btn.active {{ background:rgba(56,189,248,.3); border-color:#38bdf8; box-shadow:0 0 10px rgba(56,189,248,.4); }}
1233
+ .due-btn:hover {{ background:rgba(56,189,248,.25); }}
1234
+ .fc-container {{ perspective:1000px; width:100%; max-width:520px; margin:0 auto; }}
1235
+ .flashcard {{ width:100%; min-height:330px; display:grid; transition:transform .6s cubic-bezier(.4,.2,.2,1); transform-style:preserve-3d; cursor:pointer; }}
1236
+ .flashcard.is-flipped {{ transform:rotateY(180deg); }}
1237
+ .card-face {{ grid-area:1/1; width:100%; backface-visibility:hidden; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:20px; padding:28px; box-sizing:border-box; text-align:center; }}
1238
+ .card-front {{ background:linear-gradient(135deg,rgba(30,41,59,.95) 0%,rgba(15,23,42,.95) 100%); border-top:2px solid rgba(139,92,246,.6); box-shadow:0 10px 40px rgba(0,0,0,.5),inset 0 0 0 1px rgba(255,255,255,.05); position:relative; }}
1239
+ .card-back {{ transform:rotateY(180deg); background:linear-gradient(135deg,rgba(30,58,138,.95) 0%,rgba(15,23,42,.95) 100%); border-top:2px solid rgba(56,189,248,.6); box-shadow:0 10px 40px rgba(0,0,0,.5),inset 0 0 0 1px rgba(255,255,255,.05); }}
1240
+ .sr-badge {{ position:absolute; top:12px; right:14px; font-size:10px; padding:3px 9px; border-radius:12px; font-weight:800; letter-spacing:.5px; }}
1241
+ .badge-new {{ background:rgba(139,92,246,.3); color:#c084fc; }}
1242
+ .badge-learning {{ background:rgba(239,68,68,.3); color:#fca5a5; }}
1243
+ .badge-known {{ background:rgba(34,197,94,.3); color:#86efac; }}
1244
+ .badge-due {{ background:rgba(251,191,36,.3); color:#fde68a; }}
1245
+ .ko-text {{ font-size:50px; font-weight:800; color:#fff; text-shadow:0 0 20px rgba(139,92,246,.7); margin-bottom:14px; line-height:1.2; }}
1246
+ .en-text {{ font-size:28px; font-weight:800; color:#fff; text-shadow:0 0 15px rgba(56,189,248,.7); margin-bottom:6px; }}
1247
+ .tr-text {{ font-size:17px; font-style:italic; color:#fca5a5; margin-bottom:12px; letter-spacing:1px; }}
1248
+ .exp-text {{ font-size:14px; color:#cbd5e1; line-height:1.6; background:rgba(0,0,0,.25); padding:12px 15px; border-radius:12px; border:1px solid rgba(255,255,255,.06); max-width:100%; }}
1249
+ .flip-hint {{ margin-top:14px; color:#475569; font-size:12px; display:flex; align-items:center; gap:5px; }}
1250
+ .audio-btn {{ margin-top:10px; padding:9px 20px; background:rgba(56,189,248,.15); color:#38bdf8; border:1px solid rgba(56,189,248,.4); border-radius:20px; font-size:13px; font-weight:700; cursor:pointer; transition:all .3s; display:flex; align-items:center; gap:6px; text-transform:uppercase; letter-spacing:.5px; }}
1251
+ .audio-btn:hover {{ background:rgba(56,189,248,.35); transform:scale(1.04); box-shadow:0 5px 15px rgba(56,189,248,.3); }}
1252
+ .copy-icon {{ cursor:pointer; color:#475569; font-size:18px; transition:color .2s,transform .2s; flex-shrink:0; }}
1253
+ .copy-icon:hover {{ color:#8b5cf6; transform:scale(1.15); }}
1254
+ .sr-actions {{ display:flex; justify-content:center; gap:10px; margin-top:18px; width:100%; max-width:520px; margin-left:auto; margin-right:auto; }}
1255
+ .sr-btn {{ flex:1; padding:12px 8px; border:none; border-radius:14px; font-weight:700; font-size:13px; cursor:pointer; transition:all .25s cubic-bezier(.4,0,.2,1); display:flex; align-items:center; justify-content:center; gap:5px; font-family:inherit; }}
1256
+ .sr-dk {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.5); color:#fca5a5; }}
1257
+ .sr-dk:hover {{ background:rgba(239,68,68,.4); transform:translateY(-2px); box-shadow:0 6px 20px rgba(239,68,68,.3); }}
1258
+ .sr-sk {{ background:rgba(100,116,139,.2); border:1px solid rgba(100,116,139,.5); color:#94a3b8; }}
1259
+ .sr-sk:hover {{ background:rgba(100,116,139,.4); transform:translateY(-2px); }}
1260
+ .sr-kn {{ background:rgba(34,197,94,.2); border:1px solid rgba(34,197,94,.5); color:#86efac; }}
1261
+ .sr-kn:hover {{ background:rgba(34,197,94,.4); transform:translateY(-2px); box-shadow:0 6px 20px rgba(34,197,94,.3); }}
1262
+ .nav-buttons {{ display:flex; justify-content:space-between; align-items:center; margin-top:10px; width:100%; max-width:520px; margin-left:auto; margin-right:auto; gap:10px; }}
1263
+ .nav-btn {{ padding:9px 18px; border-radius:14px; background:rgba(139,92,246,.15); border:1px solid rgba(139,92,246,.4); color:white; font-weight:700; cursor:pointer; transition:all .25s; display:flex; align-items:center; gap:5px; font-size:13px; font-family:inherit; }}
1264
+ .nav-btn:hover {{ background:rgba(139,92,246,.4); transform:translateY(-2px); box-shadow:0 5px 15px rgba(139,92,246,.3); }}
1265
+ .nav-btn:disabled {{ background:rgba(100,116,139,.1); border-color:rgba(100,116,139,.2); color:#334155; cursor:not-allowed; transform:none; box-shadow:none; }}
1266
+ .progress {{ text-align:center; margin-top:8px; color:#475569; font-size:13px; font-weight:600; }}
1267
+ </style>
1268
+ </head>
1269
+ <body>
1270
+ <div id="fc-app">
1271
+ <div class="sr-header">
1272
+ <div class="sr-bar-wrap"><div class="sr-bar-fill" id="sr-fill" style="width:0%"></div></div>
1273
+ <span class="sr-stats-txt" id="sr-stats"></span>
1274
+ <button class="due-btn" id="due-btn" onclick="toggleReview()">πŸ“… Due Cards</button>
1275
+ </div>
1276
+ <div class="fc-container">
1277
+ <div class="flashcard" id="card" onclick="flipCard()">
1278
+ <div class="card-face card-front">
1279
+ <span class="sr-badge badge-new" id="sr-badge">πŸ†• NEW</span>
1280
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;width:100%;justify-content:center;">
1281
+ <div class="ko-text" id="front-text"><i class="fi fi-rr-spinner-third"></i></div>
1282
+ <i class="fi fi-rr-copy copy-icon" id="copy-ko" onclick="copyText('front-text',event,this)" style="display:none;"></i>
1283
+ </div>
1284
+ <button class="audio-btn" onclick="playAudio(event)" id="audio-btn" style="display:none;"><i class="fi fi-rr-play-circle"></i> Play</button>
1285
+ <p class="flip-hint"><i class="fi fi-rr-rotate-right"></i> Click card to flip 🎯</p>
1286
+ </div>
1287
+ <div class="card-face card-back">
1288
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;width:100%;justify-content:center;">
1289
+ <div class="en-text" id="back-en"></div>
1290
+ <i class="fi fi-rr-copy copy-icon" id="copy-en" onclick="copyText('back-en',event,this)" style="display:none;"></i>
1291
+ </div>
1292
+ <div class="tr-text" id="back-tr"></div>
1293
+ <div class="exp-text"><i class="fi fi-rr-lightbulb-on" style="color:#f1c40f;"></i> <span id="back-exp"></span></div>
1294
+ </div>
1295
+ </div>
1296
+ </div>
1297
+ <div class="sr-actions">
1298
+ <button class="sr-btn sr-dk" onclick="markDontKnow()">❌ Don't Know</button>
1299
+ <button class="sr-btn sr-sk" onclick="skipCard()">⏭ Skip</button>
1300
+ <button class="sr-btn sr-kn" onclick="markKnow()">βœ… Know</button>
1301
+ </div>
1302
+ <div class="nav-buttons">
1303
+ <button class="nav-btn" id="prev-btn" onclick="prevCard()"><i class="fi fi-rr-angle-left"></i> Prev</button>
1304
+ <button class="nav-btn" id="next-btn" onclick="nextCard()">Next <i class="fi fi-rr-angle-right"></i></button>
1305
+ </div>
1306
+ <div class="progress" id="prog"></div>
1307
+ </div>
1308
+ <script>
1309
+ const ALL = {cards_json};
1310
+ let disp=[...ALL], idx=0, reviewMode=false;
1311
+ const aud=new Audio();
1312
+
1313
+ function srKey(k){{ return 'sr_'+encodeURIComponent(k); }}
1314
+ function getSR(k){{ const s=localStorage.getItem(srKey(k)); return s?JSON.parse(s):{{interval:1,ef:2.5,due:null,status:'new'}}; }}
1315
+ function setSR(k,s){{ localStorage.setItem(srKey(k),JSON.stringify(s)); }}
1316
+ function today(){{ return new Date().toISOString().slice(0,10); }}
1317
+ function daysLater(n){{ const d=new Date(); d.setDate(d.getDate()+n); return d.toISOString().slice(0,10); }}
1318
+ function isDue(s){{ return !s.due||s.status==='new'||s.due<=today(); }}
1319
+
1320
+ function markKnow(){{
1321
+ if(!disp.length) return;
1322
+ const k=disp[idx].korean; let s=getSR(k);
1323
+ s.interval=Math.round((s.interval||1)*(s.ef||2.5));
1324
+ s.ef=Math.min(2.5,(s.ef||2.5)+0.1);
1325
+ s.due=daysLater(s.interval); s.status='known';
1326
+ setSR(k,s); afterAction();
1327
+ }}
1328
+ function markDontKnow(){{
1329
+ if(!disp.length) return;
1330
+ const k=disp[idx].korean; let s=getSR(k);
1331
+ s.interval=1; s.ef=Math.max(1.3,(s.ef||2.5)-0.2);
1332
+ s.due=daysLater(1); s.status='learning';
1333
+ setSR(k,s); afterAction();
1334
+ }}
1335
+ function skipCard(){{ if(!disp.length) return; afterAction(); }}
1336
+ function afterAction(){{
1337
+ if(reviewMode){{ refreshDue(); }}
1338
+ else {{ if(idx<disp.length-1){{ idx++; }} else {{ idx=0; }} showCard(); }}
1339
+ updateHeader();
1340
+ }}
1341
+
1342
+ function toggleReview(){{
1343
+ reviewMode=!reviewMode;
1344
+ const btn=document.getElementById('due-btn');
1345
+ if(reviewMode){{ btn.classList.add('active'); btn.textContent='πŸ“– All Cards'; refreshDue(); }}
1346
+ else{{ btn.classList.remove('active'); btn.textContent='πŸ“… Due Cards'; disp=[...ALL]; idx=0; showCard(); }}
1347
+ updateHeader();
1348
+ }}
1349
+ function refreshDue(){{
1350
+ const due=ALL.filter(c=>isDue(getSR(c.korean)));
1351
+ if(!due.length){{
1352
+ disp=[];
1353
+ document.getElementById('front-text').innerHTML='πŸŽ‰ All caught up!';
1354
+ ['audio-btn','copy-ko','copy-en','sr-badge'].forEach(id=>{{ const el=document.getElementById(id); if(el) el.style.display='none'; }});
1355
+ document.getElementById('prev-btn').disabled=true;
1356
+ document.getElementById('next-btn').disabled=true;
1357
+ document.getElementById('prog').textContent='✨ Nothing due today!';
1358
+ return;
1359
+ }}
1360
+ disp=due; if(idx>=disp.length) idx=0; showCard();
1361
+ }}
1362
+
1363
+ function updateHeader(){{
1364
+ const total=ALL.length; if(!total) return;
1365
+ const known=ALL.filter(c=>getSR(c.korean).status==='known').length;
1366
+ const due=ALL.filter(c=>isDue(getSR(c.korean))).length;
1367
+ document.getElementById('sr-fill').style.width=Math.round(known/total*100)+'%';
1368
+ document.getElementById('sr-stats').textContent=`βœ… ${{known}}/${{total}} Β· πŸ“… ${{due}} due`;
1369
+ if(!reviewMode){{
1370
+ const db=document.getElementById('due-btn');
1371
+ db.textContent=due>0?`πŸ“… Due (${{due}})`:'πŸ“… Due Cards';
1372
+ }}
1373
+ }}
1374
+
1375
+ function showCard(){{
1376
+ if(!disp.length) return;
1377
+ const c=disp[idx];
1378
+ document.getElementById('front-text').innerText=c.korean||'β€”';
1379
+ document.getElementById('back-en').innerText=c.translation||c.english||'';
1380
+ document.getElementById('back-tr').innerText=c.transliteration?`[${{c.transliteration}}]`:'';
1381
+ document.getElementById('back-exp').innerText=c.explanation||'';
1382
+ const s=getSR(c.korean);
1383
+ const badge=document.getElementById('sr-badge');
1384
+ badge.style.display='block';
1385
+ if(s.status==='known'){{ badge.textContent=isDue(s)?'⏰ DUE':'βœ… KNOWN'; badge.className=isDue(s)?'sr-badge badge-due':'sr-badge badge-known'; }}
1386
+ else if(s.status==='learning'){{ badge.textContent='πŸ“– LEARNING'; badge.className='sr-badge badge-learning'; }}
1387
+ else{{ badge.textContent='πŸ†• NEW'; badge.className='sr-badge badge-new'; }}
1388
+ document.getElementById('copy-ko').style.display='block';
1389
+ document.getElementById('copy-en').style.display='block';
1390
+ document.getElementById('prev-btn').disabled=idx===0;
1391
+ document.getElementById('next-btn').disabled=idx===disp.length-1;
1392
+ document.getElementById('prog').innerHTML=`πŸ“š Card ${{idx+1}} of ${{disp.length}}`;
1393
+ document.getElementById('card').classList.remove('is-flipped');
1394
+ if(c.audio_uri){{ aud.src=c.audio_uri; document.getElementById('audio-btn').style.display='flex'; }}
1395
+ else {{ document.getElementById('audio-btn').style.display='none'; }}
1396
+ updateHeader();
1397
+ }}
1398
+
1399
+ function flipCard(){{ if(disp.length) document.getElementById('card').classList.toggle('is-flipped'); }}
1400
+ function playAudio(e){{ e.stopPropagation(); aud.play().catch(()=>{{}}); }}
1401
+ function nextCard(){{ if(idx<disp.length-1){{ idx++; showCard(); }} }}
1402
+ function prevCard(){{ if(idx>0){{ idx--; showCard(); }} }}
1403
+ function copyText(id,e,el){{
1404
+ e.stopPropagation();
1405
+ navigator.clipboard.writeText(document.getElementById(id).innerText).then(()=>{{
1406
+ const old=el.className; el.className='fi fi-rr-check copy-icon'; el.style.color='#22c55e';
1407
+ setTimeout(()=>{{ el.className=old; el.style.color=''; }},1500);
1408
+ }}).catch(()=>{{}});
1409
+ }}
1410
+ window.onload=function(){{ if(ALL.length) showCard(); updateHeader(); }};
1411
+ </script>
1412
+ </body>
1413
+ </html>"""
1414
+
1415
+ safe_srcdoc = _html.escape(iframe_html)
1416
+ return f'<iframe srcdoc="{safe_srcdoc}" style="width:100%; height:700px; border:none; overflow-y:auto;"></iframe>'
1417
+
1418
+
1419
+ def build_quiz_html(vocab_list):
1420
+ """Build a 5-question multiple-choice quiz SPA."""
1421
+ import html as _html
1422
+ import random as rnd
1423
+
1424
+ if not vocab_list or len(vocab_list) < 2:
1425
+ return "<p style='color:#94a3b8;text-align:center;padding:30px;font-family:Outfit,sans-serif;font-size:16px;'>⚠️ Need at least 2 flashcards to start a quiz.<br>Generate or import a deck first!</p>"
1426
+
1427
+ nq = min(5, len(vocab_list))
1428
+ q_cards = rnd.sample(vocab_list, nq)
1429
+ quiz_data = []
1430
+ for qc in q_cards:
1431
+ correct = qc.get('translation', '') or qc.get('english', '')
1432
+ wrong_pool = [c for c in vocab_list if c is not qc and (c.get('translation', '') or c.get('english', '')) != correct]
1433
+ wrongs = rnd.sample(wrong_pool, min(3, len(wrong_pool)))
1434
+ choices = [correct] + [w.get('translation', '') or w.get('english', '') for w in wrongs]
1435
+ rnd.shuffle(choices)
1436
+ quiz_data.append({
1437
+ 'korean': qc.get('korean', ''),
1438
+ 'transliteration': qc.get('transliteration', ''),
1439
+ 'choices': choices,
1440
+ 'correct': choices.index(correct),
1441
+ })
1442
+
1443
+ quiz_json = json.dumps(quiz_data).replace("</", "<\\/")
1444
+
1445
+ iframe_html = f"""<!DOCTYPE html>
1446
+ <html>
1447
+ <head>
1448
+ <meta charset="utf-8">
1449
+ <link rel='stylesheet' href='https://cdn-uicons.flaticon.com/uicons-regular-rounded/css/uicons-regular-rounded.css'>
1450
+ <style>
1451
+ * {{ box-sizing:border-box; margin:0; padding:0; }}
1452
+ body {{ background:transparent; font-family:'Outfit','Inter',sans-serif; color:#f8fafc; padding:14px 10px; }}
1453
+ .quiz-hdr {{ text-align:center; margin-bottom:18px; }}
1454
+ .quiz-title {{ font-size:20px; font-weight:800; background:linear-gradient(to right,#c084fc,#60a5fa); -webkit-background-clip:text; -webkit-text-fill-color:transparent; margin-bottom:8px; }}
1455
+ .qpbar {{ width:100%; height:5px; background:rgba(255,255,255,.1); border-radius:3px; overflow:hidden; margin-bottom:4px; }}
1456
+ .qpfill {{ height:100%; background:linear-gradient(90deg,#8b5cf6,#3b82f6); border-radius:3px; transition:width .4s ease; }}
1457
+ .qptxt {{ font-size:12px; color:#64748b; }}
1458
+ .q-card {{ background:linear-gradient(135deg,rgba(30,41,59,.95) 0%,rgba(15,23,42,.95) 100%); border:1px solid rgba(139,92,246,.3); border-top:2px solid rgba(139,92,246,.7); border-radius:20px; padding:26px; text-align:center; margin-bottom:18px; box-shadow:0 10px 40px rgba(0,0,0,.5); }}
1459
+ .q-label {{ font-size:11px; text-transform:uppercase; letter-spacing:2px; color:#8b5cf6; font-weight:800; margin-bottom:10px; }}
1460
+ .q-word {{ font-size:52px; font-weight:800; color:#fff; text-shadow:0 0 25px rgba(139,92,246,.7); margin-bottom:7px; }}
1461
+ .q-tr {{ font-size:15px; color:#fca5a5; font-style:italic; letter-spacing:1px; }}
1462
+ .choices-grid {{ display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:14px; }}
1463
+ .choice-btn {{ padding:14px 12px; border-radius:14px; border:1px solid rgba(139,92,246,.3); background:rgba(139,92,246,.1); color:#e2e8f0; font-size:14px; font-weight:600; cursor:pointer; transition:all .25s; text-align:center; line-height:1.3; font-family:inherit; }}
1464
+ .choice-btn:hover:not(:disabled) {{ background:rgba(139,92,246,.3); border-color:rgba(139,92,246,.7); transform:translateY(-2px); box-shadow:0 5px 15px rgba(139,92,246,.3); }}
1465
+ .choice-btn.correct {{ background:rgba(34,197,94,.3)!important; border-color:#22c55e!important; color:#86efac!important; transform:scale(1.02); box-shadow:0 0 20px rgba(34,197,94,.4)!important; }}
1466
+ .choice-btn.wrong {{ background:rgba(239,68,68,.2)!important; border-color:rgba(239,68,68,.5)!important; color:#fca5a5!important; }}
1467
+ .choice-btn:disabled {{ cursor:default; }}
1468
+ .fb-txt {{ font-size:15px; font-weight:700; text-align:center; min-height:22px; margin-bottom:6px; }}
1469
+ .nxt-btn {{ width:100%; padding:13px; border:none; border-radius:14px; background:linear-gradient(135deg,#8b5cf6,#3b82f6); color:white; font-size:14px; font-weight:800; cursor:pointer; transition:all .3s; letter-spacing:.5px; text-transform:uppercase; display:none; font-family:inherit; }}
1470
+ .nxt-btn:hover {{ transform:translateY(-2px); box-shadow:0 8px 25px rgba(139,92,246,.5); }}
1471
+ #score-screen {{ display:none; text-align:center; padding:28px 16px; }}
1472
+ .score-em {{ font-size:70px; margin-bottom:14px; animation:pop .6s cubic-bezier(.68,-.55,.265,1.55); }}
1473
+ @keyframes pop {{ from {{ transform:scale(0); opacity:0; }} to {{ transform:scale(1); opacity:1; }} }}
1474
+ .score-ttl {{ font-size:26px; font-weight:800; background:linear-gradient(to right,#c084fc,#60a5fa); -webkit-background-clip:text; -webkit-text-fill-color:transparent; margin-bottom:6px; }}
1475
+ .score-sub {{ font-size:14px; color:#94a3b8; margin-bottom:20px; }}
1476
+ .score-details {{ background:rgba(139,92,246,.1); border:1px solid rgba(139,92,246,.3); border-radius:16px; padding:16px; margin-bottom:20px; }}
1477
+ .s-row {{ display:flex; justify-content:space-between; padding:5px 0; border-bottom:1px solid rgba(255,255,255,.05); font-size:13px; }}
1478
+ .s-row:last-child {{ border-bottom:none; }}
1479
+ .s-ko {{ color:#e2e8f0; }} .s-ok {{ color:#86efac; font-weight:700; }} .s-no {{ color:#fca5a5; font-weight:700; }}
1480
+ .restart-btn {{ padding:13px 28px; border:none; border-radius:14px; background:linear-gradient(135deg,#8b5cf6,#3b82f6); color:white; font-size:14px; font-weight:800; cursor:pointer; transition:all .3s; text-transform:uppercase; letter-spacing:1px; font-family:inherit; }}
1481
+ .restart-btn:hover {{ transform:translateY(-2px); box-shadow:0 8px 25px rgba(139,92,246,.5); }}
1482
+ </style>
1483
+ </head>
1484
+ <body>
1485
+ <div id="quiz-app">
1486
+ <div id="question-screen">
1487
+ <div class="quiz-hdr">
1488
+ <div class="quiz-title">🧠 Vocabulary Quiz</div>
1489
+ <div class="qpbar"><div class="qpfill" id="qp-fill" style="width:0%"></div></div>
1490
+ <div class="qptxt" id="qp-txt">Question 1 of {nq}</div>
1491
+ </div>
1492
+ <div class="q-card">
1493
+ <div class="q-label">What does this word mean?</div>
1494
+ <div class="q-word" id="q-word"></div>
1495
+ <div class="q-tr" id="q-tr"></div>
1496
+ </div>
1497
+ <div class="choices-grid" id="choices"></div>
1498
+ <div class="fb-txt" id="fb"></div>
1499
+ <button class="nxt-btn" id="nxt-btn" onclick="nextQ()">Next β†’</button>
1500
+ </div>
1501
+ <div id="score-screen">
1502
+ <div class="score-em" id="s-em"></div>
1503
+ <div class="score-ttl" id="s-ttl"></div>
1504
+ <div class="score-sub" id="s-sub"></div>
1505
+ <div class="score-details" id="s-det"></div>
1506
+ <button class="restart-btn" onclick="restart()">πŸ”„ Try Again</button>
1507
+ </div>
1508
+ </div>
1509
+ <script>
1510
+ const QD={quiz_json};
1511
+ let qi=0,score=0,res=[],answered=false;
1512
+ const NQ=QD.length;
1513
+ function loadQ(){{
1514
+ if(qi>=NQ){{ showScore(); return; }}
1515
+ answered=false;
1516
+ const q=QD[qi];
1517
+ document.getElementById('q-word').textContent=q.korean;
1518
+ document.getElementById('q-tr').textContent=q.transliteration?`[${{q.transliteration}}]`:'';
1519
+ document.getElementById('qp-fill').style.width=(qi/NQ*100)+'%';
1520
+ document.getElementById('qp-txt').textContent=`Question ${{qi+1}} of ${{NQ}}`;
1521
+ document.getElementById('fb').innerHTML='';
1522
+ document.getElementById('nxt-btn').style.display='none';
1523
+ const ch=document.getElementById('choices'); ch.innerHTML='';
1524
+ q.choices.forEach((c,i)=>{{
1525
+ const b=document.createElement('button'); b.className='choice-btn';
1526
+ b.textContent=c; b.onclick=()=>pick(i,b); ch.appendChild(b);
1527
+ }});
1528
+ }}
1529
+ function pick(ci,btn){{
1530
+ if(answered) return; answered=true;
1531
+ const q=QD[qi]; const ok=ci===q.correct;
1532
+ document.querySelectorAll('.choice-btn').forEach(b=>b.disabled=true);
1533
+ document.querySelectorAll('.choice-btn')[q.correct].classList.add('correct');
1534
+ if(ok){{ score++; document.getElementById('fb').innerHTML='βœ… <span style="color:#86efac">Correct!</span>'; res.push({{k:q.korean,ok:true}}); }}
1535
+ else{{ btn.classList.add('wrong'); document.getElementById('fb').innerHTML=`❌ <span style="color:#fca5a5">Wrong!</span> β†’ <strong>${{q.choices[q.correct]}}</strong>`; res.push({{k:q.korean,ok:false,ans:q.choices[q.correct]}}); }}
1536
+ const nb=document.getElementById('nxt-btn'); nb.style.display='block';
1537
+ nb.textContent=qi<NQ-1?'Next Question β†’':'See Results πŸŽ‰';
1538
+ }}
1539
+ function nextQ(){{ qi++; loadQ(); }}
1540
+ function showScore(){{
1541
+ document.getElementById('question-screen').style.display='none';
1542
+ document.getElementById('score-screen').style.display='block';
1543
+ const p=score/NQ;
1544
+ const data=p===1?['πŸ†','Perfect Score!','You nailed every question!']:p>=.8?['⭐','Excellent!','Almost perfect!']:p>=.6?['πŸ‘','Good Work!','Keep practicing!']:p>=.4?['πŸ“š','Keep Studying!','Review the flashcards!']:['πŸ’ͺ','Keep Going!',"Practice makes perfect!"];
1545
+ document.getElementById('s-em').textContent=data[0];
1546
+ document.getElementById('s-ttl').textContent=`${{score}}/${{NQ}} β€” ${{data[1]}}`;
1547
+ document.getElementById('s-sub').textContent=data[2];
1548
+ document.getElementById('s-det').innerHTML=res.map(r=>`<div class="s-row"><span class="s-ko">${{r.k}}</span><span class="${{r.ok?'s-ok':'s-no'}}">${{r.ok?'βœ… Correct':'❌ '+r.ans}}</span></div>`).join('');
1549
+ }}
1550
+ function restart(){{
1551
+ qi=0; score=0; res=[]; answered=false;
1552
+ document.getElementById('question-screen').style.display='block';
1553
+ document.getElementById('score-screen').style.display='none';
1554
+ loadQ();
1555
+ }}
1556
+ window.onload=loadQ;
1557
+ </script>
1558
+ </body>
1559
+ </html>"""
1560
+
1561
+ safe_srcdoc = _html.escape(iframe_html)
1562
+ return f'<iframe srcdoc="{safe_srcdoc}" style="width:100%; height:700px; border:none; overflow-y:auto;"></iframe>'
1563
+
1564
+
1565
+ def export_json_file_fn(vocab_list):
1566
+ """Export current vocab list to a JSON file for download."""
1567
+ if not vocab_list:
1568
+ gr.Warning("No flashcards to export. Generate or import a deck first!")
1569
+ return gr.update(visible=False)
1570
+ os.makedirs("log", exist_ok=True)
1571
+ path = os.path.join("log", "flashcards_export.json")
1572
+ export_data = [{k: v for k, v in item.items() if k != 'audio_uri'} for item in vocab_list]
1573
+ with open(path, "w", encoding="utf-8") as f:
1574
+ json.dump(export_data, f, ensure_ascii=False, indent=2)
1575
+ return gr.update(value=path, visible=True)
1576
+
1577
+
1578
+ def export_anki_file_fn(vocab_list):
1579
+ """Export current vocab list to an Anki .apkg file for download."""
1580
+ if not vocab_list:
1581
+ gr.Warning("No flashcards to export. Generate or import a deck first!")
1582
+ return gr.update(visible=False)
1583
+ try:
1584
+ import genanki
1585
+ import random as rnd
1586
+ except ImportError:
1587
+ gr.Warning("genanki not installed. Run: pip install genanki")
1588
+ return gr.update(visible=False)
1589
+ model = genanki.Model(
1590
+ rnd.randrange(1 << 30, 1 << 31),
1591
+ 'LocalDuo Korean Vocab',
1592
+ fields=[{'name': 'Korean'}, {'name': 'Translation'}, {'name': 'Transliteration'}, {'name': 'Explanation'}],
1593
+ templates=[{
1594
+ 'name': 'Card 1',
1595
+ 'qfmt': '<div style="font-size:42px;text-align:center;font-weight:bold;color:#4a0e8f;padding:20px;">{{Korean}}</div>',
1596
+ 'afmt': '{{FrontSide}}<hr id=answer><div style="font-size:24px;font-weight:bold;color:#1a56db;">{{Translation}}</div><div style="color:#888;font-style:italic;margin:8px 0;">{{Transliteration}}</div><div style="font-size:14px;color:#555;background:#f5f5f5;padding:10px;border-radius:8px;">{{Explanation}}</div>',
1597
+ }]
1598
  )
1599
+ deck = genanki.Deck(rnd.randrange(1 << 30, 1 << 31), 'LocalDuo - Korean Vocabulary')
1600
+ for item in vocab_list:
1601
+ deck.add_note(genanki.Note(model=model, fields=[
1602
+ item.get('korean', ''),
1603
+ item.get('translation', '') or item.get('english', ''),
1604
+ item.get('transliteration', ''),
1605
+ item.get('explanation', ''),
1606
+ ]))
1607
+ os.makedirs("log", exist_ok=True)
1608
+ path = os.path.join("log", "flashcards_export.apkg")
1609
+ genanki.Package(deck).write_to_file(path)
1610
+ return gr.update(value=path, visible=True)
1611
+
1612
+
1613
+ def import_deck_fn(json_file, anki_file):
1614
+ """Load a flashcard deck from a JSON or Anki .apkg file."""
1615
+ if json_file is not None:
1616
+ try:
1617
+ with open(json_file, "r", encoding="utf-8") as f:
1618
+ data = json.load(f)
1619
+ if not isinstance(data, list):
1620
+ data = [data]
1621
+ for item in data:
1622
+ if 'audio_uri' not in item:
1623
+ item['audio_uri'] = None
1624
+ return build_flashcard_html(data), data
1625
+ except Exception as e:
1626
+ return f"<p style='color:#fca5a5;padding:20px;font-family:Outfit,sans-serif;'>❌ Error loading JSON: {e}</p>", None
1627
+ elif anki_file is not None:
1628
+ try:
1629
+ import zipfile, sqlite3, tempfile
1630
+ with tempfile.TemporaryDirectory() as tmpdir:
1631
+ with zipfile.ZipFile(anki_file, 'r') as z:
1632
+ z.extractall(tmpdir)
1633
+ db_path = os.path.join(tmpdir, 'collection.anki2')
1634
+ if not os.path.exists(db_path):
1635
+ db_path = os.path.join(tmpdir, 'collection.anki21')
1636
+ conn = sqlite3.connect(db_path)
1637
+ rows = conn.execute("SELECT flds FROM notes").fetchall()
1638
+ conn.close()
1639
+ vocab_list = []
1640
+ for row in rows:
1641
+ fields = row[0].split('\x1f')
1642
+ vocab_list.append({
1643
+ 'korean': fields[0] if len(fields) > 0 else '',
1644
+ 'translation': fields[1] if len(fields) > 1 else '',
1645
+ 'transliteration': fields[2] if len(fields) > 2 else '',
1646
+ 'explanation': fields[3] if len(fields) > 3 else '',
1647
+ 'audio_uri': None,
1648
+ })
1649
+ if not vocab_list:
1650
+ return "<p style='color:#fca5a5;padding:20px;font-family:Outfit,sans-serif;'>❌ No notes found in Anki deck.</p>", None
1651
+ return build_flashcard_html(vocab_list), vocab_list
1652
+ except Exception as e:
1653
+ return f"<p style='color:#fca5a5;padding:20px;font-family:Outfit,sans-serif;'>❌ Error loading Anki deck: {e}</p>", None
1654
+ return "<p style='color:#94a3b8;padding:20px;text-align:center;font-family:Outfit,sans-serif;'>⚠️ Please upload a JSON or Anki (.apkg) file above.</p>", None
1655
 
1656
 
1657
  def create_demo():
 
1771
  with gr.Column(scale=1):
1772
  with gr.Tabs() as input_tabs:
1773
  with gr.Tab("Website URL", id="tab_url") as tab_url:
1774
+ url_input = gr.Textbox(label="Enter a Website URL 🌐",
1775
+ placeholder=r"e.g. https://www.bbc.com/korean/articles/cn0p7rkvxdgo",
1776
  value=r"https://www.bbc.com/korean/articles/cn0p7rkvxdgo")
1777
+
1778
  with gr.Tab("Upload PDF", id="tab_pdf") as tab_pdf:
1779
  pdf_input = gr.File(label="Upload Book PDF πŸ“š", file_types=[".pdf"], value=example_pdf)
1780
+
1781
  with gr.Tab("Upload Audio", id="tab_audio") as tab_audio:
1782
  audio_file_input = gr.File(label="Upload Audio File 🎡", file_types=[".wav", ".mp3", ".m4a", ".ogg", ".flac", ".opus", ".webm"], value=example_audio)
1783
  gr.Markdown("*Upload a Korean audio file. It will be transcribed using Cohere ASR and vocabulary will be extracted from the transcript.*", elem_classes=["hint-text"])
1784
+
1785
  with gr.Tab("YouTube Link", id="tab_yt") as tab_yt:
1786
+ yt_url_input = gr.Textbox(label="Enter a YouTube Link 🎬",
1787
+ placeholder=r"e.g. https://www.youtube.com/watch?v=...",
1788
  value="https://www.youtube.com/watch?v=9Nj7l73PBWE",
1789
  info="Audio from the first 5 minutes will be transcribed using Cohere ASR")
1790
  yt_cookies_input = gr.File(label="YouTube Cookies (cookies.txt)", file_types=[".txt"], value=None,
1791
  type="filepath")
1792
  gr.Markdown("*Optional. Helps bypass YouTube bot detection. Install the [cookies.txt](https://addons.mozilla.org/firefox/addon/cookies-txt/) extension, go to youtube.com while logged in, click the extension β†’ 'Current Site' to export.*", elem_classes=["hint-text"])
1793
+
1794
+ with gr.Tab("πŸ“‚ Import Deck", id="tab_import") as tab_import:
1795
+ gr.Markdown("### Load a saved deck into the app")
1796
+ gr.Markdown("Upload a previously exported **JSON file** or an **Anki .apkg deck** to reload flashcards without regenerating.")
1797
+ import_json_file_in = gr.File(label="πŸ“„ JSON Deck (.json)", file_types=[".json"])
1798
+ import_anki_file_in = gr.File(label="πŸ“¦ Anki Deck (.apkg)", file_types=[".apkg"])
1799
+ import_load_btn = gr.Button("πŸ“‚ Load Deck", variant="primary")
1800
+
1801
  # Track active tab
1802
  tab_url.select(fn=lambda: "Website URL", inputs=None, outputs=active_tab)
1803
  tab_pdf.select(fn=lambda: "Upload PDF", inputs=None, outputs=active_tab)
1804
  tab_audio.select(fn=lambda: "Upload Audio", inputs=None, outputs=active_tab)
1805
  tab_yt.select(fn=lambda: "YouTube Link", inputs=None, outputs=active_tab)
1806
+ tab_import.select(fn=lambda: "Import Deck", inputs=None, outputs=active_tab)
1807
+
1808
  gr.Markdown("### βš™οΈ Customization Settings")
1809
  max_text_char_input = gr.Slider(minimum=1000, maximum=30000, step=1000, value=1500, label="Max Input Text Length (Characters)")
1810
  repetition_penalty_input = gr.Slider(minimum=0.1, maximum=2.0, step=0.1, value=1.2, label="Repetition Penalty")
1811
  auto_force_chars_input = gr.Slider(minimum=1_000, maximum=10_000, step=100, value=4_000, label="Auto-force JSON after (chars of thinking)")
1812
+
1813
  with gr.Accordion("πŸ”§ Advanced", open=False):
1814
  translit_lang = gr.Dropdown(
1815
+ label="Word Transliteration Language",
1816
+ choices=LANGUAGE_CHOICES,
1817
  value="Indo-European - English"
1818
  )
1819
  translit_format = gr.Dropdown(label="Transliteration Format", choices=["dashed syllable", "regular word with space"], value="dashed syllable")
1820
  target_lang = gr.Dropdown(
1821
+ label="Target Language (Full App)",
1822
+ choices=LANGUAGE_CHOICES,
1823
  value="Indo-European - English"
1824
  )
1825
+
1826
  with gr.Row():
1827
  submit_btn = gr.Button("✨ Generate Flashcards ✨", variant="primary")
1828
  stop_thinking_btn = gr.Button("⚑ Stop thinking, Generate now", variant="secondary")
1829
  stop_btn = gr.Button("πŸ›‘ Stop Generation", variant="stop")
1830
+
1831
  with gr.Column(scale=2):
1832
+ with gr.Tabs() as output_tabs:
1833
+ with gr.Tab("πŸ“– Flashcards"):
1834
+ output_html = gr.HTML(label="Flashcards will appear here")
1835
+ gr.Markdown("**Export current deck:**")
1836
+ with gr.Row():
1837
+ export_json_btn = gr.Button("πŸ“₯ Export JSON", variant="secondary", size="sm")
1838
+ export_anki_btn = gr.Button("πŸ“¦ Export Anki (.apkg)", variant="secondary", size="sm")
1839
+ export_json_out = gr.File(label="⬇️ JSON Download", visible=False, interactive=False)
1840
+ export_anki_out = gr.File(label="⬇️ Anki Deck Download", visible=False, interactive=False)
1841
+
1842
+ with gr.Tab("❓ Quiz"):
1843
+ gr.Markdown("**Test your knowledge** with a randomized 5-question multiple-choice quiz from the current deck.")
1844
+ start_quiz_btn = gr.Button("πŸ§ͺ Start 5-Question Quiz", variant="primary")
1845
+ quiz_output_html = gr.HTML(label="Quiz")
1846
+
1847
  stream_box = gr.Textbox(label="Live Model Generation 🧠", lines=10, max_lines=20, interactive=False, autoscroll=True, elem_id="stream_box")
1848
+
1849
  with gr.Accordion("πŸ“„ Extracted Source Content", open=True):
1850
  extracted_text_box = gr.Textbox(label="Extracted Text", lines=10, max_lines=15, interactive=False)
1851
  extracted_images_gallery = gr.Gallery(label="Extracted Images", columns=4, height="auto", object_fit="contain")
1852
  extracted_audio_player = gr.Audio(label="Extracted Audio (YouTube / Uploaded)", type="filepath", interactive=False)
1853
+
1854
  last_source_state = gr.State(None)
1855
  last_korean_words_state = gr.State(None)
1856
 
 
1860
  submit_btn.click(fn=reset_btn_text, inputs=None, outputs=[stop_thinking_btn, stop_btn], queue=False)
1861
 
1862
  generate_event = submit_btn.click(
1863
+ fn=process_pdf,
1864
+ inputs=[pdf_input, url_input, audio_file_input, yt_url_input, yt_cookies_input, translit_lang, translit_format, target_lang, max_text_char_input, repetition_penalty_input, auto_force_chars_input, last_source_state, last_korean_words_state, active_tab],
1865
  outputs=[output_html, last_source_state, last_korean_words_state, stream_box, extracted_text_box, extracted_images_gallery, extracted_audio_player]
1866
  )
1867
+
1868
+ stop_thinking_btn.click(fn=set_stop_thinking, inputs=None, outputs=stop_thinking_btn, queue=False).then(
1869
+ fn=reset_stop_thinking_after_delay, inputs=None, outputs=stop_thinking_btn
1870
+ )
1871
  stop_btn.click(fn=set_kill_threads, inputs=None, outputs=stop_btn, queue=False)
1872
+
1873
+ # Export events
1874
+ export_json_btn.click(fn=export_json_file_fn, inputs=[last_korean_words_state], outputs=[export_json_out])
1875
+ export_anki_btn.click(fn=export_anki_file_fn, inputs=[last_korean_words_state], outputs=[export_anki_out])
1876
+
1877
+ # Import event
1878
+ import_load_btn.click(
1879
+ fn=import_deck_fn,
1880
+ inputs=[import_json_file_in, import_anki_file_in],
1881
+ outputs=[output_html, last_korean_words_state]
1882
+ )
1883
+
1884
+ # Quiz event
1885
+ start_quiz_btn.click(fn=build_quiz_html, inputs=[last_korean_words_state], outputs=[quiz_output_html])
1886
+
1887
  # Force autoscroll using Custom JS
1888
  stream_box.change(
1889
  fn=None,