use crate::analytics::{accuracy, fmt_dur, fmt_pct, Analysis, Bucket, TestRow}; use crate::auth::Ctx; use crate::exam::{BuildForm, Question}; pub fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len() + 16); for c in s.chars() { match c { '&' => o.push_str("&"), '<' => o.push_str("<"), '>' => o.push_str(">"), '"' => o.push_str("""), '\'' => o.push_str("'"), _ => o.push(c), } } o } /// Render question body HTML stored in the DB. The importer sanitizes at write /// time; this re-sanitizes on read so a hand-edited row can never inject a /// script. Only the sanitizer's allowlist survives — see /// scripts/import_examgoal.py for the matching write-side list. pub fn rich(s: &str) -> String { const ALLOWED: &[&str] = &[ "br", "p", "sup", "sub", "b", "strong", "i", "em", "u", "ol", "ul", "li", "table", "tbody", "thead", "tr", "th", "td", "span", ]; // A `<` only starts a tag when followed by a letter, `/`, `!` or `?` // (i.e. `

`, `

`, `
{picker}
DIFFICULTY
YEARS
{e}
0 chapters selected Pick chapters above
"#, available = available, b = ctx.base, picker = picker, e = e ); shell(ctx, Some(user), "Build", body) } fn palette(questions: &[Question], answers: &[(i64, String, bool)]) -> String { questions .iter() .enumerate() .map(|(i, q)| { let a = answers.iter().find(|(id, _, _)| *id == q.id); let sel = a.map(|(_, s, _)| !s.trim().is_empty()).unwrap_or(false); let rev = a.map(|(_, _, r)| *r).unwrap_or(false); let cls = match (sel, rev) { (true, true) => "pal ansrev", (true, false) => "pal ans", _ => "pal", }; format!(r#""#, cls = cls, n = i + 1) }) .collect::>() .join("") } pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], answers: &[(i64, String, bool)], duration_min: i64, title: &str) -> String { let body_questions: Vec = questions .iter() .enumerate() .map(|(i, q)| { let cur = answers.iter().find(|(id, _, _)| *id == q.id); let sel = cur.map(|(_, s, _)| s.clone()).unwrap_or_default(); let rev = cur.map(|(_, _, r)| *r).unwrap_or(false); let opts: Vec = if q.qtype == "NUM" { vec![format!( r#""#, id = q.id, v = esc(&sel) )] } else { ["A", "B", "C", "D"] .iter() .enumerate() .map(|(k, letter)| { let text = q.opts[k].clone(); let checked = if sel.trim().eq_ignore_ascii_case(letter) { " checked" } else { "" }; format!( r#""#, cls = if sel.trim().eq_ignore_ascii_case(letter) { " sel" } else { "" }, id = q.id, letter = letter, checked = checked, t = rich(&text) ) }) .collect() }; format!( r#"
question {n} {subj} · {qtword} · +4 / −1{revflag}
{stem}
{qopts}
{sub2} · {ch} · {year} {shift} · {difficulty}
{opts}
"#, hidden = if i == 0 { "" } else { " hidden" }, rev0 = if rev { "1" } else { "0" }, prevdis = if i == 0 { " disabled" } else { "" }, revcls = if rev { " danger" } else { "" }, n = i + 1, subj = esc(&q.subject), ch = esc(&q.chapter), qtword = if q.qtype == "NUM" { "numerical" } else { "single correct" }, revflag = format!(" · marked", h = if rev { "" } else { " hidden" }), stem = rich(&q.stem), qopts = if q.qtype == "NUM" { String::new() } else { // Read-only option text inside the white card (mirrors the // typeset block; the pills below are the click targets). let items: String = ["A", "B", "C", "D"] .iter() .enumerate() .map(|(k, _letter)| { format!( "

({n}) {t}

", n = k + 1, t = rich(&q.opts[k]), ) }) .collect(); items }, sub2 = esc(&q.subtopic), year = q.year, shift = esc(&q.shift), difficulty = esc(&q.difficulty), opts = opts.join("") ) }) .collect(); // Group questions by subject to build per-subject timer strips. let subjects_ordered: Vec<&str> = { let mut seen = std::collections::LinkedList::new(); let mut added = std::collections::HashSet::new(); for q in questions { if added.insert(q.subject.as_str()) { seen.push_back(q.subject.as_str()); } } seen.into_iter().collect() }; // Detect JEE mode: duration == 60 * nsubjects (and nsubjects > 1). let is_jee = subjects_ordered.len() > 1 && duration_min == 60 * subjects_ordered.len() as i64; let subj_timer_bar = if is_jee { let strips: String = subjects_ordered.iter().enumerate().map(|(i, subj)| { let cls = if i == 0 { " active" } else { "" }; let secs = 60 * 60i64; // 60 min per subject format!(r#"
{s}{m}:00
"#, cls = cls, s = esc(subj), secs = secs, i = i, m = 60) }).collect(); format!(r#"
{strips}
"#) } else { String::new() }; let body = format!( r#"
{title}{n} questions
candidate {user}
time left
{dur}
{stbar}
{qs}
"#, title = esc(title), user = esc(user), n = questions.len(), dur = fmt_dur(duration_min as i64 * 60), secs = duration_min * 60, stbar = subj_timer_bar, b = ctx.base, a = esc(attempt_id), qs = body_questions.join(""), pal = palette(questions, answers) ); shell_exam(ctx, Some(user), "Test", body) } pub struct RowView<'a> { pub q: &'a Question, pub selected: &'a str, pub marks: i32, pub correct: bool, pub time_secs: i64, pub attempt_id: &'a str, pub tags: Vec, } const MISTAKE_TAGS: &[(&str, &str)] = &[ ("concept", "concept gap"), ("calculation", "calculation slip"), ("misread", "misread question"), ("time", "ran out of time"), ("guess", "blind guess"), ]; fn mistake_form(base: &str, attempt: &str, qid: i64, state: &str, tags: &[String]) -> String { if state == "correct" { return String::new(); } let btns: String = MISTAKE_TAGS .iter() .map(|(v, label)| { let on = tags.iter().any(|t| t == v); format!( r#""#, v = v, cls = if on { " primary" } else { "" }, label = label ) }) .collect::>() .join(""); format!( r#"
why lost? {btns}
"#, base = base, attempt = attempt, qid = qid, btns = btns ) } fn seg(b: &Bucket) -> String { let t = b.total().max(1) as f64; format!( r#"
"#, b.right as f64 / t * 100.0, b.wrong as f64 / t * 100.0, b.skipped as f64 / t * 100.0 ) } fn bucket_rows(items: &[Bucket]) -> String { items .iter() .map(|b| { format!( r#"{l}{r}{w}{s}{seg}{p}%{st}"#, l = esc(&b.label), r = b.right, w = b.wrong, s = b.skipped, seg = seg(b), p = b.pct(), st = b.status() ) }) .collect() } pub fn result(ctx: &Ctx, user: &str, attempt_id: &str, share_token: &str, a: &Analysis, rows: &[RowView]) -> String { let base = ctx.base.clone(); let mistakes_pending = rows .iter() .filter(|r| !r.correct && r.tags.is_empty()) .count(); let title = format!("practice paper · {attempt_id}"); // Share box mirrors the paper-preview page: link + copy button. let share_box = if share_token.is_empty() { String::new() } else { format!( r##""##, b = public_origin(), tok = esc(share_token) ) }; let detail: String = rows .iter() .enumerate() .map(|(i, r)| { let state = if r.correct { "correct" } else if r.selected.trim().is_empty() { "blank" } else { "wrong" }; let cls = match state { "correct" => "ok-t", "wrong" => "bad-t", _ => "dim" }; let opts: String = if r.q.qtype == "NUM" { format!(r#"

your answer: {sel} · correct: {ans}

"#, sel = esc(if r.selected.is_empty() { "not attempted" } else { r.selected }), ans = esc(&r.q.answer)) } else { ["A", "B", "C", "D"] .iter() .enumerate() .map(|(k, l)| { let is_ans = r.q.answer.trim().eq_ignore_ascii_case(l); let is_sel = r.selected.trim().eq_ignore_ascii_case(l); let style = if is_ans { "ok-t" } else if is_sel { "bad-t" } else { "dim" }; format!(r#"{l}: {t}{mark}"#, style = style, l = l, t = rich(&r.q.opts[k]), mark = if is_ans { " (correct)" } else if is_sel { " (you)" } else { "" }) }) .collect() }; format!( r#"
q{n} · {subj} · {state} · {marks:+} marks
{stem}

{opts}

{sub2} · {ch} · {year} {shift} · {difficulty} · {qtime}

{sol}

{mform}
"#, n = i + 1, subj = esc(&r.q.subject), cls = cls, state = state, marks = r.marks, stem = rich(&r.q.stem), opts = opts, sub2 = esc(&r.q.subtopic), ch = esc(&r.q.chapter), year = r.q.year, shift = esc(&r.q.shift), difficulty = esc(&r.q.difficulty), qtime = if r.time_secs > 0 { format!("{}s spent", r.time_secs) } else { "no time recorded".to_string() }, sol = rich(&r.q.solution), mform = mistake_form(&base, r.attempt_id, r.q.id, state, &r.tags) ) }) .collect(); let topic_buckets: Vec = a.by_topic.iter().map(|(b, _)| b.to_owned()).collect(); let neg = rows.iter().filter(|r| !r.correct && !r.selected.trim().is_empty()).count() as i32; // Encouragement line: tone follows the score, like the ZenithPrep modal. let pct = fmt_pct(a.score, a.max_score); let (enc_head, enc_body) = if pct >= 75 { ("strong paper", "This score shows the chapters you chose are under control. Keep the mistakes tagged so the next paper skips what you already own.") } else if pct >= 40 { ("on the right track", "Half the battle is spotting which step broke. Tag the mistakes below and the next paper targets exactly those chapters.") } else { ("this is where progress starts", "A low score just maps the work. Tag each mistake below, then rebuild a paper from the weak chapters — the bank has enough PYQs to drill each one.") }; let body = format!( r##"
score{score} / {max}
accuracy{acc}%
negative marks−{neg}
time used{dur}
{eh}

{eb}

{share_box}

by subject

{subj}
subjectrightwrongblank%

by difficulty

{diff}
difficultyrightwrongblank%

chapters that cost you marks

{topic}
subtopicrightwrongblank%

every question

{detail}

"##, score = a.score, max = a.max_score, neg = neg, acc = accuracy(a.right, a.wrong), dur = fmt_dur(a.used_secs), eh = enc_head, eb = enc_body, share_box = share_box, subj = bucket_rows(&a.by_subject), diff = bucket_rows(&a.by_difficulty), topic = bucket_rows(&topic_buckets), mtag = if mistakes_pending > 0 { format!(r#" {}"#, mistakes_pending) } else { String::new() }, title_ = esc(&title), detail = detail, b = ctx.base ); shell(ctx, Some(user), "Result", body) } pub fn history(ctx: &Ctx, user: &str, tests: &[TestRow]) -> String { let rows: String = if tests.is_empty() { String::new() } else { tests .iter() .map(|t| { format!( r#"{d}{title}{score} / {max}{p}%{acc}%{dur}open"#, d = t.started_at.format("%d %b %Y"), title = esc(&t.title), score = t.score, max = t.max_score, p = fmt_pct(t.score, t.max_score), acc = accuracy(t.right, t.wrong), dur = fmt_dur(t.used_secs), b = ctx.base, id = esc(&t.attempt_id) ) }) .collect() }; let body = if tests.is_empty() { format!( r#"

History

No papers yet

Nothing is here because no paper has been submitted. Build one and it appears here with its score and accuracy.

build your first paper

"#, b = ctx.base ) } else { format!( r#"

History

{rows}
datepaperscore%accuracytime
"#, rows = rows ) }; shell(ctx, Some(user), "History", body) } pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, String, String)], notes: &[String], totals: (i32, i32, i32, i32), mistakes: &[(String, i64)]) -> String { let (taken, marks, gained, lost) = totals; let trend: String = if tests.is_empty() { String::new() } else { let best = tests.iter().map(|t| t.score * 100 / t.max_score.max(1)).max().unwrap_or(0); let mut s = String::from(r#"

percentage across papers

"#); for t in tests { s.push_str(&format!( r#""#, t.started_at.format("%d %b"), esc(&t.title), fmt_pct(t.score, t.max_score), accuracy(t.right, t.wrong) )); } s.push_str("
datepaper%accuracy
{}{}{}%{}%
"); s = s.replace("{best}", &best.to_string()); s }; let weak_rows: String = weak .iter() .map(|(b, chapter, subtopic)| { format!( r#"{sub} · {ch}{t}{w}{s}{seg}{st}"#, sub = esc(subtopic), ch = esc(chapter), t = b.total(), w = b.wrong, s = b.skipped, seg = seg(b), st = b.status() ) }) .collect(); let notes_html: String = notes.iter().map(|n| format!("
  • {}
  • ", esc(n))).collect(); let mistakes_html: String = if mistakes.is_empty() { String::new() } else { let total: i64 = mistakes.iter().map(|(_, n)| n).sum::().max(1); let rows: String = mistakes .iter() .map(|(tag, n)| { let label = match tag.as_str() { "concept" => "concept gap", "calculation" => "calculation slip", "misread" => "misread question", "time" => "ran out of time", "guess" => "blind guess", t => t, }; format!( r#"{label}{n}
    {p}%"#, label = esc(label), n = n, p = (*n as f64 / total as f64 * 100.0).round() as i64 ) }) .collect(); format!( r#"

    why you lose marks

    {rows}
    mistake typecount

    Tag lost questions on any result page; the most common reason surfaces here.

    "#, rows = rows ) }; let body = if tests.is_empty() { format!( r#"

    Dashboard

    No test data yet

    The trend, the weak-subtopic table and the coach notes all come from papers you submit, so there is nothing to show until the first one is done.

    build a paper

    "#, b = ctx.base ) } else { format!( r#"

    Dashboard

    papers submitted{taken}
    marks scored{marks}
    correct{gained}
    wrong{lost}
    {trend}

    weakest subtopics across all papers

    {weak}
    subtopicseenwrongblank
    {mistakes_html}

    coach notes

      {notes}

    Written from your own submitted papers. No data leaves this machine.

    all papers

    "#, mistakes_html = mistakes_html, taken = taken, marks = marks, gained = gained, lost = lost, trend = trend, weak = weak_rows, notes = notes_html, b = ctx.base ) }; shell(ctx, Some(user), "Dashboard", body) } pub fn shared(ctx: &Ctx, title: &str, duration: i64, questions: &[Question], paper_id: &str, token: &str, signed: bool) -> String { use std::collections::BTreeMap; // Group questions by subject, preserving first-seen order. let mut order: Vec = Vec::new(); let mut by_subj: BTreeMap> = BTreeMap::new(); for (i, q) in questions.iter().enumerate() { if !by_subj.contains_key(&q.subject) { order.push(q.subject.clone()); } by_subj.entry(q.subject.clone()).or_default().push((i, q)); } let n = questions.len(); let max_marks = n as i64 * 4; // Subject sections with chapter lists. let mut sections = String::new(); for subj in &order { let qs = &by_subj[subj]; let start = qs.first().map(|(i, _)| i + 1).unwrap_or(1); let end = qs.last().map(|(i, _)| i + 1).unwrap_or(n); let mut chapters: Vec = Vec::new(); for (_, q) in qs { if !chapters.iter().any(|c| c == &q.chapter) { chapters.push(q.chapter.clone()); } } let ch_list: String = chapters.iter().map(|c| format!("
  • {}
  • ", esc(c))).collect(); sections.push_str(&format!( r#"

    {subj} q{start}–q{end}

      {chs}
    "#, subj = esc(subj), start = start, end = end, chs = ch_list )); } let qs: String = questions .iter() .enumerate() .map(|(i, q)| { let opts = if q.qtype == "NUM" { String::new() } else { ["A", "B", "C", "D"] .iter() .enumerate() .map(|(k, l)| format!("

    {l} {}

    ", rich(&q.opts[k]))) .collect() }; format!( r#"
    q{n} · {subj} · {sub2}
    {stem}
    {opts}
    "#, n = i + 1, subj = esc(&q.subject), sub2 = esc(&q.subtopic), stem = rich(&q.stem), opts = opts ) }) .collect(); let cta = if signed { format!( r#"
    "#, b = ctx.base, id = esc(paper_id) ) } else { format!( r#"

    sign in to take this paper

    "#, b = ctx.base, tok = esc(token) ) }; let body = format!( r#"

    Practice paper

    {title} · JEE Main pattern · no answers or scores on this page.

    questions{count}
    duration{dur} min
    maximum marks{max}
    marking+4 / −1
    {sections}

    take this paper

    The online test works like the real exam: answers count only after save & next.

    {cta}

    Only the question paper is shared. Your answers and score stay private.

    every question

    {qs}
    "#, title = esc(title), count = questions.len(), dur = duration, max = max_marks, sections = sections, cta = cta, qs = qs ); shell(ctx, None, title, body) } #[cfg(test)] mod rich_tests { use super::rich; #[test] fn raw_less_than_in_math_survives() { let input = r"

    B. \(\operatorname{Im}\left(z_{1}\right) < 0\) and \(\operatorname{Im}\left(z_{2}\right) > 0\)

    "; let out = rich(input); // The `<` must be escaped, not eaten; delimiters stay balanced. assert!(out.contains("<"), "raw < must become <: {out}"); assert_eq!(out.matches(r"\(").count(), 2, "both openers survive: {out}"); assert_eq!(out.matches(r"\)").count(), 2, "both closers survive: {out}"); } #[test] fn real_tags_still_pass_through() { let out = rich("

    Hello world
    line

    "); assert_eq!(out, "

    Hello world
    line

    "); } #[test] fn script_tags_still_stripped() { let out = rich("

    x

    "); assert!(!out.contains("script"), "script tags must go: {out}"); assert!(out.contains("alert(1)"), "inner text stays: {out}"); } #[test] fn stray_lt_at_end_escaped() { let out = rich("a < b and c <"); assert_eq!(out, "a < b and c <"); } }