From 0ab311f66d6f3cb0b4d87c47e082d53fd67bb502 Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Sat, 17 Oct 2026 20:26:49 +0200 Subject: support writing moodle tests in markdown --- .gitignore | 2 + common.mk | 4 ++ pandoc/filters/moodle-quiz.lua | 140 +++++++++++++++++++++++++++++++++++++++ pandoc/templates/moodle-quiz.xml | 15 +++++ scripts/lesson-make | 4 +- 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 pandoc/filters/moodle-quiz.lua create mode 100644 pandoc/templates/moodle-quiz.xml diff --git a/.gitignore b/.gitignore index 939f3c74..8722f7a3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ test_loesung.pdf sequence.pdf *print.pdf *_printout.pdf +moodle-quiz.xml +!pandoc/templates/moodle-quiz.xml .build/ .owncloudsync.log* .sync_journal.db* diff --git a/common.mk b/common.mk index f4ad4ebe..01f08546 100644 --- a/common.mk +++ b/common.mk @@ -70,6 +70,10 @@ schedule.pdf: schedule.md $(MAKEFILE_LIST) @echo "building $@" $(VERBOSE)$(PANDOC) --pdf-engine-opt=--fmt=$(LATEX_FMT_BASE)/schedule.fmt -f markdown $< -o $@ +moodle-quiz.xml: moodle-quiz.md $(wildcard img/*) $(MAKEFILE_LIST) + @echo "building $@" + $(VERBOSE)$(PANDOC) --template=$(PANDOC_TEMPLATE_DIR)/moodle-quiz.xml -t $(PANDOC_FILTER_DIR)/moodle-quiz.lua --embed-resources $< -o $@ + %.pdf: %.md $(MAKEFILE_LIST) @echo "building $@" $(VERBOSE)$(PANDOC) -f markdown $< -o $@ diff --git a/pandoc/filters/moodle-quiz.lua b/pandoc/filters/moodle-quiz.lua new file mode 100644 index 00000000..ab26f93c --- /dev/null +++ b/pandoc/filters/moodle-quiz.lua @@ -0,0 +1,140 @@ +--- moodle-test.lua – filter to convert markdown questions into Moodle XML +--- +--- Copyright: © 2026 Florian Fischer +--- License: GPL-3.0-or-later + +-- Collected questions +local questions = {} +local cur_question = nil + +local concat = function(inlines) + local s = pandoc.write(pandoc.Pandoc(pandoc.Plain(inlines)), 'plain', {wrap_text='wrap-none'}) + if s:sub(-1) == '\n' then + s = s:sub(1, -2) + end + return s +end + +local function detect_question_type(question) + local ret = "" + if question.header.classes:includes("shortanswer") then + ret = "shortanswer" + elseif question.header.classes:includes("multichoice") then + ret = "multichoice" + else + pandoc.warn("No question type found") + end + return ret +end + +local function write_shortanwser_answers(question) + local ret = "" + local a = function(s) ret = ret .. s end + + for _, answer in ipairs(question.answers) do + a(' \n') + a(' \n') + + if answer.feedback then + a(' \n') + end + a(' \n') + end + return ret +end + +local write_answers_for = { + shortanswer = write_shortanwser_answers, +} + +local function write_questions() + local res = "" + local a = function(s) res = res .. s end + + for _, question in ipairs(questions) do + if not question then + pandoc.log.warn("nil question") + end + local question_type = detect_question_type(question) + -- Question type + a('\n') + + -- Question name + a(' '); + a(concat(question.name)) + a('\n') + + -- Question text + a(' \n \n \n') + a(' \n') + a(' 1.0000000\n') + a(' 0.3333333\n') + a(' 0\n') + a(' \n') + a(' 0\n') + + local answer_fn = write_answers_for[question_type] + a(answer_fn(question)) + a('\n') + end + + return res +end + +local function Header(h) + cur_question = { header=h, text={}} + table.insert(questions, cur_question) + + cur_question.name = h.content +end + +local function Image(i) + local datauri = pandoc.mediabag.make_data_uri(pandoc.mediabag.fetch(i.src)) + return pandoc.Image(i.caption, datauri) +end + +local function Div(d) + -- append question content + table.insert(cur_question.text, pandoc.walk_block(d, {Image=Image})) -- embed all images +end + +local function BulletList(l) + cur_question.answers = l.content +end + +local filter = { + -- Traverse the AST depth-first to reach the content of a question after + -- reaching its header + traverse = 'topdown', + Header = Header, -- Detect questions + Div = Div, -- Collect question content + BulletList = BulletList, -- Collect question answers +} + +Writer = function(doc, opts) + local ctx = pandoc.template.meta_to_context(doc.meta, concat, concat) + + -- collect questions + doc:walk(filter) + local body = write_questions() + + ctx['body'] = body + for k, v in pairs(opts.variables) do + ctx[k] = v + end + + if not opts.template then + pandoc.log.warn("No quiz template specified") + os.exit(1) + end + local complete = pandoc.template.apply(opts.template, ctx) + return complete:render() +end diff --git a/pandoc/templates/moodle-quiz.xml b/pandoc/templates/moodle-quiz.xml new file mode 100644 index 00000000..761c8669 --- /dev/null +++ b/pandoc/templates/moodle-quiz.xml @@ -0,0 +1,15 @@ + + + + + + $$module$$/top/Standard für ${title} + + + Standardkategorie für Fragen, die im Kontext '${title}' freigegeben sind. + + + + +${body} + diff --git a/scripts/lesson-make b/scripts/lesson-make index d5639b7a..3c6a4890 100755 --- a/scripts/lesson-make +++ b/scripts/lesson-make @@ -22,6 +22,9 @@ if [[ -z "${targets[*]}" ]]; then slides*.md) new_targets+=("${source_name/.md/.pdf}" "${source_name/.md/.pptx}") ;; + moodle-quiz.md) + new_targets+=("${source_name/.md/.xml}") + ;; *) mapfile -t new_targets < <(echo "${source}" | sed -e 's/\.tex$/.pdf/g' -e 's/\.md$/.pdf/g') ;; @@ -43,4 +46,3 @@ if [[ -z "${targets[*]}" ]]; then fi exec make -f "${MAKEFILE}" "${targets[@]}" - -- cgit v1.2.3