feat(import): Packt parser and packt-video job

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:15:09 +01:00
co-authored by Claude Opus 5.5
parent 34f27d05c9
commit 07e2223c8a
8 changed files with 473 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
import { mkdir, readdir, rename, stat } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import type { ServerContext } from "@stc/contracts";
import { readRawManifest, writeManifest, type RawManifest } from "./util/manifest";
import { slugify, uniqueId } from "./util/text";
import { extractFrame, type Tools } from "./media";
export interface ImportEnv { [k: string]: string | undefined }
export interface ImportOptions { env?: ImportEnv }
export interface Deps {
ctx: ServerContext;
env: ImportEnv;
tools: Tools;
python: string;
}
export const coursesDir = (d: Deps) => resolve(d.ctx.config.dataDir, "courses");
export const cacheDir = (d: Deps) => resolve(d.ctx.config.dataDir, "cache");
export const deckCacheDir = (d: Deps, courseId: string, deckId: string) => join(cacheDir(d), "decks", courseId, deckId);
export const coverCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "covers", courseId);
export const transcriptCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "transcripts", courseId);
export const isPlainId = (s: string) => /^[a-z0-9][a-z0-9-]*$/.test(s);
/** A fresh course id from a title, unique among managed folders and every course the server already knows. */
export async function freshCourseId(d: Deps, wanted: string | undefined, title: string): Promise<string> {
if (wanted !== undefined) {
if (!isPlainId(wanted)) throw new Error(`invalid courseId "${wanted}" (use lowercase letters, digits and dashes)`);
if (await exists(join(coursesDir(d), wanted)) || d.ctx.courses.get(wanted)) throw new Error(`a course with id "${wanted}" already exists`);
return wanted;
}
const taken = new Set<string>(d.ctx.courses.list().map((c) => c.id));
for (const e of await readdir(coursesDir(d)).catch(() => [] as string[])) taken.add(e);
return uniqueId(slugify(title), (x) => taken.has(x));
}
export async function exists(p: string): Promise<boolean> {
return stat(p).then(() => true, () => false);
}
/** Scratch folder on the same filesystem as the courses, so finished work is moved in with one rename. */
export async function stageDir(d: Deps, label: string): Promise<string> {
const dir = resolve(d.ctx.config.dataDir, "tmp", `${label}-${Bun.randomUUIDv7()}`);
await mkdir(dir, { recursive: true });
return dir;
}
export async function publishCourse(d: Deps, stage: string, id: string): Promise<string> {
await mkdir(coursesDir(d), { recursive: true });
const dest = join(coursesDir(d), id);
await rename(stage, dest);
return dest;
}
export interface ManagedCourse { id: string; dir: string; manifest: RawManifest }
/** An existing course the importer may add to. Only folders directly under <dataDir>/courses qualify: overlays and authored repos are never edited. */
export async function managedCourse(d: Deps, courseId: string | undefined): Promise<ManagedCourse> {
if (!courseId) throw new Error("options.courseId is required: choose the course to add to");
const dir = join(coursesDir(d), courseId);
const rel = relative(coursesDir(d), dir);
if (!isPlainId(courseId) || rel.startsWith("..") || !(await exists(join(dir, "stcourse.yaml")))) {
throw new Error(`course "${courseId}" is not an imported course under ${coursesDir(d)}; the importer only edits courses it created`);
}
const manifest = await readRawManifest(dir);
if (manifest.id !== courseId) throw new Error(`course folder "${courseId}" has manifest id "${manifest.id}"`);
return { id: courseId, dir, manifest };
}
/** Reloads the repository and turns load problems into warnings. */
export async function finish(d: Deps, id: string, warnings: string[]): Promise<void> {
await d.ctx.courses.reload(id);
const c = d.ctx.courses.get(id);
if (!c) {
warnings.push(`course "${id}" is not visible to the server: add ${coursesDir(d)} to STC_COURSE_DIRS`);
return;
}
for (const diag of c.diagnostics) warnings.push(`${diag.level}: ${diag.message}${diag.path ? ` (${diag.path})` : ""}`);
}
export async function saveManifest(dir: string, manifest: RawManifest): Promise<void> {
await writeManifest(dir, manifest);
}
export function sourceBlock(kind: string, origin: string) {
return { kind, origin, importedAt: new Date().toISOString() };
}
/** Writes up to `count` frame candidates from evenly spaced clips; returns the candidate file names. Failures are skipped. */
export async function frameCandidates(d: Deps, courseId: string, clips: string[], count = 3): Promise<string[]> {
const out: string[] = [];
const n = Math.min(count, clips.length);
for (let i = 0; i < n; i++) {
const name = `frame-${i + 1}.jpg`;
try {
await extractFrame(d.tools, clips[Math.floor((i * clips.length) / n)]!, join(coverCacheDir(d, courseId), name), 0.2);
out.push(name);
} catch { /* a bad clip must not fail the import */ }
}
return out;
}
@@ -0,0 +1,48 @@
import type { ImportResult, ImportSource, JobContext } from "@stc/contracts";
import { copyFile, mkdir, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { coverCacheDir, exists, type Deps } from "../context";
export type Opts = Record<string, unknown>;
export function parsePayload(payload: unknown, kind: ImportSource["kind"]): { path: string; options: Opts } {
const p = payload as Partial<ImportSource> | null;
if (!p || typeof p.path !== "string" || p.path === "") throw new Error("payload.path is required");
if (p.kind !== undefined && p.kind !== kind) throw new Error(`payload.kind "${p.kind}" does not match job kind "${kind}"`);
return { path: p.path, options: (p.options ?? {}) as Opts };
}
export const optStr = (o: Opts, k: string): string | undefined => (typeof o[k] === "string" && (o[k] as string).trim() !== "" ? (o[k] as string).trim() : undefined);
export const optBool = (o: Opts, k: string): boolean => o[k] === true || o[k] === "true";
/** Runs a job body with a scratch folder that is always removed, and a result shape shared by all import jobs. */
export async function withStage<T>(stage: string, fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} finally {
await rm(stage, { recursive: true, force: true });
}
}
/** Copies the first cover candidate into the course (relative path returned) so the library has a cover straight away. */
export async function adoptCover(d: Deps, courseId: string, courseDir: string, candidate: string | undefined): Promise<string | undefined> {
if (!candidate) return undefined;
const src = join(coverCacheDir(d, courseId), candidate);
if (!(await exists(src))) return undefined;
const rel = "media/cover.jpg";
await mkdir(dirname(join(courseDir, rel)), { recursive: true });
await copyFile(src, join(courseDir, rel));
return rel;
}
export async function maybeQueueTranscription(d: Deps, courseId: string, wanted: boolean, warnings: string[]): Promise<void> {
if (!wanted) return;
if (!d.env.STC_TRANSCRIBE_URL) {
warnings.push("transcription requested but STC_TRANSCRIBE_URL is not set; skipped");
return;
}
await d.ctx.jobs.enqueue("import.transcribe", { courseId });
}
export type Reporter = JobContext["reportProgress"];
export type { ImportResult };
@@ -0,0 +1,86 @@
import { mkdir, rename } from "node:fs/promises";
import { basename, dirname, join, posix } from "node:path";
import type { ImportResult, JobContext } from "@stc/contracts";
import { finish, frameCandidates, freshCourseId, publishCourse, saveManifest, sourceBlock, stageDir, type Deps } from "../context";
import { probeDuration } from "../media";
import { parsePacktIndex } from "../packt";
import { openSource } from "../util/source";
import { adoptCover, maybeQueueTranscription, optBool, optStr, parsePayload, withStage } from "./common";
export async function importPacktVideo(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
const { path, options } = parsePayload(payload, "packt-video");
const warnings: string[] = [];
await jc.reportProgress(0.02, "Reading the package");
const src = await openSource(path);
// The player page is the shallowest index.html that lists sections.
const candidates = src.entries.filter((e) => /(^|\/)index\.html$/i.test(e)).sort((a, b) => a.split("/").length - b.split("/").length);
let indexEntry: string | undefined;
let index: ReturnType<typeof parsePacktIndex> | undefined;
for (const c of candidates) {
try { index = parsePacktIndex(await src.readText(c)); indexEntry = c; break; } catch { /* try the next */ }
}
if (!index || indexEntry === undefined) throw new Error("no Packt player index.html (with \"Section N: …\" headings) found in the package");
warnings.push(...index.warnings);
const base = dirname(indexEntry) === "." ? "" : dirname(indexEntry);
const at = (rel: string) => posix.normalize(base ? `${base}/${rel}` : rel);
const title = optStr(options, "title") ?? index.title;
const id = await freshCourseId(d, optStr(options, "courseId"), title);
const stage = await stageDir(d, "packt");
return withStage(stage, async () => {
const known = new Set(src.entries);
const modules: any[] = [];
const used = new Set<string>();
const clipFiles: string[] = [];
const total = index.sections.reduce((n, s) => n + s.clips.length, 0) || 1;
let done = 0;
for (const sec of index.sections) {
const mid = `m${sec.number}`;
const entries: { clip: (typeof sec.clips)[number]; entry: string }[] = [];
for (const clip of sec.clips) {
const entry = at(clip.video);
if (!known.has(entry)) { warnings.push(`video for ${sec.number}.${clip.number} is missing from the package: ${clip.video}`); continue; }
entries.push({ clip, entry });
used.add(entry);
}
await jc.reportProgress(0.05 + 0.65 * (done / total), `Copying section ${sec.number}: ${sec.title}`);
const tmp = join(stage, "x");
await src.extract(entries.map((e) => e.entry), tmp, jc.signal);
const lessons: any[] = [];
for (const { clip, entry } of entries) {
const rel = `media/${basename(entry)}`;
await mkdir(join(stage, "media"), { recursive: true });
await rename(join(tmp, entry), join(stage, rel));
clipFiles.push(join(stage, rel));
const duration = await probeDuration(d.tools, join(stage, rel));
lessons.push({ id: `${mid}-l${clip.number}`, title: clip.title, type: "video", file: rel, ...(duration ? { duration } : {}) });
done++;
}
modules.push({ id: mid, title: sec.title.replace(/^Section\s+\d+\s*[:.-]\s*/i, ""), lessons });
}
const unused = src.entries.filter((e) => /\.(mp4|webm|m4v|mov)$/i.test(e) && !used.has(e));
if (unused.length) warnings.push(`${unused.length} video file(s) in the package are not listed in index.html and were left out`);
await jc.reportProgress(0.75, "Making cover candidates");
const covers = await frameCandidates(d, id, clipFiles);
if (covers.length === 0 && clipFiles.length) warnings.push("could not extract cover frames (is ffmpeg available?)");
const cover = await adoptCover(d, id, stage, covers[0]);
const manifest = {
schema: 1, id, title,
visibility: "restricted",
...(cover ? { cover } : {}),
source: sourceBlock("packt-video", basename(path)),
modules,
};
await saveManifest(stage, manifest);
await jc.reportProgress(0.9, "Publishing the course");
const dir = await publishCourse(d, stage, id);
await finish(d, id, warnings);
await maybeQueueTranscription(d, id, optBool(options, "transcribe"), warnings);
await jc.reportProgress(1, `Imported ${total} clips`);
return { courseId: id, manifestPath: join(dir, "stcourse.yaml"), warnings };
});
}
+74
View File
@@ -0,0 +1,74 @@
import { decodeEntities, stripTags } from "./util/text";
export interface PacktClip { number: number; title: string; video: string; }
export interface PacktSection { number: number; title: string; clips: PacktClip[] }
export interface PacktIndex { title: string; sections: PacktSection[]; warnings: string[] }
/**
* Parses the Packt player's index.html structurally:
* - the section overview: `<h1>Section N: …</h1>` followed by a `<ul>` of clip titles, in order, usually WITHOUT "Video N.M:" prefixes;
* - the per-section chapter blocks (`class="chapter" id="content-N"`) with one `<li>` per clip: `<h1>N.M title</h1>` and `data-video="…"`.
* Chapter blocks are authoritative for the video file; the overview list fills in any title or whole section that is missing.
*/
export function parsePacktIndex(html: string): PacktIndex {
const warnings: string[] = [];
const intro = /<div[^>]*class="intro"[\s\S]*?<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(html);
const titleTag = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
const title = stripTags(intro?.[1] ?? titleTag?.[1] ?? "") || "Imported course";
const chapterStart = html.search(/<div[^>]*class="chapter"/i);
const overview = chapterStart >= 0 ? html.slice(0, chapterStart) : html;
const chaptersHtml = chapterStart >= 0 ? html.slice(chapterStart) : "";
// overview: sections in document order
const secRe = /<h1[^>]*>\s*Section\s+(\d+)\s*[:.-]\s*([\s\S]*?)<\/h1>/gi;
const heads = [...overview.matchAll(secRe)];
if (heads.length === 0) throw new Error("not a Packt video package: no \"Section N: …\" headings found in index.html");
const overviewTitles = new Map<number, string[]>();
const sectionTitles = new Map<number, string>();
heads.forEach((h, i) => {
const n = Number(h[1]);
sectionTitles.set(n, stripTags(h[2]!));
const block = overview.slice(h.index! + h[0].length, heads[i + 1]?.index ?? overview.length);
const ul = /<ul[^>]*>([\s\S]*?)<\/ul>/i.exec(block);
const items = ul ? [...ul[1]!.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)].map((m) => stripTags(m[1]!)).filter(Boolean) : [];
const firstP = /<p[^>]*>\s*Video\s+\d+\.\d+\s*:\s*([\s\S]*?)<\/p>/i.exec(block);
if (items.length === 0 && firstP) items.push(stripTags(firstP[1]!));
overviewTitles.set(n, items);
});
// chapters: clips with their files
const chapterClips = new Map<number, PacktClip[]>();
const chapRe = /<div[^>]*class="chapter"[^>]*id="content-(\d+)"[^>]*>/gi;
const chaps = [...chaptersHtml.matchAll(chapRe)];
chaps.forEach((c, i) => {
const sec = Number(c[1]);
const block = chaptersHtml.slice(c.index! + c[0].length, chaps[i + 1]?.index ?? chaptersHtml.length);
const clipRe = /<h1[^>]*>\s*(?:Video\s+)?(\d+)\.(\d+)\s*[:.]?\s*([\s\S]*?)<\/h1>/gi;
const hs = [...block.matchAll(clipRe)];
const clips: PacktClip[] = [];
hs.forEach((h, j) => {
const part = block.slice(h.index!, hs[j + 1]?.index ?? block.length);
const vid = /data-video="([^"]+)"/i.exec(part)?.[1] ?? /href="([^"]+\.(?:mp4|webm|m4v|mov))"/i.exec(part)?.[1];
const num = Number(h[2]);
if (!vid) { warnings.push(`clip ${sec}.${num} has no video file in index.html`); return; }
clips.push({ number: num, title: stripTags(h[3]!), video: decodeEntities(vid) });
});
chapterClips.set(sec, clips);
});
const sections: PacktSection[] = [];
for (const n of [...sectionTitles.keys()].sort((a, b) => a - b)) {
let clips = chapterClips.get(n);
const titles = overviewTitles.get(n) ?? [];
if (!clips || clips.length === 0) {
warnings.push(`section ${n} has no chapter block; clips were taken from the overview list and mapped to videos/video${n}_M.mp4 in order`);
clips = titles.map((t, i) => ({ number: i + 1, title: t, video: `videos/video${n}_${i + 1}.mp4` }));
} else {
clips = clips.map((c, i) => ({ ...c, title: c.title || titles[i] || `Video ${n}.${c.number}` }));
if (titles.length && titles.length !== clips.length) warnings.push(`section ${n}: overview lists ${titles.length} clips but the chapter has ${clips.length}`);
}
sections.push({ number: n, title: sectionTitles.get(n)!, clips });
}
return { title, sections, warnings };
}
@@ -0,0 +1,55 @@
import { cp, mkdir, readdir, readFile, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { run } from "./exec";
/** A read-only view of an input: a zip file or an already extracted folder. Nothing in here writes to the input. */
export interface Source {
kind: "zip" | "dir";
entries: string[]; // files only, "/"-separated, relative to the source root
readText(entry: string): Promise<string>;
/** Copies the given entries to `dest`, keeping their relative paths. */
extract(entries: string[], dest: string, signal?: AbortSignal): Promise<void>;
}
const globEscape = (s: string) => s.replace(/[\\[\]*?]/g, (c) => `\\${c}`);
async function walk(root: string, rel = "", out: string[] = []): Promise<string[]> {
for (const e of await readdir(join(root, rel), { withFileTypes: true })) {
const r = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) await walk(root, r, out);
else if (e.isFile()) out.push(r);
}
return out;
}
export async function openSource(path: string): Promise<Source> {
const st = await stat(path).catch(() => null);
if (!st) throw new Error(`source not found: ${path}`);
if (st.isDirectory()) {
return {
kind: "dir",
entries: await walk(path),
readText: (e) => readFile(join(path, e), "utf8"),
async extract(entries, dest) {
for (const e of entries) {
await mkdir(dirname(join(dest, e)), { recursive: true });
await cp(join(path, e), join(dest, e));
}
},
};
}
const listing = await run(["unzip", "-Z1", path]);
const entries = listing.split("\n").filter((l) => l && !l.endsWith("/"));
return {
kind: "zip",
entries,
readText: (e) => run(["unzip", "-p", path, globEscape(e)]),
async extract(list, dest, signal) {
await mkdir(dest, { recursive: true });
// unzip treats arguments as patterns, so glob characters in names are escaped; batches keep argv small.
for (let i = 0; i < list.length; i += 200) {
await run(["unzip", "-q", "-o", path, ...list.slice(i, i + 200).map(globEscape), "-d", dest], { signal });
}
},
};
}
+21
View File
@@ -0,0 +1,21 @@
import { join } from "node:path";
import type { JobContext, ServerContext } from "@stc/contracts";
import { createCourseRepository } from "@stc/course-format";
import type { Deps } from "../src/context";
import { PYTHON, tmp } from "./fixtures";
export interface TestEnv { deps: Deps; dataDir: string; queued: { kind: string; payload: unknown }[]; jc: JobContext; progress: [number, string | undefined][] }
export async function makeEnv(env: Record<string, string | undefined> = {}): Promise<TestEnv> {
const dataDir = await tmp("stc-data-");
const courses = await createCourseRepository([join(dataDir, "courses")]);
const queued: TestEnv["queued"] = [];
const progress: TestEnv["progress"] = [];
const ctx = {
config: { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "http://x" },
courses,
jobs: { enqueue: async (kind: string, payload: unknown) => { queued.push({ kind, payload }); return "j1"; }, get: async () => null, list: async () => [] },
} as unknown as ServerContext;
const jc: JobContext = { signal: new AbortController().signal, reportProgress: async (f, m) => { progress.push([f, m]); } };
return { deps: { ctx, env, tools: { ffmpeg: "ffmpeg", ffprobe: "ffprobe" }, python: PYTHON }, dataDir, queued, jc, progress };
}
@@ -0,0 +1,59 @@
import { expect, test } from "bun:test";
import { readdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { importPacktVideo } from "../src/jobs/packt-video";
import { makeEnv } from "./harness";
import { makePacktZip, tmp } from "./fixtures";
test("imports a Packt zip into a restricted course with sections, titles and media", async () => {
const env = await makeEnv();
const zip = await makePacktZip(await tmp());
const before = await readFile(zip);
const r = await importPacktVideo(env.deps, { kind: "packt-video", path: zip, options: { title: "Docker Test" } }, env.jc);
expect(r.courseId).toBe("docker-test");
expect(r.warnings).toEqual([]);
const c = env.deps.ctx.courses.get("docker-test")!;
expect(c.status).toBe("ok");
expect(c.diagnostics).toEqual([]);
const m = c.manifest!;
expect(m.visibility).toBe("restricted");
expect(m.source?.kind).toBe("packt-video");
expect(m.source?.origin).toBe("video_zip.zip");
expect(m.source?.importedAt).toBeTruthy();
expect(m.modules.map((x) => x.title)).toEqual(["Intro to Docker", "Images (Sample)"]);
expect(m.modules.map((x) => x.lessons.map((l) => l.title))).toEqual([["Course Introduction", "Docker & Friends"], ["Topic Introduction - Images"]]);
expect(m.modules[0]!.lessons[0]).toMatchObject({ id: "m1-l1", type: "video", file: "media/video1_1.mp4", duration: 2 });
expect(env.deps.ctx.courses.resolvePath("docker-test", "media/video2_1.mp4")).toBeTruthy();
expect(m.cover).toBe("media/cover.jpg");
expect((await readdir(join(env.dataDir, "cache/covers/docker-test"))).sort()).toEqual(["frame-1.jpg", "frame-2.jpg", "frame-3.jpg"]);
expect(await readdir(join(env.dataDir, "tmp"))).toEqual([]); // scratch removed
expect(Buffer.compare(await readFile(zip), before)).toBe(0); // original untouched
expect(env.progress.at(-1)![0]).toBe(1);
});
test("second import of the same title gets a fresh id; transcribe requested without a URL warns", async () => {
const env = await makeEnv();
const zip = await makePacktZip(await tmp());
await importPacktVideo(env.deps, { path: zip, options: { title: "Same" } }, env.jc);
const r = await importPacktVideo(env.deps, { path: zip, options: { title: "Same", transcribe: true } }, env.jc);
expect(r.courseId).toBe("same-2");
expect(r.warnings.join()).toContain("STC_TRANSCRIBE_URL is not set");
expect(env.queued).toEqual([]);
});
test("a zip that is not a Packt package fails and leaves nothing behind", async () => {
const env = await makeEnv();
const d = await tmp();
await Bun.write(join(d, "t", "readme.txt"), "x");
const { zipDir } = await import("./fixtures");
await zipDir(join(d, "t"), join(d, "bad.zip"));
await expect(importPacktVideo(env.deps, { path: join(d, "bad.zip") }, env.jc)).rejects.toThrow(/index\.html/);
expect(await stat(join(env.dataDir, "courses")).catch(() => null)).toBeNull();
});
test("transcribe option queues the transcription job when a URL is configured", async () => {
const env = await makeEnv({ STC_TRANSCRIBE_URL: "http://127.0.0.1:1/v1/audio/transcriptions" });
const zip = await makePacktZip(await tmp());
const r = await importPacktVideo(env.deps, { path: zip, options: { transcribe: true } }, env.jc);
expect(env.queued).toEqual([{ kind: "import.transcribe", payload: { courseId: r.courseId } }]);
});
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test";
import { parsePacktIndex } from "../src/packt";
import { packtIndexHtml } from "./fixtures";
test("sections, clip titles and video files come from the structure", () => {
const p = parsePacktIndex(packtIndexHtml());
expect(p.title).toBe("Fake Docker Course");
expect(p.sections.map((s) => s.title)).toEqual(["Intro to Docker", "Images (Sample)"]);
expect(p.sections[0]!.clips).toEqual([
{ number: 1, title: "Course Introduction", video: "videos/video1_1.mp4" },
{ number: 2, title: "Docker & Friends", video: "videos/video1_2.mp4" },
]);
expect(p.sections[1]!.clips[0]!.video).toBe("videos/video2_1.mp4");
expect(p.warnings).toEqual([]);
});
test("a section without a chapter block falls back to the overview list", () => {
const p = parsePacktIndex(packtIndexHtml({ dropChapter: 1 }));
expect(p.sections[0]!.clips.map((c) => [c.title, c.video])).toEqual([
["Course Introduction", "videos/video1_1.mp4"],
["Docker & Friends", "videos/video1_2.mp4"],
]);
expect(p.warnings.join()).toContain("section 1 has no chapter block");
});
test("not a Packt page is a clear error", () => {
expect(() => parsePacktIndex("<html><h1>Hello</h1></html>")).toThrow(/no "Section N/);
});