aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2026-10-17 20:26:49 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2026-10-17 22:23:21 +0200
commit0ab311f66d6f3cb0b4d87c47e082d53fd67bb502 (patch)
tree092f3bb6726a234aa49b898bdf677f630f6dd81a
parent5a865a67d70efd3d23448a7ac885305b0f58ff5d (diff)
downloadunterricht-0ab311f66d6f3cb0b4d87c47e082d53fd67bb502.tar.gz
unterricht-0ab311f66d6f3cb0b4d87c47e082d53fd67bb502.zip
support writing moodle tests in markdown
-rw-r--r--.gitignore2
-rw-r--r--common.mk4
-rw-r--r--pandoc/filters/moodle-quiz.lua140
-rw-r--r--pandoc/templates/moodle-quiz.xml15
-rwxr-xr-xscripts/lesson-make4
5 files changed, 164 insertions, 1 deletions
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(' <answer fraction="100" format="html">\n')
+ a(' <text><![CDATA[');
+ a(pandoc.write(pandoc.Pandoc(answer), "html"))
+ a(']]></text>\n')
+
+ if answer.feedback then
+ a(' <feedback format="html"><text><![CDATA[')
+ a(pandoc.write(pandoc.Pandoc(answer.feedback), "html"))
+ a(']]></text></feedback>\n')
+ end
+ a(' </answer>\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('<question type="' .. question_type .. '">\n')
+
+ -- Question name
+ a(' <name><text>');
+ a(concat(question.name))
+ a('</text></name>\n')
+
+ -- Question text
+ a(' <questiontext format="html">\n <text><![CDATA[')
+ for _, part in ipairs(question.text) do
+ a(pandoc.write(pandoc.Pandoc(part), "html"))
+ end
+ a(']]></text>\n </questiontext>\n')
+ a(' <generalfeedback format="html"><text></text></generalfeedback>\n')
+ a(' <defaultgrade>1.0000000</defaultgrade>\n')
+ a(' <penalty>0.3333333</penalty>\n')
+ a(' <hidden>0</hidden>\n')
+ a(' <idnumber></idnumber>\n')
+ a(' <usecase>0</usecase>\n')
+
+ local answer_fn = write_answers_for[question_type]
+ a(answer_fn(question))
+ a('</question>\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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<quiz>
+<!-- question: 0 -->
+ <question type="category">
+ <category>
+ <text>$$module$$/top/Standard für ${title}</text>
+ </category>
+ <info format="moodle_auto_format">
+ <text>Standardkategorie für Fragen, die im Kontext '${title}' freigegeben sind.</text>
+ </info>
+ <idnumber></idnumber>
+ </question>
+
+${body}
+</quiz>
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[@]}"
-