mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-05-08 18:01:20 -05:00
- Fix section ID matching issue (handle '#' prefix properly) - Add consistent '[Quiz Filter]' branding to match cross-references - Use same clean output pattern as cross-reference filter - Now shows proper output when quizzes are detected: 📝 [Quiz Filter] 🚀 Quiz Injection Filter 📝 [Quiz Filter] 🔍 Document has quizzes - processing... ✅ [Quiz Filter] 📊 SUMMARY: X quiz sections injected - Complete silence when no quizzes found - Professional, consistent build experience
457 lines
14 KiB
Lua
457 lines
14 KiB
Lua
-- inject_quizzes.lua
|
|
---@diagnostic disable-next-line: undefined-global
|
|
PANDOC_DOCUMENT = PANDOC_DOCUMENT
|
|
|
|
|
|
local json = require("pandoc.json")
|
|
local utils = pandoc.utils
|
|
|
|
-- global state: all quiz sections are collected here, organized by file source
|
|
local quiz_sections = {}
|
|
local quiz_sections_by_file = {} -- track which sections came from which file
|
|
local current_document_file = "unknown" -- track which document is being processed
|
|
local has_quizzes = false -- track if current document has any quiz sections
|
|
|
|
-- Helper function to check if document has any sections with quizzes
|
|
local function document_has_quizzes(doc, quiz_lookup)
|
|
for _, block in ipairs(doc.blocks) do
|
|
if block.t == "Header" and block.identifier and block.identifier ~= "" then
|
|
-- Check both with and without "#" prefix since quiz system uses "#" prefix
|
|
local section_id = block.identifier
|
|
local section_id_with_hash = "#" .. section_id
|
|
if quiz_lookup[section_id] or quiz_lookup[section_id_with_hash] then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- 1) Load a single JSON file
|
|
local function load_quiz_data(path)
|
|
local f, err = io.open(path, "r")
|
|
if not f then
|
|
io.stderr:write("❌ [QUIZ] Failed to open file: " .. path .. " - " .. (err or "unknown error") .. "\n")
|
|
return nil
|
|
end
|
|
local content = f:read("*all")
|
|
f:close()
|
|
|
|
local ok, data = pcall(json.decode, content)
|
|
if not ok or type(data) ~= "table" then
|
|
io.stderr:write("❌ [QUIZ] Failed to parse JSON from file: " .. path .. "\n")
|
|
return nil
|
|
end
|
|
return data
|
|
end
|
|
|
|
-- 2) Extract sections from JSON and map them to section_id
|
|
local function register_sections(data, file_path)
|
|
local secs = {}
|
|
local sections_found = 0
|
|
|
|
if data.sections then
|
|
for _, s in ipairs(data.sections) do
|
|
if s.quiz_data
|
|
and s.quiz_data.quiz_needed
|
|
and s.quiz_data.questions then
|
|
secs[s.section_id] = s.quiz_data.questions
|
|
sections_found = sections_found + 1
|
|
end
|
|
end
|
|
else
|
|
for sid, qs in pairs(data) do
|
|
if sid ~= "metadata" then
|
|
secs[sid] = qs
|
|
sections_found = sections_found + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
return secs, sections_found
|
|
end
|
|
|
|
-- 3) Render content_md into a callout Div for all formats
|
|
local function create_quiz_div(div_id, div_class, content_md)
|
|
-- parse the Markdown content
|
|
local doc = pandoc.read(content_md, "markdown")
|
|
|
|
-- return a callout Div with proper attributes
|
|
return pandoc.Div(
|
|
doc.blocks,
|
|
pandoc.Attr(div_id, {"callout", div_class}, {})
|
|
)
|
|
end
|
|
|
|
-- 4) From a given set of questions, create question + answer divs
|
|
local function process_quiz_questions(questions, section_id)
|
|
local ql, al = {}, {}
|
|
local clean = section_id:gsub("^#", "")
|
|
local qid = "quiz-question-" .. clean
|
|
local aid = "quiz-answer-" .. clean
|
|
|
|
for i, q in ipairs(questions) do
|
|
table.insert(ql, i..". "..q.question)
|
|
if q.question_type == "MCQ" and q.choices then
|
|
local choices_line = {}
|
|
for j, c in ipairs(q.choices) do
|
|
table.insert(choices_line, " "..string.char(96+j)..") "..c)
|
|
end
|
|
table.insert(ql, table.concat(choices_line, "\n"))
|
|
end
|
|
-- build answers
|
|
table.insert(al, i..". **"..q.question.."**")
|
|
if q.question_type == "MCQ" and q.choices then
|
|
local choices_line = {}
|
|
for j, c in ipairs(q.choices) do
|
|
table.insert(choices_line, " "..string.char(96+j)..") "..c)
|
|
end
|
|
table.insert(al, table.concat(choices_line, "\n"))
|
|
table.insert(al, "")
|
|
end
|
|
table.insert(al, " *Answer*: "..q.answer)
|
|
table.insert(al, "")
|
|
table.insert(al, " *Learning Objective*: "..q.learning_objective)
|
|
table.insert(al, "")
|
|
end
|
|
|
|
if FORMAT == "latex" then
|
|
table.insert(ql, string.format("\\noindent\\hspace*{1.25em}\\hyperref[%s]{\\textbf{See Answer~$\\rightarrow$}}", aid))
|
|
table.insert(al, string.format("\\noindent\\hspace*{1.25em}\\hyperref[%s]{\\textbf{$\\leftarrow$~Back to Question}}", qid))
|
|
else
|
|
table.insert(ql, '<a href="#' .. aid .. '" class="question-label">See Answers →</a>')
|
|
table.insert(al, '<a href="#' .. qid .. '" class="answer-label">← Back to Questions</a>')
|
|
end
|
|
|
|
return create_quiz_div(qid, "callout-quiz-question", table.concat(ql, "\n\n")),
|
|
create_quiz_div(aid, "callout-quiz-answer", table.concat(al, "\n\n"))
|
|
end
|
|
|
|
-- 5) Meta phase: read one or more paths from meta.quiz
|
|
local function handle_meta(meta)
|
|
local raw = meta.quiz
|
|
|
|
-- Try to get the current document filename
|
|
if PANDOC_DOCUMENT and PANDOC_DOCUMENT.meta and PANDOC_DOCUMENT.meta.filename then
|
|
current_document_file = utils.stringify(PANDOC_DOCUMENT.meta.filename)
|
|
elseif PANDOC_STATE and PANDOC_STATE.input_files and PANDOC_STATE.input_files[1] then
|
|
current_document_file = PANDOC_STATE.input_files[1]
|
|
end
|
|
|
|
-- Check if this is a PDF build (combined document) or HTML build (individual files)
|
|
local is_pdf_build = false
|
|
if PANDOC_STATE and PANDOC_STATE.input_files and #PANDOC_STATE.input_files > 1 then
|
|
is_pdf_build = true
|
|
end
|
|
|
|
-- Alternative PDF detection: check if we're in a PDF format
|
|
if FORMAT and (FORMAT:lower():match("pdf") or FORMAT:lower():match("latex")) then
|
|
is_pdf_build = true
|
|
end
|
|
|
|
-- Get quiz configuration from global metadata
|
|
local quiz_config = meta["quiz-config"] or {}
|
|
local file_pattern = quiz_config["file-pattern"] or "*_quizzes.json"
|
|
local scan_directory = quiz_config["scan-directory"] or "contents/core"
|
|
local auto_discover_pdf = quiz_config["auto-discover-pdf"] ~= false -- default to true
|
|
|
|
-- Convert Meta objects to strings if needed
|
|
if type(file_pattern) == "table" and file_pattern.t == "MetaString" then
|
|
file_pattern = file_pattern.text
|
|
elseif type(file_pattern) == "table" then
|
|
file_pattern = utils.stringify(file_pattern)
|
|
end
|
|
|
|
if type(scan_directory) == "table" and scan_directory.t == "MetaString" then
|
|
scan_directory = scan_directory.text
|
|
elseif type(scan_directory) == "table" then
|
|
scan_directory = utils.stringify(scan_directory)
|
|
end
|
|
|
|
if type(auto_discover_pdf) == "table" and auto_discover_pdf.t == "MetaBool" then
|
|
auto_discover_pdf = auto_discover_pdf.bool
|
|
elseif type(auto_discover_pdf) == "table" then
|
|
auto_discover_pdf = utils.stringify(auto_discover_pdf) ~= "false"
|
|
end
|
|
|
|
-- Configuration loaded silently
|
|
|
|
if not raw then
|
|
if is_pdf_build and auto_discover_pdf then
|
|
-- For PDF builds, auto-discover quiz files from the input files (silently)
|
|
local files = {}
|
|
local total_sections_loaded = 0
|
|
|
|
-- Since Quarto combines files into a temporary document, we need to scan the directory directly
|
|
|
|
local function scan_for_quiz_files()
|
|
local quiz_files = {}
|
|
|
|
local command
|
|
if package.config:sub(1,1) == "\\" then
|
|
-- Windows
|
|
command = string.format('dir "%s\\%s" /b /s 2>nul', scan_directory, "*_quizzes.json")
|
|
else
|
|
-- Unix
|
|
command = string.format("find %s -name '%s' 2>/dev/null", scan_directory, "*_quizzes.json")
|
|
end
|
|
|
|
local pipe = io.popen(command)
|
|
if pipe then
|
|
for file in pipe:lines() do
|
|
table.insert(quiz_files, file)
|
|
end
|
|
pipe:close()
|
|
end
|
|
|
|
return quiz_files
|
|
end
|
|
|
|
files = scan_for_quiz_files()
|
|
|
|
if #files > 0 then
|
|
for i, path in ipairs(files) do
|
|
local data = load_quiz_data(path)
|
|
if data then
|
|
local secs, sections_found = register_sections(data, path)
|
|
if sections_found > 0 then
|
|
-- Track which sections came from this file
|
|
quiz_sections_by_file[path] = {}
|
|
for k, v in pairs(secs) do
|
|
quiz_sections[k] = v
|
|
quiz_sections_by_file[path][k] = v
|
|
end
|
|
total_sections_loaded = total_sections_loaded + sections_found
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return meta
|
|
end
|
|
|
|
-- collect all files into this list
|
|
local files = {}
|
|
|
|
-- if raw is a table with elements, treat it as a list
|
|
if type(raw) == "table" and raw[1] ~= nil then
|
|
for _, item in ipairs(raw) do
|
|
local p = utils.stringify(item)
|
|
table.insert(files, p)
|
|
end
|
|
else
|
|
-- not a list, so it's a single file
|
|
local p = utils.stringify(raw)
|
|
table.insert(files, p)
|
|
end
|
|
|
|
-- load each file silently
|
|
local total_sections_loaded = 0
|
|
|
|
for i, path in ipairs(files) do
|
|
local data = load_quiz_data(path)
|
|
if data then
|
|
local secs, sections_found = register_sections(data, path)
|
|
if sections_found > 0 then
|
|
-- Track which sections came from this file
|
|
quiz_sections_by_file[path] = {}
|
|
for k, v in pairs(secs) do
|
|
quiz_sections[k] = v
|
|
quiz_sections_by_file[path][k] = v
|
|
end
|
|
total_sections_loaded = total_sections_loaded + sections_found
|
|
end
|
|
end
|
|
end
|
|
|
|
return meta
|
|
end
|
|
|
|
local function is_part_header(block)
|
|
if block.t == "Header" and block.level == 1 then
|
|
-- Check for class 'part'
|
|
if block.attr and block.attr.classes then
|
|
for _, cls in ipairs(block.attr.classes) do
|
|
if cls == "part" then return true end
|
|
end
|
|
end
|
|
-- Check for id starting with 'part-'
|
|
if block.identifier and block.identifier:match("^part%-.+") then
|
|
return true
|
|
end
|
|
-- Check for content like '(PART)' (common in Quarto)
|
|
if block.content and #block.content > 0 then
|
|
local txt = pandoc.utils.stringify(block.content)
|
|
if txt:match("%(%s*PART%s*%)") then return true end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function insert_quizzes(doc)
|
|
if not next(quiz_sections) then return doc end
|
|
|
|
-- Check if this document has any quiz sections
|
|
has_quizzes = document_has_quizzes(doc, quiz_sections)
|
|
|
|
if not has_quizzes then
|
|
-- No quizzes for this document, process silently
|
|
return doc
|
|
end
|
|
|
|
-- Document has quizzes, show clean processing info
|
|
io.stderr:write("📝 [Quiz Filter] 🚀 Quiz Injection Filter\n")
|
|
io.stderr:write("📝 [Quiz Filter] 🔍 Document has quizzes - processing...\n")
|
|
|
|
local quizzes_injected = 0
|
|
|
|
local function has_class(classes, cls)
|
|
for _, c in ipairs(classes) do
|
|
if c == cls then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- priznajemo dva boundary-a: marker ili H1
|
|
local function is_marker(b)
|
|
return b.t == "Div"
|
|
and b.attr
|
|
and has_class(b.attr.classes, "quiz-end")
|
|
end
|
|
|
|
local function is_h1(b)
|
|
return b.t == "Header" and b.level == 1
|
|
end
|
|
|
|
-- akumulators
|
|
local new_blocks = {}
|
|
local chapter_blocks = {}
|
|
local chapter_answers = {}
|
|
local chapter_title = nil
|
|
|
|
local section_blocks = {}
|
|
local current_section_id = nil
|
|
local current_quiz_block = nil
|
|
local current_answer_block = nil
|
|
|
|
local function flush_section()
|
|
if current_quiz_block and current_answer_block then
|
|
table.insert(section_blocks, current_quiz_block)
|
|
table.insert(chapter_answers, current_answer_block)
|
|
end
|
|
for _, blk in ipairs(section_blocks) do
|
|
table.insert(chapter_blocks, blk)
|
|
end
|
|
section_blocks = {}
|
|
current_section_id = nil
|
|
current_quiz_block = nil
|
|
current_answer_block = nil
|
|
end
|
|
|
|
local function flush_chapter()
|
|
local out = {}
|
|
for _, blk in ipairs(chapter_blocks) do
|
|
table.insert(out, blk)
|
|
end
|
|
if #chapter_answers > 0 then
|
|
table.insert(out,
|
|
pandoc.Header(2,
|
|
{ pandoc.Str("Self-Check Answers") },
|
|
pandoc.Attr("self-check-answers")
|
|
)
|
|
)
|
|
for _, ans in ipairs(chapter_answers) do
|
|
table.insert(out, ans)
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local i = 1
|
|
while i <= #doc.blocks do
|
|
local b = doc.blocks[i]
|
|
|
|
-- 1) MARKER flush
|
|
if is_marker(b) then
|
|
flush_section()
|
|
if #chapter_blocks>0 or #chapter_answers>0 then
|
|
for _, x in ipairs(flush_chapter()) do
|
|
table.insert(new_blocks, x)
|
|
end
|
|
chapter_blocks, chapter_answers = {}, {}
|
|
end
|
|
-- ne ubacuj marker u novi chapter (ili ga po želji prebaciš)
|
|
-- table.insert(new_blocks, b)
|
|
i = i + 1
|
|
goto continue
|
|
end
|
|
|
|
-- 2) H1 boundary flush
|
|
if is_h1(b) then
|
|
flush_section()
|
|
if #chapter_blocks>0 or #chapter_answers>0 then
|
|
for _, x in ipairs(flush_chapter()) do
|
|
table.insert(new_blocks, x)
|
|
end
|
|
chapter_blocks, chapter_answers = {}, {}
|
|
end
|
|
-- sada umetni novi H1
|
|
table.insert(new_blocks, b)
|
|
chapter_title = pandoc.utils.stringify(b.content or {})
|
|
section_blocks = {}
|
|
i = i + 1
|
|
goto continue
|
|
end
|
|
|
|
-- 3) H2 → sekcija sa kvizom
|
|
if b.t=="Header" and b.level==2 and b.identifier then
|
|
if current_section_id then
|
|
flush_section()
|
|
else
|
|
for _, x in ipairs(section_blocks) do
|
|
table.insert(chapter_blocks, x)
|
|
end
|
|
section_blocks = {}
|
|
end
|
|
section_blocks = { b }
|
|
current_section_id = "#" .. b.identifier
|
|
local quiz = quiz_sections[current_section_id]
|
|
if quiz then
|
|
local q, a = process_quiz_questions(quiz, current_section_id)
|
|
current_quiz_block, current_answer_block = q, a
|
|
quizzes_injected = quizzes_injected + 1
|
|
end
|
|
i = i + 1
|
|
goto continue
|
|
end
|
|
|
|
-- 4) ostali blokovi → u sekciju
|
|
table.insert(section_blocks, b)
|
|
i = i + 1
|
|
|
|
::continue::
|
|
end
|
|
|
|
-- završni flush na kraju dokumenta
|
|
flush_section()
|
|
if #chapter_blocks>0 or #chapter_answers>0 then
|
|
for _, x in ipairs(flush_chapter()) do
|
|
table.insert(new_blocks, x)
|
|
end
|
|
end
|
|
|
|
-- Show summary if quizzes were injected
|
|
if quizzes_injected > 0 then
|
|
io.stderr:write("✅ [Quiz Filter] 📊 SUMMARY: " .. quizzes_injected .. " quiz sections injected\n")
|
|
end
|
|
|
|
return pandoc.Pandoc(new_blocks, doc.meta)
|
|
end
|
|
|
|
|
|
|
|
-- 7) Register the filter
|
|
return {
|
|
{ Meta = handle_meta },
|
|
{ Pandoc = insert_quizzes }
|
|
} |