diff --git a/.gitignore b/.gitignore index 9fcf1e0..787a214 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ moodle/TOKEN.md TOKEN.md *.token +*.mbz diff --git a/moodle/src/fill_mbz.py b/moodle/src/fill_mbz.py new file mode 100644 index 0000000..4dde0c5 --- /dev/null +++ b/moodle/src/fill_mbz.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Fill an AISE502 Moodle course backup (.mbz, moodle2 format) with the generated section content. + + python3 src/fill_mbz.py inspect + lists Moodle release, course format, every section (number, id, name) and the activities in it + python3 src/fill_mbz.py fill [--summary] + week sections 1..14: section name -> "Week N: ...", the three template text areas (Lernziele / + Theorie / Selbststudium) -> blocks 1..3 of week_NN.html; two sections appended for the lecture + script and the project (cloned from the last week section, one text area each). + --summary: write each week's three blocks into the section description instead of text areas. + +Only text fields are changed; ids of new sections/labels are fresh unique numbers; everything else in +the archive is copied untouched. The result is validated structurally (all referenced directories exist, +XML parses) but a restore in Moodle is the real test. +""" +import io, pathlib, re, shutil, sys, tarfile, tempfile, zipfile, html +import xml.etree.ElementTree as ET + +HERE = pathlib.Path(__file__).resolve().parent +OUT = HERE.parent + +# ----------------------------------------------------------------------------- archive helpers +def unpack(mbz, dest): + data = open(mbz, "rb").read(4) + if data[:2] == b"PK": + with zipfile.ZipFile(mbz) as z: z.extractall(dest); return "zip" + with tarfile.open(mbz, "r:gz") as t: t.extractall(dest); return "tgz" + +def pack(src, out, kind): + src = pathlib.Path(src) + if kind == "zip": + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for p in sorted(src.rglob("*")): + if p.is_file(): z.write(p, p.relative_to(src).as_posix()) + else: + with tarfile.open(out, "w:gz") as t: + for p in sorted(src.rglob("*")): + if p.is_file(): t.add(p, p.relative_to(src).as_posix()) + +def read(p): return pathlib.Path(p).read_text(encoding="utf-8") +def write(p, s): pathlib.Path(p).write_text(s, encoding="utf-8") + +def tag(xml, name): + m = re.search(rf"<{name}>(.*?)", xml, re.S) + return html.unescape(m.group(1)) if m else None + +def set_tag(xml, name, value, count=1): + esc = html.escape(value, quote=False) + new, n = re.subn(rf"<{name}>.*?", lambda m: f"<{name}>{esc}", xml, count=count, flags=re.S) + if n == 0: # empty tag form or + new, n = re.subn(rf"<{name}\s*/>", f"<{name}>{esc}", xml, count=count) + assert n, f"tag {name} not found" + return new + +# ----------------------------------------------------------------------------- model +class Backup: + def __init__(self, root): + self.root = pathlib.Path(root) + self.mb = read(self.root / "moodle_backup.xml") + self.release = tag(self.mb, "moodle_release") + self.format = tag(self.mb, "format") # course format e.g. topics + # sections: from moodle_backup.xml contents + self.sections = [] + for m in re.finditer(r"
\s*(\d+)\s*(.*?)\s*(.*?)\s*
", self.mb, re.S): + sid, title, d = int(m.group(1)), m.group(2), m.group(3) + sx = read(self.root / d / "section.xml") + self.sections.append(dict(id=sid, dir=d, number=int(tag(sx, "number")), name=tag(sx, "name") or "", + sequence=[int(x) for x in (tag(sx, "sequence") or "").split(",") if x.strip()], + visible=tag(sx, "visible"))) + self.sections.sort(key=lambda s: s["number"]) + self.activities = {} + for m in re.finditer(r"\s*(\d+)\s*(\d+)\s*(.*?)\s*(.*?)\s*(.*?)\s*", self.mb, re.S): + mid, sid, mod, title, d = int(m.group(1)), int(m.group(2)), m.group(3), html.unescape(m.group(4)), m.group(5) + a = dict(moduleid=mid, sectionid=sid, modname=mod, title=title, dir=d) + mx = read(self.root / d / "module.xml") + a["visible"] = tag(mx, "visible") + if mod == "label": + lx = read(self.root / d / "label.xml") + a["intro"] = tag(lx, "intro") or "" + self.activities[mid] = a + + def label_kind(self, a): + t = re.sub(r"<[^>]+>", " ", a.get("intro", "")) + t = html.unescape(t) + if a["visible"] == "0" or "Auftrag Dozierende" in t: return "hidden" + if "Lernziele" in t or "Learning objectives" in t: return "objectives" + if "Theorie" in t or "Theory" in t: return "theory" + if "Selbststudium" in t or "Self-study" in t: return "selfstudy" + return "other" + +def inspect(mbz): + with tempfile.TemporaryDirectory() as tmp: + kind = unpack(mbz, tmp) + b = Backup(tmp) + print(f"archive: {kind} · Moodle {b.release} · course format: {b.format} · {len(b.sections)} sections · {len(b.activities)} activities") + for s in b.sections: + print(f"\n[{s['number']}] id={s['id']} visible={s['visible']} name={s['name']!r}") + for mid in s["sequence"]: + a = b.activities.get(mid) + if not a: print(f" ? module {mid} not in backup contents"); continue + kind_ = b.label_kind(a) if a["modname"] == "label" else "-" + print(f" {a['modname']:<10} id={mid:<6} visible={a['visible']} kind={kind_:<10} {a['title'][:70]!r}") + +# ----------------------------------------------------------------------------- filling +def blocks_of(week): + t = read(OUT / f"week_{week:02d}.html") + parts = re.split(r"\n", t) + assert len(parts) == 4, f"week {week}: expected 3 blocks, got {len(parts)-1}" + title = re.search(r"Section name\): (.*)", t).group(1).strip() + return title, [p.strip() + "\n" for p in parts[1:]] + +def label_name(intro): + t = html.unescape(re.sub(r"<[^>]+>", " ", intro)); t = re.sub(r"\s+", " ", t).strip() + return t[:60] + +def set_label(b, a, intro_html): + p = b.root / a["dir"] / "label.xml" + lx = read(p) + lx = set_tag(lx, "intro", intro_html) + lx = set_tag(lx, "name", label_name(intro_html)) + lx = set_tag(lx, "introformat", "1") + write(p, lx) + # title in moodle_backup.xml + b.mb = re.sub(rf"({a['moduleid']}\s*\d+\s*label\s*).*?()", + lambda m: m.group(1) + html.escape(label_name(intro_html), quote=False) + m.group(2), b.mb, count=1, flags=re.S) + +def set_section_name(b, s, name): + p = b.root / s["dir"] / "section.xml" + write(p, set_tag(read(p), "name", name)) + s["name"] = name + +def set_section_summary(b, s, summary_html): + p = b.root / s["dir"] / "section.xml" + sx = set_tag(read(p), "summary", summary_html) + sx = set_tag(sx, "summaryformat", "1") + write(p, sx) + +def next_ids(b): + """fresh ids above everything used in the archive""" + nums = [int(x) for x in re.findall(r'id="(\d+)"|(\d+)|_(\d+)/|(\d+)|(\d+)|contextid="(\d+)"', b.mb) for x in x if x] if False else [] + allx = "\n".join(read(p) for p in b.root.rglob("*.xml")) + nums = [int(n) for n in re.findall(r"\b(\d{1,9})\b", allx)] + base = (max(nums) // 1000 + 1) * 1000 + return base + +def clone_section(b, template, number, name, label_intros, base): + """clone a week section directory as section `number` with len(label_intros) label activities (cloned from the template's first label)""" + tsec = template + sid = base; base += 1 + sdir = f"sections/section_{sid}" + shutil.copytree(b.root / tsec["dir"], b.root / sdir) + sx = read(b.root / sdir / "section.xml") + sx = re.sub(r'
" in mx else mx + write(b.root / adir / "module.xml", mx) + lx = read(b.root / adir / "label.xml") + lx = re.sub(r'\n\n') + # register in moodle_backup.xml: contents/activities + settings + entry = (f" \n {mid}\n {sid}\n" + f" label\n {html.escape(label_name(intro), quote=False)}\n" + f" {adir}\n \n") + b.mb = b.mb.replace("", entry + " ", 1) + for sname, val in ((f"label_{mid}_included", "1"), (f"label_{mid}_userinfo", "0")): + b.mb = b.mb.replace("", f" \n activity\n label_{mid}\n {sname}\n {val}\n \n ", 1) + mids.append(mid) + sx = set_tag(sx, "sequence", ",".join(str(m) for m in mids)) + write(b.root / sdir / "section.xml", sx) + write(b.root / sdir / "inforef.xml", '\n\n') + entry = (f"
\n {sid}\n {number}\n {sdir}\n
\n") + b.mb = b.mb.replace("", entry + " ", 1) + for sname, val in ((f"section_{sid}_included", "1"), (f"section_{sid}_userinfo", "0")): + b.mb = b.mb.replace("", f" \n section\n
section_{sid}
\n {sname}\n {val}\n
\n ", 1) + return base + +def fill(mbz, out, use_summary=False): + tmp = tempfile.mkdtemp() + kind = unpack(mbz, tmp) + b = Backup(tmp) + weeks = {s["number"]: s for s in b.sections if 1 <= s["number"] <= 14} + assert len(weeks) == 14, f"expected sections 1..14, found {sorted(weeks)}" + report = [] + for n in range(1, 15): + s = weeks[n]; title, blocks = blocks_of(n) + set_section_name(b, s, title) + if use_summary: + set_section_summary(b, s, "\n".join(blocks)); report.append(f"week {n}: name + summary"); continue + kinds = {} + for mid in s["sequence"]: + a = b.activities[mid] + if a["modname"] == "label": + k = b.label_kind(a) + if k in ("objectives", "theory", "selfstudy") and k not in kinds: kinds[k] = a + missing = [k for k in ("objectives", "theory", "selfstudy") if k not in kinds] + assert not missing, f"week {n}: no template text area for {missing}; use --summary or adjust label_kind()" + for k, blk in zip(("objectives", "theory", "selfstudy"), blocks): + set_label(b, kinds[k], blk) + report.append(f"week {n}: name + 3 text areas") + # two extra sections + base = next_ids(b) + last = max(b.sections, key=lambda s: s["number"]) + nxt = last["number"] + 1 + script_html = read(OUT / "lecture_script.html"); project_html = read(OUT / "project_exercise.html") + def body(t): return re.sub(r"\s*", "", t, count=1, flags=re.S).strip() + "\n" + base = clone_section(b, weeks[14], nxt, "📘 Lecture script", [body(script_html)], base) + base = clone_section(b, weeks[14], nxt + 1, "🛠️ Project exercise", [body(project_html)], base) + report.append(f"sections {nxt} (script) and {nxt+1} (project) appended") + write(pathlib.Path(tmp) / "moodle_backup.xml", b.mb) + # structural validation + b2 = Backup(tmp) + for s in b2.sections: + for mid in s["sequence"]: + assert mid in b2.activities, f"section {s['number']} references unknown module {mid}" + for p in pathlib.Path(tmp).rglob("*.xml"): + ET.parse(p) + pack(tmp, out, kind) + shutil.rmtree(tmp) + print("\n".join(report)); print(f"wrote {out} ({kind}); Moodle release in backup: {b2.release}; sections now: {[s['number'] for s in b2.sections]}") + +if __name__ == "__main__": + if len(sys.argv) >= 3 and sys.argv[1] == "inspect": inspect(sys.argv[2]) + elif len(sys.argv) >= 4 and sys.argv[1] == "fill": fill(sys.argv[2], sys.argv[3], "--summary" in sys.argv) + else: print(__doc__)