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. `
`, `
`, `
"#,
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}
"#,
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#""#,
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##"
share this paper
Send the question paper to a friend. Your answers and this score stay private.
{b}/s/{tok}
"##,
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
{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##"
"#,
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.
");
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
mistake type
count
{rows}
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.
"#,
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!("
"#,
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 <");
}
}