@@ -2,7 +2,9 @@ import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerConte
|
||||
import { createApp, createJobQueue, loadConfig } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import { importFeature } from "@stc/feature-import";
|
||||
import { learnerFeature } from "@stc/feature-learner";
|
||||
import { resolve } from "node:path";
|
||||
import indexHtml from "../../web/index.html";
|
||||
|
||||
// Used until a feature supplies an `auth` service (the learner package does).
|
||||
@@ -49,8 +51,12 @@ export async function startServer(
|
||||
|
||||
if (import.meta.main) {
|
||||
const config = loadConfig();
|
||||
// Contract request (import.md): core config should include <dataDir>/courses itself.
|
||||
const generated = resolve(config.dataDir, "courses");
|
||||
if (!config.courseDirs.some((c) => resolve(c) === generated)) config.courseDirs.push(generated);
|
||||
const { server } = await startServer(config, [
|
||||
learnerFeature,
|
||||
(ctx) => importFeature(ctx),
|
||||
]);
|
||||
console.log(`STCourses listening on ${server.url}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { WebFeature } from "@stc/contracts";
|
||||
import { learnerWeb } from "@stc/feature-learner/web";
|
||||
import { importWeb } from "../../../packages/feature-import/src/web";
|
||||
|
||||
// Phase 1 packages append one import line each and push into this list.
|
||||
export const webFeatures: WebFeature[] = [learnerWeb];
|
||||
export const webFeatures: WebFeature[] = [learnerWeb, importWeb];
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Contract requests from WP-B (Importer)
|
||||
|
||||
1. **`loadConfig` should add `<dataDir>/courses` to `courseDirs`.** The importer writes courses there. Worked around in `apps/server/src/main.ts` (the main guard pushes it) and in `packages/feature-import/dev/serve.ts`. `/api/import/info` reports `coursesDirServed` and the UI warns when it is false.
|
||||
2. **`startServer` accepts feature factories** (`Feature | ((ctx) => Feature)`). A `Feature` needs `ServerContext`, but `ServerContext.jobs` is built from the features' job handlers. `importFeature(ctx)` only reads `ctx` while a job or request runs, so `main.ts` now builds `ctx` first, resolves the factories, then assigns `ctx.jobs`. The edit is about ten lines in `apps/server/src/main.ts`; Phase 2 should keep or replace it.
|
||||
3. **Web entry for features.** `apps/web` must not pull a package's server code into the browser bundle. `@stc/feature-import` exposes a browser-safe entry as `./web` (package.json `exports`), but the root tsconfig `paths` (`@stc/*` to `packages/*/src`) cannot resolve `@stc/feature-import/web`, so `apps/web/src/features.ts` imports it by relative path. Suggest a tsconfig `paths` entry per feature, or a `src/web.ts` convention.
|
||||
4. **Derived cache layout (no contract defines one).** Written by the importer, to be read by the learner and AI packages:
|
||||
- `<dataDir>/cache/decks/<courseId>/<deckId>/p-001.jpg ...` (1280px wide JPEGs) and `text.json` (`[{ page, text }]`);
|
||||
- `<dataDir>/cache/covers/<courseId>/frame-N.jpg` and `deck-<deckId>.jpg` (cover candidates);
|
||||
- `<dataDir>/cache/transcripts/<courseId>/<lessonId>.json` (`{ lessonId, text, segments: [{ start, end, text }] }`).
|
||||
Suggest a `cacheDir`/`deckPageUrl` helper in core, and a media route for `cache/decks` so the deck viewer can load pages.
|
||||
5. **Video lessons have no description field.** Packt clips come with a one-line description. It is dropped, because `transcript` is the only Markdown a video lesson can carry and the transcription job writes it.
|
||||
6. **`STC_IMPORT_ROOTS`** (":"-separated, default `<dataDir>/import-inbox`, created when `importFeature` is built) is read inside the import package. Core config could own it later.
|
||||
@@ -0,0 +1,56 @@
|
||||
# WP-B Importer Implementation Plan
|
||||
|
||||
> Executed inline by the WP-B agent, task by task, TDD. Spec: `docs/superpowers/specs/2026-10-30-stcourses-design.md` sections 5, 8, 13.
|
||||
|
||||
**Goal:** `packages/feature-import` turns a Packt video zip, a companion code repo, a slide PDF or a folder of videos into an ordinary course folder under `<dataDir>/courses/<id>/`, as background jobs with visible progress, plus an owner-only admin UI at `/admin/import`.
|
||||
|
||||
**Architecture:**
|
||||
- `importFeature(ctx)` returns a `Feature` with job handlers `import.packt-video`, `import.code-repo`, `import.pdf-deck`, `import.video-folder`, `import.transcribe` and owner-only `/api/import/*` routes. `ctx` is read lazily (inside handlers), because the job queue is built from the handlers.
|
||||
- Courses are built in `<dataDir>/tmp/<job>` and renamed into `<dataDir>/courses/<id>` in one step. Attach jobs (code, deck) only touch courses under `<dataDir>/courses` and write the manifest last, through temp file + rename.
|
||||
- Media: **copied** (`cp --reflink=auto`, so instant on CoW filesystems), or hard-linked with `options.media = "link"` (falls back to copy across filesystems). Symlinks out of the course are not an option: the media route 404s on paths that escape the course root. The originals are only ever read.
|
||||
- PDFs: PyMuPDF through `py/pdf_tool.py` run as `python -I` in a subprocess (`STC_PYTHON`, default `python3`). Chosen over pdfjs/mupdf-wasm because it rasterises and extracts text natively, is already installed, is AGPL-compatible and costs no idle memory in the server. If it is missing, the deck is still attached and the job reports a warning.
|
||||
- Derived data goes to `<dataDir>/cache/`: `decks/<course>/<deck>/p-001.jpg…` + `text.json`, `covers/<course>/*.jpg`, `transcripts/<course>/<lesson>.json`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
Inherited unchanged from `docs/superpowers/plans/2026-10-30-stcourses.md` (Bun conventions, AGPL, design tokens, no licensed content in the repo, commit trailer).
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Original files are never modified; attach jobs refuse courses outside `<dataDir>/courses` (overlays, authored repos).
|
||||
2. Paths with spaces/non-ASCII (`S08 - Containerizing Applications`) survive unzip, copy and manifest round trip.
|
||||
3. Packt parsing is structural (chapter blocks plus the section summary lists), not regexes on "Video N.M:" prefixes.
|
||||
4. Failure leaves no half-built course visible; a failed PDF/ffmpeg step degrades to a warning where the course is still valid.
|
||||
5. Routes: 401 without a user, 403 without the owner role.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Utilities (slug, HTML entities, natural sort, exec, manifest IO, sources)
|
||||
- Files: `src/util/*.ts`, `test/util.test.ts`
|
||||
- Interfaces: `slugify`, `uniqueCourseId`, `decodeEntities`, `naturalSort`, `run`, `readRawManifest`, `writeManifest` (validates with `ManifestSchema`), `openSource(path)` (zip via `unzip`, or directory).
|
||||
- [ ] Failing tests for each helper; [ ] implement; [ ] commit.
|
||||
|
||||
### Task 2: Fixtures and media tooling
|
||||
- Files: `test/fixtures.ts` (generates fake Packt zip, repo zip, PDF, video folder in a temp dir), `src/media.ts` (ffprobe duration, ffmpeg frame, copy/link), `src/pdf.ts`, `py/pdf_tool.py`.
|
||||
- [ ] Failing tests (frame extraction, duration, PDF pages+text); [ ] implement; [ ] commit.
|
||||
|
||||
### Task 3: Packt parser and `import.packt-video`
|
||||
- Files: `src/packt.ts`, `src/jobs/packt-video.ts`, `test/packt.test.ts`
|
||||
- [ ] Parser test on the synthetic index (summary-only titles, missing chapter); [ ] job test (2 sections, 3 clips, restricted manifest, source filled, course loads `ok`, cover candidates); [ ] implement; [ ] commit.
|
||||
|
||||
### Task 4: `import.video-folder`, `import.pdf-deck`
|
||||
- [ ] Tests: natural order, deck cache pages/text, attach to lesson and module, refuses unmanaged course; [ ] implement; [ ] commit.
|
||||
|
||||
### Task 5: `import.code-repo`
|
||||
- [ ] Tests: folder `S02 - …` attaches to module 2 by number, by name when no number, PDFs become deck/attachment, git URL validation; [ ] implement; [ ] commit.
|
||||
|
||||
### Task 6: `import.transcribe`
|
||||
- [ ] Tests with a fake Whisper server; skipped when `STC_TRANSCRIBE_URL` unset; [ ] implement; [ ] commit.
|
||||
|
||||
### Task 7: Routes and `importFeature`
|
||||
- [ ] Tests: 401/403/owner, enqueue validation, queue integration (SQLite and Postgres), covers; [ ] implement; wire `main.ts`; [ ] commit.
|
||||
|
||||
### Task 8: Web UI `importWeb`
|
||||
- [ ] Page with form, job list with progress, cover picker; register in `apps/web/src/features.ts`; screenshot check; [ ] commit.
|
||||
|
||||
### Task 9: Manual check against `/home/user/example_course_content`, report.
|
||||
@@ -0,0 +1,23 @@
|
||||
// Dev server for the import UI while real sign-in (learner package) is not merged: everyone is the owner.
|
||||
// Usage: STC_PORT=3462 STC_DATA_DIR=/tmp/stc-import-data STC_PYTHON=… bun packages/feature-import/dev/serve.ts
|
||||
import { resolve } from "node:path";
|
||||
import type { AuthService, ServerContext } from "@stc/contracts";
|
||||
import { createApp, createJobQueue, loadConfig } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import indexHtml from "../../../apps/web/index.html";
|
||||
import { importFeature } from "../src";
|
||||
|
||||
const config = loadConfig();
|
||||
config.courseDirs.push(resolve(config.dataDir, "courses"));
|
||||
const db = await connect(config.databaseUrl);
|
||||
await migrate(db);
|
||||
const auth: AuthService = { userFromRequest: async () => ({ id: "dev", email: "dev@localhost", name: "Dev owner", roles: ["owner"], groups: [] }) };
|
||||
const ctx = { config, db, courses: await createCourseRepository(config.courseDirs), auth } as ServerContext;
|
||||
const feature = importFeature(ctx);
|
||||
const jobs = createJobQueue(db, feature.jobs!);
|
||||
ctx.jobs = jobs;
|
||||
const app = createApp(ctx, [feature]);
|
||||
jobs.start();
|
||||
const server = Bun.serve({ port: config.port, routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml }, fetch: (req) => app.fetch(req) });
|
||||
console.log(`import dev server on ${server.url}`);
|
||||
@@ -3,5 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-only"
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "src/index.ts",
|
||||
"exports": { ".": "./src/index.ts", "./web": "./src/web.ts" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""PDF helper for the STCourses importer. Run as: python -I pdf_tool.py render <pdf> <outdir> [--width 1280] [--quality 80]
|
||||
|
||||
Writes outdir/p-001.jpg ... and outdir/text.json ([{"page": 1, "text": "..."}]).
|
||||
Prints one "page N/M" line per page on stdout and a final JSON line {"pages": M, "title": "..."}.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import pymupdf as fitz
|
||||
except ImportError: # older PyMuPDF
|
||||
import fitz
|
||||
|
||||
|
||||
def render(pdf, outdir, width, quality):
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
doc = fitz.open(pdf)
|
||||
n = len(doc)
|
||||
texts = []
|
||||
for i, page in enumerate(doc, start=1):
|
||||
zoom = width / max(page.rect.width, 1)
|
||||
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
|
||||
pix.save(os.path.join(outdir, "p-%03d.jpg" % i), jpg_quality=quality)
|
||||
texts.append({"page": i, "text": page.get_text().strip()})
|
||||
print("page %d/%d" % (i, n), flush=True)
|
||||
with open(os.path.join(outdir, "text.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(texts, f, ensure_ascii=False)
|
||||
print(json.dumps({"pages": n, "title": (doc.metadata or {}).get("title") or ""}), flush=True)
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 4 or argv[1] != "render":
|
||||
print("usage: pdf_tool.py render <pdf> <outdir> [--width N] [--quality N]", file=sys.stderr)
|
||||
return 2
|
||||
width, quality = 1280, 80
|
||||
rest = argv[4:]
|
||||
while rest:
|
||||
flag, val = rest[0], rest[1] if len(rest) > 1 else ""
|
||||
if flag == "--width":
|
||||
width = int(val)
|
||||
elif flag == "--quality":
|
||||
quality = int(val)
|
||||
rest = rest[2:]
|
||||
render(argv[2], argv[3], width, quality)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -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,49 @@
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { coverCacheDir, deckCacheDir, exists, type Deps, type ManagedCourse } from "./context";
|
||||
import { putFile } from "./media";
|
||||
import { renderPdf } from "./pdf";
|
||||
import { slugify, uniqueId } from "./util/text";
|
||||
|
||||
export interface AttachedDeck { deckId: string; file: string; pages?: number; warning?: string }
|
||||
|
||||
/** All lesson and module ids of a raw manifest, for validating "attach to". */
|
||||
export function attachTargets(manifest: Record<string, any>): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const m of manifest.modules ?? []) {
|
||||
ids.add(m.id);
|
||||
for (const l of m.lessons ?? []) ids.add(l.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a PDF into the course (media/decks/), registers it in the (in-memory) manifest, and builds the derived cache:
|
||||
* page images + text.json under <dataDir>/cache/decks/<course>/<deck>/ and the first page as a cover candidate.
|
||||
* A failure of the PDF tool only degrades to a warning: the deck stays attached and the cache can be rebuilt.
|
||||
* The caller writes the manifest.
|
||||
*/
|
||||
export async function attachDeck(d: Deps, course: ManagedCourse, pdfPath: string, attachTo: string, opts: { title?: string; mode?: "copy" | "link"; onProgress?: (fraction: number) => void } = {}): Promise<AttachedDeck> {
|
||||
if (!attachTarget(course, attachTo)) throw new Error(`attachTo "${attachTo}" is not a lesson or module of course "${course.id}"`);
|
||||
const decks: any[] = (course.manifest.decks ??= []);
|
||||
const baseName = basename(pdfPath, extname(pdfPath));
|
||||
const deckId = uniqueId(`d-${slugify(baseName)}`, (x) => decks.some((k) => k.id === x));
|
||||
let file = `media/decks/${basename(pdfPath)}`;
|
||||
for (let n = 2; await exists(join(course.dir, file)); n++) file = `media/decks/${baseName}-${n}${extname(pdfPath)}`;
|
||||
await putFile(pdfPath, join(course.dir, file), opts.mode ?? "copy");
|
||||
decks.push({ id: deckId, file, title: opts.title ?? baseName.replace(/[_]+/g, " ").trim(), attachTo });
|
||||
|
||||
const out: AttachedDeck = { deckId, file };
|
||||
try {
|
||||
const r = await renderPdf(d.python, join(course.dir, file), deckCacheDir(d, course.id, deckId), { onPage: (p, t) => opts.onProgress?.(p / t) });
|
||||
out.pages = r.pages;
|
||||
await mkdir(coverCacheDir(d, course.id), { recursive: true });
|
||||
await copyFile(join(deckCacheDir(d, course.id, deckId), "p-001.jpg"), join(coverCacheDir(d, course.id), `deck-${deckId}.jpg`));
|
||||
} catch (e) {
|
||||
out.warning = `deck "${basename(pdfPath)}" is attached, but page images and text could not be built (${e instanceof Error ? e.message.split("\n")[0] : e}); STC_PYTHON must point to Python with PyMuPDF`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const attachTarget = (c: ManagedCourse, id: string) => attachTargets(c.manifest).has(id);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Feature, JobHandler, ServerContext } from "@stc/contracts";
|
||||
import type { Deps, ImportOptions } from "./context";
|
||||
import { importCodeRepo } from "./jobs/code-repo";
|
||||
import { importPacktVideo } from "./jobs/packt-video";
|
||||
import { importPdfDeck } from "./jobs/pdf-deck";
|
||||
import { transcribeCourse } from "./jobs/transcribe";
|
||||
import { importVideoFolder } from "./jobs/video-folder";
|
||||
import { ensureRoots } from "./roots";
|
||||
import { importRoutes } from "./routes";
|
||||
|
||||
export type { ImportOptions } from "./context";
|
||||
export { deckCacheDir, coverCacheDir, transcriptCacheDir } from "./context";
|
||||
|
||||
/**
|
||||
* `ctx` is only read when a job runs or a request arrives, never while this function runs, so the server can pass
|
||||
* its context object before the job queue (which needs these handlers) exists.
|
||||
* Environment: STC_PYTHON (Python with PyMuPDF, default python3), STC_FFMPEG / STC_FFPROBE, STC_TRANSCRIBE_URL (+ _KEY, _MODEL).
|
||||
*/
|
||||
export function importFeature(ctx: ServerContext, opts: ImportOptions = {}): Feature {
|
||||
const deps = (): Deps => {
|
||||
const env = opts.env ?? process.env;
|
||||
return { ctx, env, tools: { ffmpeg: env.STC_FFMPEG || "ffmpeg", ffprobe: env.STC_FFPROBE || "ffprobe" }, python: env.STC_PYTHON || "python3" };
|
||||
};
|
||||
const job = (fn: (d: Deps, p: unknown, jc: Parameters<JobHandler>[1]) => Promise<unknown>): JobHandler => (p, jc) => fn(deps(), p, jc);
|
||||
ensureRoots({ ctx, env: opts.env ?? process.env });
|
||||
return {
|
||||
name: "import",
|
||||
routes: importRoutes(deps),
|
||||
jobs: {
|
||||
"import.packt-video": job(importPacktVideo),
|
||||
"import.code-repo": job(importCodeRepo),
|
||||
"import.pdf-deck": job(importPdfDeck),
|
||||
"import.video-folder": job(importVideoFolder),
|
||||
"import.transcribe": job(transcribeCourse),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { importWeb } from "./web";
|
||||
@@ -0,0 +1,135 @@
|
||||
import { cp, lstat, mkdir, readdir } from "node:fs/promises";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import type { ImportResult, JobContext } from "@stc/contracts";
|
||||
import { finish, managedCourse, saveManifest, stageDir, type Deps } from "../context";
|
||||
import { attachDeck } from "../deck";
|
||||
import { putFile } from "../media";
|
||||
import { run } from "../util/exec";
|
||||
import { assertAllowedPath } from "../roots";
|
||||
import { openSource } from "../util/source";
|
||||
import { containment } from "../util/text";
|
||||
import { optStr, parsePayload, withStage } from "./common";
|
||||
|
||||
const GIT_URL = /^(https?:\/\/|ssh:\/\/|git@)[^\s]+$/;
|
||||
const SKIP_DIRS = new Set([".git", "node_modules", "__pycache__", ".venv"]);
|
||||
const NUMBER = /^\s*(?:s|sec(?:tion)?|mod(?:ule)?)?\s*0*(\d+)\b\s*[-–—:.)_]*\s*/i;
|
||||
|
||||
export function folderNumber(name: string): number | null {
|
||||
const m = NUMBER.exec(name);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
export function folderName(name: string): string {
|
||||
return name.replace(NUMBER, "").trim();
|
||||
}
|
||||
|
||||
function moduleNumber(m: { id: string; title: string }, index: number): number {
|
||||
const byId = /^m0*(\d+)$/i.exec(m.id);
|
||||
if (byId) return Number(byId[1]);
|
||||
return folderNumber(m.title) ?? index + 1;
|
||||
}
|
||||
|
||||
/** Single-directory wrappers ("Repo-main/…") of a zip are skipped. */
|
||||
async function effectiveRoot(dir: string): Promise<string> {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const entries = (await readdir(dir, { withFileTypes: true })).filter((e) => !e.name.startsWith(".") && e.name !== "__MACOSX");
|
||||
if (entries.length === 1 && entries[0]!.isDirectory()) dir = join(dir, entries[0]!.name);
|
||||
else break;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function findPdfs(dir: string, depth = 0, out: string[] = []): Promise<string[]> {
|
||||
for (const e of await readdir(dir, { withFileTypes: true })) {
|
||||
if (SKIP_DIRS.has(e.name)) continue;
|
||||
if (e.isDirectory() && depth < 2) await findPdfs(join(dir, e.name), depth + 1, out);
|
||||
else if (e.isFile() && extname(e.name).toLowerCase() === ".pdf") out.push(join(dir, e.name));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function importCodeRepo(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
|
||||
const { path, options } = parsePayload(payload, "code-repo");
|
||||
const warnings: string[] = [];
|
||||
const course = await managedCourse(d, optStr(options, "courseId"));
|
||||
if (!GIT_URL.test(path)) await assertAllowedPath(d, path);
|
||||
const stage = await stageDir(d, "repo");
|
||||
|
||||
return withStage(stage, async () => {
|
||||
let root: string;
|
||||
if (GIT_URL.test(path)) {
|
||||
await jc.reportProgress(0.05, "Cloning the repository");
|
||||
await run(["git", "clone", "--depth", "1", "--", path, join(stage, "repo")], { signal: jc.signal, env: { GIT_TERMINAL_PROMPT: "0" } });
|
||||
root = join(stage, "repo");
|
||||
} else if (/\.zip$/i.test(path)) {
|
||||
await jc.reportProgress(0.05, "Unpacking the repository zip");
|
||||
const src = await openSource(path);
|
||||
await src.extract(src.entries.filter((e) => !e.startsWith("__MACOSX/")), join(stage, "repo"), jc.signal);
|
||||
root = await effectiveRoot(join(stage, "repo")); // GitHub-style zips wrap everything in one folder
|
||||
} else {
|
||||
root = path; // an extracted folder is only read
|
||||
}
|
||||
|
||||
const modules: any[] = course.manifest.modules ?? [];
|
||||
const folders = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")).map((e) => e.name).sort();
|
||||
const attachments: any[] = (course.manifest.attachments ??= []);
|
||||
const claimed = new Set<string>();
|
||||
let matched = 0;
|
||||
|
||||
for (const [i, folder] of folders.entries()) {
|
||||
await jc.reportProgress(0.1 + 0.8 * (i / Math.max(1, folders.length)), `Matching ${folder}`);
|
||||
const num = folderNumber(folder);
|
||||
const name = folderName(folder);
|
||||
let mod = num !== null ? modules.find((m, idx) => moduleNumber(m, idx) === num) : undefined;
|
||||
if (mod && name && containment(name, mod.title) < 0.3 && containment(mod.title, name) < 0.3) {
|
||||
warnings.push(`"${folder}" was matched to module ${num} ("${mod.title}") by number only; the names differ`);
|
||||
}
|
||||
if (!mod && name) {
|
||||
let best = 0;
|
||||
for (const m of modules) {
|
||||
const score = containment(name, m.title);
|
||||
if (score > best) { best = score; mod = m; }
|
||||
}
|
||||
if (best < 0.6) mod = undefined;
|
||||
}
|
||||
if (!mod) { warnings.push(`folder "${folder}" matches no module and was skipped`); continue; }
|
||||
if (claimed.has(mod.id)) { warnings.push(`folder "${folder}" also matches module "${mod.id}", which already got code; skipped`); continue; }
|
||||
if (mod.code) { warnings.push(`module "${mod.id}" already has a code folder; "${folder}" skipped`); continue; }
|
||||
claimed.add(mod.id);
|
||||
matched++;
|
||||
|
||||
const srcDir = join(root, folder);
|
||||
const dest = `code/${folder}`;
|
||||
await mkdir(join(course.dir, "code"), { recursive: true });
|
||||
await cp(srcDir, join(course.dir, dest), {
|
||||
recursive: true,
|
||||
filter: async (s) => {
|
||||
const base = basename(s);
|
||||
if (SKIP_DIRS.has(base)) return false;
|
||||
const st = await lstat(s);
|
||||
if (st.isSymbolicLink()) return false; // a link could point outside the course
|
||||
return !(st.isFile() && extname(base).toLowerCase() === ".pdf");
|
||||
},
|
||||
});
|
||||
mod.code = { dir: dest };
|
||||
|
||||
for (const pdf of await findPdfs(srcDir)) {
|
||||
const title = basename(pdf, ".pdf").replace(/_/g, " ").trim();
|
||||
if (/cheat|quick.?ref|reference|handout/i.test(basename(pdf))) {
|
||||
let rel = `media/files/${basename(pdf)}`;
|
||||
await putFile(pdf, join(course.dir, rel));
|
||||
attachments.push({ file: rel, title, module: mod.id });
|
||||
} else {
|
||||
const r = await attachDeck(d, course, pdf, mod.id, { title });
|
||||
if (r.warning) warnings.push(r.warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attachments.length === 0) delete course.manifest.attachments;
|
||||
if (matched === 0) warnings.push("no folder of the repository matched a module; nothing was attached");
|
||||
|
||||
await saveManifest(course.dir, course.manifest);
|
||||
await finish(d, course.id, warnings);
|
||||
await jc.reportProgress(1, `Attached code to ${matched} module(s)`);
|
||||
return { courseId: course.id, manifestPath: join(course.dir, "stcourse.yaml"), warnings };
|
||||
});
|
||||
}
|
||||
@@ -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,89 @@
|
||||
import { mkdir, rename, rm } 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 { assertAllowedPath } from "../roots";
|
||||
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 assertAllowedPath(d, path);
|
||||
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, ".extract");
|
||||
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++;
|
||||
}
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { ImportResult, JobContext } from "@stc/contracts";
|
||||
import { finish, managedCourse, saveManifest, type Deps } from "../context";
|
||||
import { assertAllowedPath } from "../roots";
|
||||
import { attachDeck } from "../deck";
|
||||
import { optStr, parsePayload } from "./common";
|
||||
|
||||
export async function importPdfDeck(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
|
||||
const { path, options } = parsePayload(payload, "pdf-deck");
|
||||
const warnings: string[] = [];
|
||||
await assertAllowedPath(d, path);
|
||||
if (!(await stat(path).catch(() => null))?.isFile() || !/\.pdf$/i.test(path)) throw new Error(`not a PDF file: ${path}`);
|
||||
const course = await managedCourse(d, optStr(options, "courseId"));
|
||||
const attachTo = optStr(options, "attachTo");
|
||||
if (!attachTo) throw new Error("options.attachTo is required: the lesson or module id the deck belongs to");
|
||||
await jc.reportProgress(0.05, "Copying the deck");
|
||||
const r = await attachDeck(d, course, path, attachTo, {
|
||||
title: optStr(options, "title"),
|
||||
mode: options.media === "link" ? "link" : "copy",
|
||||
onProgress: (f) => void jc.reportProgress(0.1 + 0.8 * f, "Rendering slides"),
|
||||
});
|
||||
if (r.warning) warnings.push(r.warning);
|
||||
await saveManifest(course.dir, course.manifest);
|
||||
await finish(d, course.id, warnings);
|
||||
await jc.reportProgress(1, r.pages ? `Deck attached (${r.pages} slides)` : "Deck attached");
|
||||
return { courseId: course.id, manifestPath: join(course.dir, "stcourse.yaml"), warnings };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { JobContext } from "@stc/contracts";
|
||||
import { finish, managedCourse, saveManifest, stageDir, transcriptCacheDir, type Deps } from "../context";
|
||||
import { run } from "../util/exec";
|
||||
import { optStr, withStage } from "./common";
|
||||
|
||||
export interface TranscribeResult { courseId: string; skipped?: boolean; reason?: string; transcribed: number; warnings: string[] }
|
||||
interface Segment { start: number; end: number; text: string }
|
||||
|
||||
const stamp = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||||
|
||||
/** Groups timed segments into paragraphs of roughly 30 seconds, each starting with a [m:ss] marker. */
|
||||
export function segmentsToMarkdown(title: string, segments: Segment[], fallbackText: string): string {
|
||||
if (segments.length === 0) return `# ${title}\n\n${fallbackText.trim()}\n`;
|
||||
const paras: string[] = [];
|
||||
let cur: string[] = [];
|
||||
let startedAt = segments[0]!.start;
|
||||
for (const s of segments) {
|
||||
if (cur.length && s.start - startedAt >= 30) {
|
||||
paras.push(`[${stamp(startedAt)}] ${cur.join(" ")}`);
|
||||
cur = []; startedAt = s.start;
|
||||
}
|
||||
cur.push(s.text.trim());
|
||||
}
|
||||
if (cur.length) paras.push(`[${stamp(startedAt)}] ${cur.join(" ")}`);
|
||||
return `# ${title}\n\n${paras.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional job: sends each video lesson's audio to a Whisper-compatible endpoint (STC_TRANSCRIBE_URL, OpenAI
|
||||
* /v1/audio/transcriptions shape) and attaches the result as the lesson's transcript. The model never runs in this process.
|
||||
*/
|
||||
export async function transcribeCourse(d: Deps, payload: unknown, jc: JobContext): Promise<TranscribeResult> {
|
||||
const p = (payload ?? {}) as Record<string, unknown>;
|
||||
const url = d.env.STC_TRANSCRIBE_URL;
|
||||
const courseId = optStr(p, "courseId") ?? "";
|
||||
if (!url) return { courseId, skipped: true, reason: "STC_TRANSCRIBE_URL is not set", transcribed: 0, warnings: [] };
|
||||
const course = await managedCourse(d, courseId);
|
||||
const only = Array.isArray(p.lessonIds) ? new Set(p.lessonIds.map(String)) : null;
|
||||
const lessons = (course.manifest.modules as any[]).flatMap((m) => m.lessons as any[]).filter((l) => l.type === "video" && !l.transcript && (!only || only.has(l.id)));
|
||||
const warnings: string[] = [];
|
||||
const stage = await stageDir(d, "transcribe");
|
||||
let done = 0;
|
||||
return withStage(stage, async () => {
|
||||
try {
|
||||
for (const [i, l] of lessons.entries()) {
|
||||
if (jc.signal.aborted) break;
|
||||
await jc.reportProgress(i / Math.max(1, lessons.length), `Transcribing ${l.title}`);
|
||||
try {
|
||||
const audio = join(stage, `${l.id}.ogg`);
|
||||
await run([d.tools.ffmpeg, "-v", "error", "-y", "-i", join(course.dir, l.file), "-vn", "-ac", "1", "-ar", "16000", "-c:a", "libopus", "-b:a", "24k", audio], { signal: jc.signal });
|
||||
const form = new FormData();
|
||||
form.set("file", Bun.file(audio), `${l.id}.ogg`);
|
||||
form.set("model", d.env.STC_TRANSCRIBE_MODEL || "whisper-1");
|
||||
form.set("response_format", "verbose_json");
|
||||
const res = await fetch(url, {
|
||||
method: "POST", body: form, signal: jc.signal,
|
||||
headers: d.env.STC_TRANSCRIBE_KEY ? { Authorization: `Bearer ${d.env.STC_TRANSCRIBE_KEY}` } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
|
||||
const body = (await res.json()) as { text?: string; segments?: Segment[] };
|
||||
const segments = (body.segments ?? []).map((s) => ({ start: Number(s.start) || 0, end: Number(s.end) || 0, text: String(s.text ?? "") }));
|
||||
const rel = `transcripts/${l.id}.md`;
|
||||
await mkdir(join(course.dir, "transcripts"), { recursive: true });
|
||||
await writeFile(join(course.dir, rel), segmentsToMarkdown(l.title, segments, body.text ?? ""));
|
||||
await mkdir(transcriptCacheDir(d, course.id), { recursive: true });
|
||||
await writeFile(join(transcriptCacheDir(d, course.id), `${l.id}.json`), JSON.stringify({ lessonId: l.id, text: body.text ?? "", segments }));
|
||||
l.transcript = rel;
|
||||
done++;
|
||||
} catch (e) {
|
||||
warnings.push(`${l.title}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (done > 0) await saveManifest(course.dir, course.manifest); // keep what finished even if the job was cancelled
|
||||
}
|
||||
if (done > 0) await finish(d, course.id, warnings);
|
||||
await jc.reportProgress(1, `Transcribed ${done} of ${lessons.length} lessons`);
|
||||
return { courseId: course.id, transcribed: done, warnings };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import type { ImportResult, JobContext } from "@stc/contracts";
|
||||
import { finish, frameCandidates, freshCourseId, publishCourse, saveManifest, sourceBlock, stageDir, type Deps } from "../context";
|
||||
import { probeDuration, putFile } from "../media";
|
||||
import { assertAllowedPath } from "../roots";
|
||||
import { naturalSort } from "../util/text";
|
||||
import { openSource } from "../util/source";
|
||||
import { adoptCover, maybeQueueTranscription, optBool, optStr, parsePayload, withStage } from "./common";
|
||||
|
||||
const VIDEO = /\.(mp4|m4v|webm|mov|mkv|avi)$/i;
|
||||
const BROWSER_OK = /\.(mp4|m4v|webm|mov)$/i;
|
||||
|
||||
export function lessonTitle(file: string): string {
|
||||
const base = basename(file, extname(file));
|
||||
const t = base.replace(/^\s*\d+\s*[-_.)\s]+(?=\S)/, "").replace(/_/g, " ").replace(/\s+/g, " ").trim();
|
||||
return t || base;
|
||||
}
|
||||
|
||||
export async function importVideoFolder(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
|
||||
const { path, options } = parsePayload(payload, "video-folder");
|
||||
const warnings: string[] = [];
|
||||
await assertAllowedPath(d, path);
|
||||
if (!(await stat(path).catch(() => null))?.isDirectory()) throw new Error(`not a folder: ${path}`);
|
||||
const src = await openSource(path);
|
||||
const files = naturalSort(src.entries.filter((e) => VIDEO.test(e) && !e.split("/").some((p) => p.startsWith("."))));
|
||||
if (files.length === 0) throw new Error(`no video files found in ${path}`);
|
||||
const title = optStr(options, "title") ?? basename(path).replace(/[_]+/g, " ");
|
||||
const id = await freshCourseId(d, optStr(options, "courseId"), title);
|
||||
const mode = options.media === "link" ? "link" : "copy";
|
||||
const stage = await stageDir(d, "videos");
|
||||
|
||||
return withStage(stage, async () => {
|
||||
const lessons: any[] = [];
|
||||
const placed: string[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const [i, f] of files.entries()) {
|
||||
await jc.reportProgress(0.05 + 0.7 * (i / files.length), `Copying ${basename(f)}`);
|
||||
let rel = `media/${f}`;
|
||||
if (used.has(rel.toLowerCase())) rel = `media/${i + 1}-${basename(f)}`;
|
||||
used.add(rel.toLowerCase());
|
||||
await putFile(join(path, f), join(stage, rel), mode);
|
||||
placed.push(join(stage, rel));
|
||||
if (!BROWSER_OK.test(f)) warnings.push(`${f} may not play in browsers (${extname(f)}); consider converting it to MP4`);
|
||||
const duration = await probeDuration(d.tools, join(stage, rel));
|
||||
lessons.push({ id: `l${i + 1}`, title: lessonTitle(f), type: "video", file: rel, ...(duration ? { duration } : {}) });
|
||||
}
|
||||
await jc.reportProgress(0.8, "Making cover candidates");
|
||||
const covers = await frameCandidates(d, id, placed);
|
||||
if (covers.length === 0) warnings.push("could not extract cover frames (is ffmpeg available?)");
|
||||
const cover = await adoptCover(d, id, stage, covers[0]);
|
||||
await saveManifest(stage, {
|
||||
schema: 1, id, title, visibility: "restricted", ...(cover ? { cover } : {}),
|
||||
source: sourceBlock("video-folder", path),
|
||||
modules: [{ id: "m1", title: "Videos", lessons }],
|
||||
});
|
||||
await jc.reportProgress(0.92, "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 ${files.length} videos`);
|
||||
return { courseId: id, manifestPath: join(dir, "stcourse.yaml"), warnings };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { link, mkdir, copyFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { run } from "./util/exec";
|
||||
|
||||
export interface Tools { ffmpeg: string; ffprobe: string }
|
||||
|
||||
export async function probeDuration(t: Tools, file: string): Promise<number | undefined> {
|
||||
try {
|
||||
const out = await run([t.ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file]);
|
||||
const s = Number(out.trim());
|
||||
return Number.isFinite(s) && s > 0 ? Math.round(s) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** One JPEG frame at `fraction` of the clip, 640px wide. */
|
||||
export async function extractFrame(t: Tools, file: string, out: string, fraction = 0.2): Promise<void> {
|
||||
await mkdir(dirname(out), { recursive: true });
|
||||
const dur = (await probeDuration(t, file)) ?? 0;
|
||||
const at = Math.max(0, dur * fraction);
|
||||
await run([t.ffmpeg, "-v", "error", "-y", "-ss", String(at), "-i", file, "-frames:v", "1", "-vf", "scale=640:-2", "-q:v", "4", out]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a large file into the course. "copy" uses `cp --reflink=auto` (a CoW clone where the filesystem supports it,
|
||||
* a plain copy elsewhere); "link" makes a hard link and falls back to copying across filesystems.
|
||||
* Either way the original is only read, and the course folder stays self-contained (the media route refuses symlinks that leave it).
|
||||
*/
|
||||
export async function putFile(src: string, dest: string, mode: "copy" | "link" = "copy"): Promise<void> {
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
if (mode === "link") {
|
||||
try { await link(src, dest); return; } catch { /* cross-device or unsupported: copy */ }
|
||||
}
|
||||
try {
|
||||
await run(["cp", "--reflink=auto", "--", src, dest]);
|
||||
} catch {
|
||||
await copyFile(src, dest);
|
||||
}
|
||||
}
|
||||
@@ -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,23 @@
|
||||
import { join } from "node:path";
|
||||
import { run } from "./util/exec";
|
||||
|
||||
export interface DeckRender { pages: number; title: string }
|
||||
|
||||
const TOOL = join(import.meta.dir, "..", "py", "pdf_tool.py");
|
||||
|
||||
/**
|
||||
* Renders page images (p-001.jpg ...) and text.json into `outDir` with PyMuPDF in a subprocess,
|
||||
* so the server process never holds a PDF engine. `python` is STC_PYTHON (default python3).
|
||||
*/
|
||||
export async function renderPdf(python: string, pdf: string, outDir: string, opts: { onPage?: (page: number, total: number) => void; signal?: AbortSignal } = {}): Promise<DeckRender> {
|
||||
let last = "";
|
||||
await run([python, "-I", TOOL, "render", pdf, outDir], {
|
||||
signal: opts.signal,
|
||||
onLine(l) {
|
||||
const m = /^page (\d+)\/(\d+)$/.exec(l);
|
||||
if (m) opts.onPage?.(Number(m[1]), Number(m[2]));
|
||||
else if (l.trim()) last = l;
|
||||
},
|
||||
});
|
||||
return JSON.parse(last) as DeckRender;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { resolve, sep } from "node:path";
|
||||
import type { Deps } from "./context";
|
||||
|
||||
export const OUTSIDE = "path is outside the allowed import folders";
|
||||
|
||||
/** STC_IMPORT_ROOTS (":"-separated), default <dataDir>/import-inbox. */
|
||||
export function importRoots(d: Pick<Deps, "ctx" | "env">): string[] {
|
||||
const raw = d.env.STC_IMPORT_ROOTS;
|
||||
const list = raw ? raw.split(":").filter(Boolean) : [resolve(d.ctx.config.dataDir, "import-inbox")];
|
||||
return list.map((r) => resolve(r));
|
||||
}
|
||||
|
||||
export function ensureRoots(d: Pick<Deps, "ctx" | "env">): void {
|
||||
for (const r of importRoots(d)) { try { mkdirSync(r, { recursive: true }); } catch { /* reported when used */ } }
|
||||
}
|
||||
|
||||
/** Real path (symlinks followed) must lie inside the real path of one root. Throws OUTSIDE otherwise. */
|
||||
export async function assertAllowedPath(d: Pick<Deps, "ctx" | "env">, path: string): Promise<string> {
|
||||
let real: string;
|
||||
try { real = await realpath(resolve(path)); } catch { throw new Error(OUTSIDE); }
|
||||
for (const root of importRoots(d)) {
|
||||
const rr = await realpath(root).catch(() => null);
|
||||
if (rr && (real === rr || real.startsWith(rr + sep))) return real;
|
||||
}
|
||||
throw new Error(OUTSIDE);
|
||||
}
|
||||
|
||||
export const isGitUrl = (p: string) => /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(p);
|
||||
@@ -0,0 +1,114 @@
|
||||
import { copyFile, mkdir, readdir } from "node:fs/promises";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import type { Feature, RouteHandler } from "@stc/contracts";
|
||||
import { serveMedia } from "@stc/core";
|
||||
import { assertAllowedPath, importRoots, OUTSIDE } from "./roots";
|
||||
import { coverCacheDir, coursesDir, exists, isPlainId, managedCourse, saveManifest, type Deps } from "./context";
|
||||
|
||||
export const KINDS = ["packt-video", "code-repo", "pdf-deck", "video-folder"] as const;
|
||||
const OPTION_KEYS = ["title", "courseId", "attachTo", "media", "transcribe"] as const;
|
||||
const COVER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*\.jpg$/;
|
||||
const GIT_URL = /^(https?:\/\/|ssh:\/\/|git@)\S+$/;
|
||||
|
||||
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
||||
|
||||
export function importRoutes(deps: () => Deps): Feature["routes"] {
|
||||
/** Imports read arbitrary server paths, so every route needs the owner role. */
|
||||
const owner = (h: RouteHandler): RouteHandler => (req, rc) => {
|
||||
if (!rc.user) return json({ error: "Sign in required" }, 401);
|
||||
if (!rc.user.roles.includes("owner")) return json({ error: "Only the owner can import" }, 403);
|
||||
return h(req, rc);
|
||||
};
|
||||
|
||||
return {
|
||||
"/api/import/info": {
|
||||
GET: owner(() => {
|
||||
const d = deps();
|
||||
const dir = coursesDir(d);
|
||||
const served = d.ctx.config.courseDirs.some((c) => resolve(c) === dir);
|
||||
return json({ transcribe: Boolean(d.env.STC_TRANSCRIBE_URL), coursesDir: dir, coursesDirServed: served, importRoots: importRoots(d) });
|
||||
}),
|
||||
},
|
||||
"/api/import/jobs": {
|
||||
GET: owner(async () => {
|
||||
const jobs = (await deps().ctx.jobs.list()).filter((j) => j.kind.startsWith("import."));
|
||||
return json(jobs);
|
||||
}),
|
||||
POST: owner(async (req) => {
|
||||
const d = deps();
|
||||
let body: any;
|
||||
try { body = await req.json(); } catch { return json({ error: "Body must be JSON" }, 400); }
|
||||
const kind = String(body?.kind ?? "");
|
||||
const options: Record<string, unknown> = {};
|
||||
for (const k of OPTION_KEYS) if (body?.options?.[k] !== undefined && body.options[k] !== "") options[k] = body.options[k];
|
||||
if (kind === "transcribe") {
|
||||
if (!d.env.STC_TRANSCRIBE_URL) return json({ error: "STC_TRANSCRIBE_URL is not set" }, 409);
|
||||
if (typeof options.courseId !== "string") return json({ error: "courseId is required" }, 400);
|
||||
return json({ id: await d.ctx.jobs.enqueue("import.transcribe", { courseId: options.courseId }) }, 202);
|
||||
}
|
||||
if (!(KINDS as readonly string[]).includes(kind)) return json({ error: `kind must be one of ${KINDS.join(", ")}` }, 400);
|
||||
const path = typeof body?.path === "string" ? body.path.trim() : "";
|
||||
if (!path) return json({ error: "path is required" }, 400);
|
||||
const isGit = kind === "code-repo" && GIT_URL.test(path);
|
||||
if (!isGit) {
|
||||
if (!path.startsWith("/")) return json({ error: "Use an absolute path" }, 400);
|
||||
try { await assertAllowedPath(d, path); } catch { return json({ error: OUTSIDE }, 400); }
|
||||
}
|
||||
if ((kind === "code-repo" || kind === "pdf-deck") && typeof options.courseId !== "string") return json({ error: "courseId is required" }, 400);
|
||||
if (kind === "pdf-deck" && typeof options.attachTo !== "string") return json({ error: "attachTo is required" }, 400);
|
||||
return json({ id: await d.ctx.jobs.enqueue(`import.${kind}`, { kind, path, options }) }, 202);
|
||||
}),
|
||||
},
|
||||
"/api/import/courses": {
|
||||
GET: owner(() => {
|
||||
const d = deps();
|
||||
const base = coursesDir(d);
|
||||
const out = d.ctx.courses.list()
|
||||
.filter((c) => c.manifest && !relative(base, c.root).startsWith(".."))
|
||||
.map((c) => ({
|
||||
id: c.id, title: c.manifest!.title,
|
||||
modules: c.manifest!.modules.map((m) => ({ id: m.id, title: m.title, lessons: m.lessons.map((l) => ({ id: l.id, title: l.title })) })),
|
||||
}));
|
||||
return json(out);
|
||||
}),
|
||||
},
|
||||
"/api/import/covers/:courseId": {
|
||||
GET: owner(async (_req, rc) => {
|
||||
const d = deps();
|
||||
const id = rc.params.courseId!;
|
||||
if (!isPlainId(id)) return json({ error: "Invalid course id" }, 400);
|
||||
const c = d.ctx.courses.get(id);
|
||||
const names = (await readdir(coverCacheDir(d, id)).catch(() => [] as string[])).filter((n) => COVER_NAME.test(n)).sort();
|
||||
return json({ current: c?.manifest?.cover ?? null, candidates: names });
|
||||
}),
|
||||
POST: owner(async (req, rc) => {
|
||||
const d = deps();
|
||||
let body: any;
|
||||
try { body = await req.json(); } catch { return json({ error: "Body must be JSON" }, 400); }
|
||||
const name = String(body?.name ?? "");
|
||||
if (!COVER_NAME.test(name)) return json({ error: "Invalid candidate name" }, 400);
|
||||
if (!isPlainId(rc.params.courseId!)) return json({ error: "Invalid course id" }, 400);
|
||||
const src = join(coverCacheDir(d, rc.params.courseId!), name);
|
||||
if (!(await exists(src))) return json({ error: "No such candidate" }, 404);
|
||||
let course;
|
||||
try { course = await managedCourse(d, rc.params.courseId); } catch (e) { return json({ error: (e as Error).message }, 404); }
|
||||
await mkdir(join(course.dir, "media"), { recursive: true });
|
||||
await copyFile(src, join(course.dir, "media", "cover.jpg"));
|
||||
course.manifest.cover = "media/cover.jpg";
|
||||
await saveManifest(course.dir, course.manifest);
|
||||
await d.ctx.courses.reload(course.id);
|
||||
return json({ cover: "media/cover.jpg" });
|
||||
}),
|
||||
},
|
||||
"/api/import/covers/:courseId/:name": {
|
||||
GET: owner(async (req, rc) => {
|
||||
const name = rc.params.name!;
|
||||
if (!COVER_NAME.test(name)) return new Response("Not found", { status: 404 });
|
||||
if (!isPlainId(rc.params.courseId!)) return new Response("Not found", { status: 404 });
|
||||
const file = join(coverCacheDir(deps(), rc.params.courseId!), name);
|
||||
if (!(await exists(file))) return new Response("Not found", { status: 404 });
|
||||
return serveMedia(req, file);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Runs a command (no shell). Resolves with stdout; rejects with the stderr tail on a non-zero exit. */
|
||||
export async function run(cmd: string[], opts: { signal?: AbortSignal; env?: Record<string, string | undefined>; onLine?: (line: string) => void } = {}): Promise<string> {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
stdout: "pipe", stderr: "pipe", stdin: "ignore",
|
||||
env: { ...process.env, LC_ALL: "C.UTF-8", ...opts.env } as Record<string, string>,
|
||||
signal: opts.signal,
|
||||
});
|
||||
const out = (async () => {
|
||||
let text = "";
|
||||
let pending = "";
|
||||
const dec = new TextDecoder();
|
||||
for await (const chunk of proc.stdout) {
|
||||
const s = dec.decode(chunk, { stream: true });
|
||||
text += s;
|
||||
if (opts.onLine) {
|
||||
pending += s;
|
||||
const lines = pending.split("\n");
|
||||
pending = lines.pop()!;
|
||||
for (const l of lines) opts.onLine(l);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
})();
|
||||
const [stdout, stderr, code] = await Promise.all([out, new Response(proc.stderr).text(), proc.exited]);
|
||||
if (code !== 0) {
|
||||
const tail = stderr.trim().split("\n").slice(-6).join("\n");
|
||||
throw new Error(`${cmd[0]} exited with ${code}${tail ? `: ${tail}` : ""}`);
|
||||
}
|
||||
return stdout;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { ManifestSchema } from "@stc/course-format";
|
||||
|
||||
export type RawManifest = Record<string, any>;
|
||||
|
||||
export async function readRawManifest(courseDir: string): Promise<RawManifest> {
|
||||
return Bun.YAML.parse(await readFile(join(courseDir, "stcourse.yaml"), "utf8")) as RawManifest;
|
||||
}
|
||||
|
||||
/** Validates against the course-format schema, then writes stcourse.yaml via temp file + rename. */
|
||||
export async function writeManifest(courseDir: string, manifest: RawManifest): Promise<void> {
|
||||
const r = ManifestSchema.safeParse(manifest);
|
||||
if (!r.success) {
|
||||
throw new Error(`generated manifest is invalid: ${r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
||||
}
|
||||
const target = join(courseDir, "stcourse.yaml");
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, Bun.YAML.stringify(manifest, null, 2) + "\n");
|
||||
await rename(tmp, target);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { cp, mkdir, readdir, readFile, rm, 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;
|
||||
}
|
||||
|
||||
async function removeSymlinks(dir: string): Promise<void> {
|
||||
for (const e of await readdir(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isSymbolicLink()) await rm(p, { force: true });
|
||||
else if (e.isDirectory()) await removeSymlinks(p);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
} finally {
|
||||
await removeSymlinks(dest); // a zip can carry symlinks; none may survive extraction
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export function slugify(s: string): string {
|
||||
const slug = s
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
.replace(/-+$/g, "");
|
||||
return slug || "course";
|
||||
}
|
||||
|
||||
export function uniqueId(base: string, taken: (id: string) => boolean): string {
|
||||
if (!taken(base)) return base;
|
||||
for (let i = 2; ; i++) if (!taken(`${base}-${i}`)) return `${base}-${i}`;
|
||||
}
|
||||
|
||||
const NAMED: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
||||
export function decodeEntities(s: string): string {
|
||||
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) => {
|
||||
if (e[0] === "#") {
|
||||
const code = e[1]!.toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
|
||||
}
|
||||
return NAMED[e.toLowerCase()] ?? m;
|
||||
});
|
||||
}
|
||||
|
||||
export function stripTags(s: string): string {
|
||||
return decodeEntities(s.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
|
||||
export function naturalSort(list: string[]): string[] {
|
||||
return [...list].sort((a, b) => collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0));
|
||||
}
|
||||
|
||||
/** Lowercase alphanumeric words, for loose name matching. */
|
||||
export function words(s: string): string[] {
|
||||
return s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
|
||||
}
|
||||
/** Share of the words of `a` that also occur in `b`. */
|
||||
export function containment(a: string, b: string): number {
|
||||
const wa = words(a);
|
||||
if (wa.length === 0) return 0;
|
||||
const wb = new Set(words(b));
|
||||
return wa.filter((w) => wb.has(w)).length / wa.length;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { WebFeature } from "@stc/contracts";
|
||||
|
||||
/** Browser-safe entry (no server imports): apps/web imports this as "@stc/feature-import/web". */
|
||||
export const importWeb: WebFeature = {
|
||||
name: "import",
|
||||
routes: [{ path: "/admin/import", element: () => import("./web/ImportPage"), nav: { label: "Import", area: "admin" } }],
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { Pill, ProgressBar, SegmentedToggle } from "@stc/ui";
|
||||
import "./import.css";
|
||||
|
||||
type Kind = "packt-video" | "code-repo" | "pdf-deck" | "video-folder";
|
||||
interface Job { id: string; kind: string; status: "queued" | "running" | "done" | "failed"; progress: number; message: string | null; payload: any; result: any; error: string | null; createdAt: string }
|
||||
interface CourseInfo { id: string; title: string; modules: { id: string; title: string; lessons: { id: string; title: string }[] }[] }
|
||||
interface Info { importRoots: string[]; transcribe: boolean; coursesDir: string; coursesDirServed: boolean }
|
||||
|
||||
const KINDS: { value: Kind; label: string; hint: string; placeholder: string }[] = [
|
||||
{ value: "packt-video", label: "Packt video zip", hint: "A video package whose index.html lists the sections and clips.", placeholder: "/data/downloads/video_zip.zip" },
|
||||
{ value: "code-repo", label: "Code repository", hint: "A zip, a folder or a git URL. Folders such as S08 - Name attach to the matching section.", placeholder: "/data/downloads/course-main.zip or https://github.com/…" },
|
||||
{ value: "pdf-deck", label: "Slide PDF", hint: "Becomes a deck on a lesson or section. Its text is extracted for search.", placeholder: "/data/downloads/slides.pdf" },
|
||||
{ value: "video-folder", label: "Folder of videos", hint: "One lesson per video file, in natural file-name order.", placeholder: "/mnt/videos/my-course" },
|
||||
];
|
||||
const LABEL: Record<string, string> = { "import.packt-video": "Packt video", "import.code-repo": "Code repository", "import.pdf-deck": "Slide PDF", "import.video-folder": "Video folder", "import.transcribe": "Transcription" };
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw Object.assign(new Error(body.error ?? `HTTP ${r.status}`), { status: r.status });
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function CoverPicker({ courseId }: { courseId: string }) {
|
||||
const [data, setData] = useState<{ current: string | null; candidates: string[] } | null>(null);
|
||||
const [version, setVersion] = useState(0);
|
||||
useEffect(() => { api<typeof data>(`/api/import/covers/${courseId}`).then(setData).catch(() => setData(null)); }, [courseId, version]);
|
||||
if (!data || data.candidates.length === 0) return null;
|
||||
return (
|
||||
<details>
|
||||
<summary>Cover candidates</summary>
|
||||
<div className="imp-covers">
|
||||
{data.candidates.map((name) => (
|
||||
<div className="imp-cand" key={name}>
|
||||
<img src={`/api/import/covers/${courseId}/${name}`} alt={`Cover candidate ${name}`} loading="lazy" />
|
||||
<Pill onClick={async () => { await api(`/api/import/covers/${courseId}`, { method: "POST", body: JSON.stringify({ name }) }); setVersion((v) => v + 1); }}>Use as cover</Pill>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function JobRow({ job }: { job: Job }) {
|
||||
const src = job.payload?.path ? String(job.payload.path).split("/").filter(Boolean).pop() : job.payload?.courseId;
|
||||
const result = job.result;
|
||||
const warnings: string[] = result?.warnings ?? [];
|
||||
return (
|
||||
<li className="imp-job">
|
||||
<div className="imp-job-top">
|
||||
<span className="imp-job-kind">{LABEL[job.kind] ?? job.kind}</span>
|
||||
<span className="imp-job-src" title={job.payload?.path}>{src}</span>
|
||||
<span className="imp-status" data-s={job.status}>{job.status}</span>
|
||||
</div>
|
||||
<div className="imp-job-bar">
|
||||
<ProgressBar value={job.status === "done" ? 1 : job.progress} label={`${LABEL[job.kind] ?? job.kind} progress`} />
|
||||
<span className="imp-job-msg">{job.message}</span>
|
||||
</div>
|
||||
{job.error ? <div className="imp-err" role="alert">{job.error}</div> : null}
|
||||
{job.status === "done" && result?.skipped ? <div className="imp-job-msg">Skipped: {result.reason}</div> : null}
|
||||
{job.status === "done" && result?.courseId && !result.skipped ? (
|
||||
<div className="imp-job-msg">Course <a href={`/c/${result.courseId}`}>{result.courseId}</a>, restricted to the owner until you change its visibility.</div>
|
||||
) : null}
|
||||
{warnings.length ? (
|
||||
<details><summary>{warnings.length} warning{warnings.length > 1 ? "s" : ""}</summary><ul className="warn">{warnings.map((w, i) => <li key={i}>{w}</li>)}</ul></details>
|
||||
) : null}
|
||||
{job.status === "done" && (job.kind === "import.packt-video" || job.kind === "import.video-folder") && result?.courseId ? <CoverPicker courseId={result.courseId} /> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportPage() {
|
||||
const [info, setInfo] = useState<Info | null>(null);
|
||||
const [courses, setCourses] = useState<CourseInfo[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [denied, setDenied] = useState<string | null>(null);
|
||||
const [kind, setKind] = useState<Kind>("packt-video");
|
||||
const [path, setPath] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [courseId, setCourseId] = useState("");
|
||||
const [attachTo, setAttachTo] = useState("");
|
||||
const [transcribe, setTranscribe] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setJobs((await api<Job[]>("/api/import/jobs")).sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
|
||||
setCourses(await api<CourseInfo[]>("/api/import/courses"));
|
||||
} catch (e: any) {
|
||||
if (e.status === 401 || e.status === 403) setDenied(e.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api<Info>("/api/import/info").then(setInfo).catch((e) => { if (e.status === 401 || e.status === 403) setDenied(e.message); });
|
||||
void refresh();
|
||||
const t = setInterval(refresh, 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [refresh]);
|
||||
|
||||
const spec = KINDS.find((k) => k.value === kind)!;
|
||||
const needsCourse = kind === "code-repo" || kind === "pdf-deck";
|
||||
const course = courses.find((c) => c.id === courseId);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await api("/api/import/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, path, options: { title, courseId: needsCourse ? courseId : undefined, attachTo: kind === "pdf-deck" ? attachTo : undefined, transcribe: transcribe || undefined } }),
|
||||
});
|
||||
setPath(""); setTitle("");
|
||||
await refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (denied) {
|
||||
return <div className="imp"><h1 className="imp-h">Import</h1><p className="imp-sub" role="alert">{denied === "Sign in required" ? "Sign in as the owner to import courses." : "Only the owner can import courses."}</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="imp">
|
||||
<h1 className="imp-h">Import</h1>
|
||||
<p className="imp-sub">Turn material you own into a course. Originals are only read; the new course is restricted until you change its visibility.</p>
|
||||
{info && !info.coursesDirServed ? (
|
||||
<p className="imp-note" role="status">The server is not scanning <code>{info.coursesDir}</code>. Add it to <code>STC_COURSE_DIRS</code> to see imported courses.</p>
|
||||
) : null}
|
||||
|
||||
<form className="imp-form" onSubmit={submit}>
|
||||
<SegmentedToggle label="Source type" options={KINDS.map((k) => ({ value: k.value, label: k.label }))} value={kind} onChange={(v) => setKind(v as Kind)} />
|
||||
<label className="imp-field">Path on the server
|
||||
<input type="text" className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder={spec.placeholder} required />
|
||||
<span className="imp-hint">{spec.hint}</span>
|
||||
{info ? <span className="imp-hint">Allowed folders: {info.importRoots.map((r) => <code key={r}>{r} </code>)}(set <code>STC_IMPORT_ROOTS</code>; git URLs are exempt)</span> : null}
|
||||
</label>
|
||||
{!needsCourse ? (
|
||||
<label className="imp-field">Course title (optional)
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Taken from the source when empty" />
|
||||
</label>
|
||||
) : (
|
||||
<div className="imp-row">
|
||||
<label className="imp-field">Add to course
|
||||
<select value={courseId} onChange={(e) => { setCourseId(e.target.value); setAttachTo(""); }} required>
|
||||
<option value="">Choose an imported course</option>
|
||||
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{kind === "pdf-deck" ? (
|
||||
<label className="imp-field">Attach deck to
|
||||
<select value={attachTo} onChange={(e) => setAttachTo(e.target.value)} required disabled={!course}>
|
||||
<option value="">Choose a section or lesson</option>
|
||||
{course?.modules.map((m) => (
|
||||
<optgroup key={m.id} label={m.title}>
|
||||
<option value={m.id}>Whole section: {m.title}</option>
|
||||
{m.lessons.map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{info?.transcribe && (kind === "packt-video" || kind === "video-folder") ? (
|
||||
<label className="imp-check"><input type="checkbox" checked={transcribe} onChange={(e) => setTranscribe(e.target.checked)} />Transcribe the videos afterwards</label>
|
||||
) : null}
|
||||
<div className="imp-actions">
|
||||
<Pill variant="primary" type="submit" disabled={busy || !path}>Start import</Pill>
|
||||
{error ? <span className="imp-err" role="alert">{error}</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h2 className="imp-sec">Jobs</h2>
|
||||
{jobs.length === 0 ? <p className="imp-empty">No imports yet.</p> : <ul className="imp-jobs">{jobs.map((j) => <JobRow key={j.id} job={j} />)}</ul>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
.imp { max-width: 880px; margin: 0 auto; padding-bottom: 80px; }
|
||||
.imp-h { font: 600 24px/1.2 var(--sans); color: var(--head); margin: 0 0 4px; }
|
||||
.imp-sub { margin: 0 0 28px; color: var(--muted); font-size: 13.5px; }
|
||||
.imp-sec { font: 600 14px var(--sans); color: var(--muted); margin: 34px 0 10px; }
|
||||
.imp-note { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--line2); border-radius: 8px; background: var(--side); font-size: 13px; color: var(--muted); }
|
||||
.imp-note code, .imp-hint code { font-size: 12px; color: var(--text); }
|
||||
.imp-form { display: grid; gap: 16px; padding: 20px; border: 1px solid var(--line); border-radius: 10px; background: var(--side); }
|
||||
.imp-field { display: grid; gap: 6px; font-size: 12.5px; color: var(--muted); }
|
||||
.imp-field input[type="text"], .imp-field select { height: 36px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 8px; background: var(--ground); color: var(--text); font-size: 13.5px; width: 100%; }
|
||||
.imp-field input.mono { font-family: var(--mono); font-size: 12.5px; }
|
||||
.imp-field input::placeholder { color: var(--faint); }
|
||||
.imp-hint { font-size: 12px; color: var(--faint); }
|
||||
.imp-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.imp-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
|
||||
.imp-check input { accent-color: var(--accent); }
|
||||
.imp-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.imp-err { color: var(--bad); font-size: 13px; }
|
||||
.imp-jobs { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.imp-job { display: grid; gap: 8px; padding: 14px 2px; border-bottom: 1px solid var(--line); font-size: 13.5px; }
|
||||
.imp-job-top { display: flex; align-items: baseline; gap: 12px; }
|
||||
.imp-job-kind { font-weight: 600; color: var(--head); }
|
||||
.imp-job-src { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); font-size: 12.5px; }
|
||||
.imp-status { font-size: 12px; font-weight: 500; color: var(--muted); }
|
||||
.imp-status[data-s="done"] { color: var(--ok); }
|
||||
.imp-status[data-s="failed"] { color: var(--bad); }
|
||||
.imp-status[data-s="running"] { color: var(--accent); }
|
||||
.imp-job-bar { display: flex; align-items: center; gap: 12px; }
|
||||
.imp-job-msg { font-size: 12.5px; color: var(--faint); }
|
||||
.imp-job-bar .imp-job-msg { flex: none; width: 200px; text-align: right; }
|
||||
.imp-job details { font-size: 12.5px; color: var(--muted); }
|
||||
.imp-job summary { cursor: pointer; }
|
||||
.imp-job ul.warn { margin: 6px 0 0; padding-left: 18px; }
|
||||
.imp-covers { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||||
.imp-cand { display: grid; gap: 6px; justify-items: start; }
|
||||
.imp-cand img { width: 160px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 6px; border: 1px solid var(--line2); background: var(--raised); }
|
||||
.imp-cand[aria-current="true"] img { border-color: var(--accent); }
|
||||
.imp-empty { color: var(--faint); font-size: 13px; padding: 14px 2px; }
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdir, readdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { folderName, folderNumber, importCodeRepo } from "../src/jobs/code-repo";
|
||||
import { importPacktVideo } from "../src/jobs/packt-video";
|
||||
import { makeEnv } from "./harness";
|
||||
import { makePacktZip, makeRepoZip, tmp } from "./fixtures";
|
||||
|
||||
test("folder names split into number and name", () => {
|
||||
expect(folderNumber("S08 - Containerizing Applications")).toBe(8);
|
||||
expect(folderName("S08 - Containerizing Applications")).toBe("Containerizing Applications");
|
||||
expect(folderNumber("Section 3: Images")).toBe(3);
|
||||
expect(folderNumber("Bonus Extras")).toBeNull();
|
||||
});
|
||||
|
||||
async function target() {
|
||||
const env = await makeEnv();
|
||||
const r = await importPacktVideo(env.deps, { path: await makePacktZip(await tmp()), options: { title: "Docker Test" } }, env.jc);
|
||||
return { env, id: r.courseId };
|
||||
}
|
||||
|
||||
test("zip repo: folders attach to modules by number, PDFs become deck and attachment", async () => {
|
||||
const { env, id } = await target();
|
||||
const zip = await makeRepoZip(await tmp());
|
||||
const res = await importCodeRepo(env.deps, { kind: "code-repo", path: zip, options: { courseId: id } }, env.jc);
|
||||
const c = env.deps.ctx.courses.get(id)!;
|
||||
expect(c.status).toBe("ok");
|
||||
expect(c.manifest!.modules[0]!.code).toEqual({ dir: "code/S01 - Intro to Docker" });
|
||||
expect(c.manifest!.modules[1]!.code).toEqual({ dir: "code/S02 - Images (Sample)" });
|
||||
expect(env.deps.ctx.courses.resolvePath(id, "code/S02 - Images (Sample)/app/main.py")).toBeTruthy();
|
||||
expect(env.deps.ctx.courses.resolvePath(id, "code/S02 - Images (Sample)/Ünïcode notes.txt")).toBeTruthy();
|
||||
expect(c.manifest!.decks).toEqual([{ id: "d-slides-intro", file: "media/decks/Slides_Intro.pdf", title: "Slides Intro", attachTo: "m1" }]);
|
||||
expect(c.manifest!.attachments).toEqual([{ file: "media/files/Docker_CheatSheet.pdf", title: "Docker CheatSheet", module: "m1" }]);
|
||||
expect(await readdir(join(env.dataDir, "cache/decks", id, "d-slides-intro"))).toContain("p-003.jpg");
|
||||
expect(await readdir(join(c.root, "code/S01 - Intro to Docker"))).toEqual([]); // PDFs are not duplicated into code
|
||||
expect(res.warnings).toEqual(['folder "Bonus Extras" matches no module and was skipped']);
|
||||
});
|
||||
|
||||
test("an unnumbered folder matches a module by name; symlinks are not copied; the input is untouched", async () => {
|
||||
const { env, id } = await target();
|
||||
const top = join(await tmp(), "repo");
|
||||
await mkdir(join(top, "Images Sample"), { recursive: true });
|
||||
await writeFile(join(top, "Images Sample", "a.txt"), "a");
|
||||
await symlink("/etc/passwd", join(top, "Images Sample", "link"));
|
||||
const res = await importCodeRepo(env.deps, { path: top, options: { courseId: id } }, env.jc);
|
||||
expect(res.warnings).toEqual([]);
|
||||
const c = env.deps.ctx.courses.get(id)!;
|
||||
expect(c.manifest!.modules[1]!.code).toEqual({ dir: "code/Images Sample" });
|
||||
expect(await readdir(join(c.root, "code/Images Sample"))).toEqual(["a.txt"]);
|
||||
expect(await readdir(join(top, "Images Sample"))).toContain("link");
|
||||
});
|
||||
|
||||
test("rejects unmanaged courses and bad git URLs without running git", async () => {
|
||||
const { env, id } = await target();
|
||||
await expect(importCodeRepo(env.deps, { path: "/x", options: { courseId: "sample" } }, env.jc)).rejects.toThrow(/only edits courses it created/);
|
||||
await expect(importCodeRepo(env.deps, { path: "--upload-pack=touch /tmp/pwn", options: { courseId: id } }, env.jc)).rejects.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// Tiny synthetic inputs for the importer tests. Nothing here is real course material.
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { run } from "../src/util/exec";
|
||||
|
||||
// PyMuPDF is needed for PDF tests: set STC_PYTHON, or this machine's venv is used when present.
|
||||
const VENV_PY = "/home/user/.venvs/pdf/bin/python";
|
||||
export const PYTHON = process.env.STC_PYTHON ?? (existsSync(VENV_PY) ? VENV_PY : "python3");
|
||||
|
||||
export async function tmp(prefix = "stc-imp-"): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export async function makeVideo(path: string, seconds = 2): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", `testsrc=duration=${seconds}:size=160x90:rate=10`, "-f", "lavfi", "-i", `sine=frequency=440:duration=${seconds}`,
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", path]);
|
||||
}
|
||||
|
||||
/** Minimal valid PDF with one line of text per page. */
|
||||
export function pdfBytes(pages: string[]): Uint8Array {
|
||||
const objs: string[] = [];
|
||||
const kids = pages.map((_, i) => `${4 + i * 2} 0 R`).join(" ");
|
||||
objs.push("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
objs.push(`<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`);
|
||||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
|
||||
pages.forEach((text, i) => {
|
||||
const content = `BT /F1 24 Tf 40 150 Td (${text.replace(/[()\\]/g, "")}) Tj ET`;
|
||||
objs.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 200] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + i * 2} 0 R >>`);
|
||||
objs.push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`);
|
||||
});
|
||||
let out = "%PDF-1.4\n";
|
||||
const offsets: number[] = [];
|
||||
objs.forEach((o, i) => { offsets.push(out.length); out += `${i + 1} 0 obj\n${o}\nendobj\n`; });
|
||||
const xref = out.length;
|
||||
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n` + offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("");
|
||||
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return new TextEncoder().encode(out);
|
||||
}
|
||||
|
||||
export async function makePdf(path: string, pages = ["First slide alpha", "Second slide beta"]): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, pdfBytes(pages));
|
||||
}
|
||||
|
||||
export async function zipDir(srcDir: string, zipPath: string): Promise<void> {
|
||||
await run([PYTHON, "-I", "-c",
|
||||
"import sys,os,zipfile\nsrc,out=sys.argv[1:3]\nz=zipfile.ZipFile(out,'w',zipfile.ZIP_STORED)\nfor r,d,f in os.walk(src):\n for n in f:\n p=os.path.join(r,n); z.write(p, os.path.relpath(p,src))\nz.close()",
|
||||
srcDir, zipPath]);
|
||||
}
|
||||
|
||||
const li = (t: string) => `<li>${t}</li>`;
|
||||
const clipHtml = (s: number, m: number, title: string) => `
|
||||
<li>
|
||||
<img src="images/image.jpg" class="image-holder"/>
|
||||
<h1>${s}.${m} ${title}</h1>
|
||||
<span>In this video, ${title}, you'll learn it.</span>
|
||||
<div id="video${s}_${m}" class="btn btn-small btn-primary watch" data-video="videos/video${s}_${m}.mp4" data-poster="images/videoImage.jpg">Watch Video</div>
|
||||
<a href="videos/video${s}_${m}.mp4" class="btn download">Download</a>
|
||||
</li>`;
|
||||
|
||||
export const PACKT_SECTIONS = [
|
||||
{ title: "Intro to Docker", clips: ["Course Introduction", "Docker & Friends"] },
|
||||
{ title: "Images (Sample)", clips: ["Topic Introduction - Images"] },
|
||||
];
|
||||
|
||||
/** Mimics the real package layout: summary lists without "Video N.M:" prefixes, then chapter blocks. */
|
||||
export function packtIndexHtml(opts: { dropChapter?: number } = {}): string {
|
||||
const summary = PACKT_SECTIONS.map((s, i) => `
|
||||
<li>
|
||||
<img src="images/image.jpg" class="image-holder"/>
|
||||
<h1>Section ${i + 1}: ${s.title}</h1>
|
||||
<p>Video ${i + 1}.1: ${s.clips[0]}</p>
|
||||
<ul>${s.clips.map(li).join("\n")}</ul>
|
||||
<div class="btn btn-primary browse" id="#content-${i + 1}">Browse Videos</div>
|
||||
</li>`).join("");
|
||||
const chapters = PACKT_SECTIONS.map((s, i) => i + 1 === opts.dropChapter ? "" : `
|
||||
<div class="chapter" id="content-${i + 1}" style="display: none;">
|
||||
<ul class="move">${s.clips.map((c, j) => clipHtml(i + 1, j + 1, c)).join("")}
|
||||
</ul>
|
||||
</div>`).join("");
|
||||
return `<!doctype html><html><head><title>Fake</title></head><body>
|
||||
<div class="intro"><div class="inner"><h1>Fake Docker Course</h1></div></div>
|
||||
<div id="content-main"><ul class="move">${summary}</ul>${chapters}</div></body></html>`;
|
||||
}
|
||||
|
||||
export async function makePacktTree(root: string): Promise<void> {
|
||||
await mkdir(join(root, "package", "videos"), { recursive: true });
|
||||
await writeFile(join(root, "package", "index.html"), packtIndexHtml());
|
||||
await writeFile(join(root, "Video Package_User Guide.pdf"), "not used");
|
||||
await makeVideo(join(root, "package/videos/video1_1.mp4"), 2);
|
||||
for (const n of ["video1_2", "video2_1"]) await Bun.write(join(root, "package/videos", `${n}.mp4`), Bun.file(join(root, "package/videos/video1_1.mp4")));
|
||||
}
|
||||
|
||||
export async function makePacktZip(dir: string): Promise<string> {
|
||||
const tree = join(dir, "packt-tree");
|
||||
await makePacktTree(tree);
|
||||
const zip = join(dir, "video_zip.zip");
|
||||
await zipDir(tree, zip);
|
||||
return zip;
|
||||
}
|
||||
|
||||
/** Companion repo with folders that have spaces; S01 has a deck and a cheat sheet, S02 has code. */
|
||||
export async function makeRepoTree(root: string): Promise<string> {
|
||||
const top = join(root, "Course-Repo-main");
|
||||
await makePdf(join(top, "S01 - Intro to Docker", "Slides_Intro.pdf"), ["Repo deck one", "Repo deck two", "Repo deck three"]);
|
||||
await makePdf(join(top, "S01 - Intro to Docker", "Docker_CheatSheet.pdf"), ["cheats"]);
|
||||
await mkdir(join(top, "S02 - Images (Sample)", "app"), { recursive: true });
|
||||
await writeFile(join(top, "S02 - Images (Sample)", "app", "main.py"), "print('hi')\n");
|
||||
await writeFile(join(top, "S02 - Images (Sample)", "Ünïcode notes.txt"), "notes\n");
|
||||
await mkdir(join(top, "Bonus Extras"), { recursive: true });
|
||||
await writeFile(join(top, "Bonus Extras", "x.txt"), "x");
|
||||
await writeFile(join(top, "README.md"), "# repo\n");
|
||||
return top;
|
||||
}
|
||||
|
||||
export async function makeRepoZip(dir: string): Promise<string> {
|
||||
const tree = join(dir, "repo-tree");
|
||||
await makeRepoTree(tree);
|
||||
const zip = join(dir, "repo.zip");
|
||||
await zipDir(tree, zip);
|
||||
return zip;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { tmpdir } from "node:os";
|
||||
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: { STC_IMPORT_ROOTS: tmpdir(), ...env }, tools: { ffmpeg: "ffmpeg", ffprobe: "ffprobe" }, python: PYTHON }, dataDir, queued, jc, progress };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { openSource } from "../src/util/source";
|
||||
import { PYTHON, tmp } from "./fixtures";
|
||||
import { run } from "../src/util/exec";
|
||||
|
||||
/** Builds a zip by hand with traversal, absolute and symlink entries. */
|
||||
async function hostileZip(path: string) {
|
||||
await run([PYTHON, "-I", "-c", `
|
||||
import sys, zipfile, stat
|
||||
z = zipfile.ZipFile(sys.argv[1], 'w')
|
||||
z.writestr('../../evil-up.txt', 'x')
|
||||
z.writestr('/tmp/stc-evil-abs.txt', 'x')
|
||||
z.writestr('ok/fine.txt', 'fine')
|
||||
i = zipfile.ZipInfo('ok/link'); i.create_system = 3; i.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
z.writestr(i, '/etc')
|
||||
i2 = zipfile.ZipInfo('ok/link/pwned.txt'); z.writestr(i2, 'x')
|
||||
z.close()`, path]);
|
||||
}
|
||||
|
||||
test("hostile zip entries never write outside the extraction dir", async () => {
|
||||
const base = await tmp("stc-zip-");
|
||||
await hostileZip(join(base, "h.zip"));
|
||||
const src = await openSource(join(base, "h.zip"));
|
||||
const dest = join(base, "a", "b", "out");
|
||||
await src.extract(src.entries, dest).catch(() => {}); // unzip may warn; what matters is where files land
|
||||
expect(existsSync(join(base, "evil-up.txt"))).toBe(false);
|
||||
expect(existsSync(join(base, "a", "evil-up.txt"))).toBe(false);
|
||||
expect(existsSync(join(base, "..", "evil-up.txt"))).toBe(false);
|
||||
expect(existsSync("/tmp/stc-evil-abs.txt")).toBe(false);
|
||||
expect(existsSync("/etc/pwned.txt")).toBe(false);
|
||||
expect(await Bun.file(join(dest, "ok/fine.txt")).text()).toBe("fine");
|
||||
const left: string[] = [];
|
||||
const walk = async (d: string) => { for (const e of await readdir(d, { withFileTypes: true })) { if (e.isSymbolicLink()) left.push(e.name); else if (e.isDirectory()) await walk(join(d, e.name)); } };
|
||||
await walk(dest);
|
||||
expect(left).toEqual([]); // the symlink entry is dropped
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { extractFrame, probeDuration, putFile } from "../src/media";
|
||||
import { renderPdf } from "../src/pdf";
|
||||
import { makePdf, makeVideo, PYTHON, tmp } from "./fixtures";
|
||||
|
||||
const tools = { ffmpeg: "ffmpeg", ffprobe: "ffprobe" };
|
||||
|
||||
test("duration and frame extraction on a tiny clip", async () => {
|
||||
const d = await tmp();
|
||||
await makeVideo(join(d, "a.mp4"), 2);
|
||||
expect(await probeDuration(tools, join(d, "a.mp4"))).toBe(2);
|
||||
await extractFrame(tools, join(d, "a.mp4"), join(d, "out", "f.jpg"));
|
||||
expect((await stat(join(d, "out", "f.jpg"))).size).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test("putFile copies and links without touching the original", async () => {
|
||||
const d = await tmp();
|
||||
await makeVideo(join(d, "sp ace", "ö.mp4"), 1);
|
||||
const before = await readFile(join(d, "sp ace", "ö.mp4"));
|
||||
await putFile(join(d, "sp ace", "ö.mp4"), join(d, "c", "x.mp4"), "copy");
|
||||
await putFile(join(d, "sp ace", "ö.mp4"), join(d, "l", "x.mp4"), "link");
|
||||
expect(Buffer.compare(await readFile(join(d, "c", "x.mp4")), before)).toBe(0);
|
||||
expect((await stat(join(d, "l", "x.mp4"))).ino).toBe((await stat(join(d, "sp ace", "ö.mp4"))).ino);
|
||||
});
|
||||
|
||||
test("renderPdf writes page images and text", async () => {
|
||||
const d = await tmp();
|
||||
await makePdf(join(d, "deck.pdf"), ["Hello alpha", "Second beta"]);
|
||||
const seen: number[] = [];
|
||||
const r = await renderPdf(PYTHON, join(d, "deck.pdf"), join(d, "out"), { onPage: (p) => seen.push(p) });
|
||||
expect(r.pages).toBe(2);
|
||||
expect(seen).toEqual([1, 2]);
|
||||
expect((await stat(join(d, "out", "p-002.jpg"))).size).toBeGreaterThan(100);
|
||||
const text = JSON.parse(await readFile(join(d, "out", "text.json"), "utf8"));
|
||||
expect(text[0].text).toContain("Hello alpha");
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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(c.root)).sort()).toEqual(["media", "stcourse.yaml"]); // no extraction leftovers
|
||||
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/);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { importCodeRepo } from "../src/jobs/code-repo";
|
||||
import { importPdfDeck } from "../src/jobs/pdf-deck";
|
||||
import { importVideoFolder } from "../src/jobs/video-folder";
|
||||
import { importPacktVideo } from "../src/jobs/packt-video";
|
||||
import { makeEnv } from "./harness";
|
||||
import { makePdf, tmp } from "./fixtures";
|
||||
|
||||
test("every job re-checks its path against the roots", async () => {
|
||||
const inbox = await tmp("stc-inbox-");
|
||||
const outside = await tmp("stc-outside-");
|
||||
await makePdf(join(outside, "a.pdf"));
|
||||
await symlink(outside, join(inbox, "link"));
|
||||
const env = await makeEnv({ STC_IMPORT_ROOTS: inbox });
|
||||
const msg = /outside the allowed import folders/;
|
||||
await expect(importVideoFolder(env.deps, { path: outside }, env.jc)).rejects.toThrow(msg);
|
||||
await expect(importVideoFolder(env.deps, { path: join(inbox, "link") }, env.jc)).rejects.toThrow(msg);
|
||||
await expect(importPacktVideo(env.deps, { path: join(outside, "x.zip") }, env.jc)).rejects.toThrow(msg);
|
||||
await expect(importPdfDeck(env.deps, { path: join(outside, "a.pdf"), options: {} }, env.jc)).rejects.toThrow(msg);
|
||||
await expect(importCodeRepo(env.deps, { path: outside, options: { courseId: "x" } }, env.jc)).rejects.toThrow(/created|outside/);
|
||||
await mkdir(join(inbox, "empty"));
|
||||
await writeFile(join(inbox, "empty", "n.txt"), "");
|
||||
await expect(importVideoFolder(env.deps, { path: join(inbox, "empty") }, env.jc)).rejects.toThrow(/no video files/); // allowed, then fails on content
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { expect } from "bun:test";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AuthUser, ServerContext } from "@stc/contracts";
|
||||
import { createApp, createJobQueue } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { importFeature } from "../src";
|
||||
import { mkdir, stat, symlink } from "node:fs/promises";
|
||||
import { makeVideo, tmp } from "./fixtures";
|
||||
|
||||
const owner: AuthUser = { id: "o", email: "o@x", name: "O", roles: ["owner"], groups: [] };
|
||||
const learner: AuthUser = { id: "l", email: "l@x", name: "L", roles: ["learner"], groups: [] };
|
||||
|
||||
async function setup(db: any, env: Record<string, string | undefined> = {}) {
|
||||
const dataDir = await tmp("stc-data-");
|
||||
const courses = await createCourseRepository([join(dataDir, "courses")]);
|
||||
const ctx = {
|
||||
config: { port: 0, databaseUrl: "", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "" },
|
||||
db, courses,
|
||||
auth: { userFromRequest: async (req: Request) => ({ owner, learner }[req.headers.get("x-user") ?? ""] ?? null) },
|
||||
} as unknown as ServerContext;
|
||||
const feature = importFeature(ctx, { env: { STC_PYTHON: process.env.STC_PYTHON, STC_IMPORT_ROOTS: tmpdir(), ...env } });
|
||||
const queue = createJobQueue(db, feature.jobs!, { pollMs: 10 });
|
||||
ctx.jobs = queue;
|
||||
const app = createApp(ctx, [feature]);
|
||||
const call = (path: string, init: RequestInit & { as?: string; json?: unknown } = {}) =>
|
||||
app.fetch(new Request(`http://x${path}`, {
|
||||
method: init.method, headers: { ...(init.as ? { "x-user": init.as } : {}), ...(init.json !== undefined ? { "content-type": "application/json" } : {}) },
|
||||
body: init.json !== undefined ? JSON.stringify(init.json) : undefined,
|
||||
}));
|
||||
return { ctx, queue, call, dataDir };
|
||||
}
|
||||
const until = async (f: () => Promise<boolean>) => { for (let i = 0; i < 600; i++) { if (await f()) return; await Bun.sleep(20); } throw new Error("timeout"); };
|
||||
|
||||
forEachDb("only the owner reaches the import API", async (db) => {
|
||||
const { call } = await setup(db);
|
||||
expect((await call("/api/import/info")).status).toBe(401);
|
||||
expect((await call("/api/import/info", { as: "learner" })).status).toBe(403);
|
||||
expect((await call("/api/import/jobs", { as: "learner", method: "POST", json: {} })).status).toBe(403);
|
||||
const r = await call("/api/import/info", { as: "owner" });
|
||||
expect(await r.json()).toMatchObject({ transcribe: false, coursesDirServed: true });
|
||||
});
|
||||
|
||||
forEachDb("job creation validates input", async (db) => {
|
||||
const { call } = await setup(db);
|
||||
const post = async (json: unknown) => call("/api/import/jobs", { as: "owner", method: "POST", json });
|
||||
expect((await post({ kind: "nope", path: "/tmp" })).status).toBe(400);
|
||||
expect((await post({ kind: "video-folder" })).status).toBe(400);
|
||||
expect((await post({ kind: "video-folder", path: "/definitely/not/here" })).status).toBe(400);
|
||||
expect((await post({ kind: "video-folder", path: "relative" })).status).toBe(400);
|
||||
expect((await post({ kind: "pdf-deck", path: "/tmp", options: { courseId: "x" } })).status).toBe(400);
|
||||
expect((await post({ kind: "code-repo", path: "/tmp" })).status).toBe(400);
|
||||
expect((await post({ kind: "transcribe", options: { courseId: "x" } })).status).toBe(409);
|
||||
});
|
||||
|
||||
forEachDb("a folder import runs through the real queue, then cover candidates can be chosen", async (db) => {
|
||||
const { call, queue, dataDir } = await setup(db);
|
||||
queue.start();
|
||||
try {
|
||||
const src = join(await tmp(), "Queue Videos");
|
||||
await makeVideo(join(src, "1 - a.mp4"), 1);
|
||||
await makeVideo(join(src, "2 - b.mp4"), 1);
|
||||
const r = await call("/api/import/jobs", { as: "owner", method: "POST", json: { kind: "video-folder", path: src, options: { title: "Queued", bogus: 1 } } });
|
||||
expect(r.status).toBe(202);
|
||||
const { id } = (await r.json()) as { id: string };
|
||||
await until(async () => ["done", "failed"].includes((await queue.get(id))!.status));
|
||||
const job = (await queue.get(id))!;
|
||||
expect(job.error).toBeNull();
|
||||
expect(job.status).toBe("done");
|
||||
expect(job.result).toMatchObject({ courseId: "queued", warnings: [] });
|
||||
expect(job.payload).toEqual({ kind: "video-folder", path: src, options: { title: "Queued" } });
|
||||
|
||||
const list = (await (await call("/api/import/jobs", { as: "owner" })).json()) as any[];
|
||||
expect(list.map((j) => j.id)).toContain(id);
|
||||
const courses = (await (await call("/api/import/courses", { as: "owner" })).json()) as any[];
|
||||
expect(courses).toEqual([{ id: "queued", title: "Queued", modules: [{ id: "m1", title: "Videos", lessons: [{ id: "l1", title: "a" }, { id: "l2", title: "b" }] }] }]);
|
||||
|
||||
const covers = (await (await call("/api/import/covers/queued", { as: "owner" })).json()) as any;
|
||||
expect(covers.current).toBe("media/cover.jpg");
|
||||
expect(covers.candidates).toEqual(["frame-1.jpg", "frame-2.jpg"]);
|
||||
const img = await call("/api/import/covers/queued/frame-2.jpg", { as: "owner" });
|
||||
expect(img.headers.get("content-type")).toBe("image/jpeg");
|
||||
expect((await call("/api/import/covers/queued/frame-2.jpg")).status).toBe(401);
|
||||
expect((await call("/api/import/covers/queued/..%2F..%2Fx.jpg", { as: "owner" })).status).toBe(404);
|
||||
const set = await call("/api/import/covers/queued", { as: "owner", method: "POST", json: { name: "frame-2.jpg" } });
|
||||
expect(set.status).toBe(200);
|
||||
expect((await call("/api/import/covers/queued", { as: "owner", method: "POST", json: { name: "../evil.jpg" } })).status).toBe(400);
|
||||
expect(await Bun.file(join(dataDir, "courses/queued/media/cover.jpg")).exists()).toBe(true);
|
||||
} finally { await queue.stop(); }
|
||||
});
|
||||
|
||||
forEachDb("paths must stay inside the import roots", async (db) => {
|
||||
const inbox = await tmp("stc-inbox-");
|
||||
const outside = await tmp("stc-outside-");
|
||||
await mkdir(join(inbox, "ok"));
|
||||
await symlink(outside, join(inbox, "escape"));
|
||||
const { call, ctx } = await setup(db, { STC_IMPORT_ROOTS: inbox });
|
||||
const post = (path: string) => call("/api/import/jobs", { as: "owner", method: "POST", json: { kind: "video-folder", path } });
|
||||
const bad = await post(outside);
|
||||
expect(bad.status).toBe(400);
|
||||
expect(await bad.json()).toEqual({ error: "path is outside the allowed import folders" });
|
||||
expect((await post(join(inbox, "..", "x"))).status).toBe(400);
|
||||
expect((await post(join(inbox, "escape"))).status).toBe(400); // symlink out of the root
|
||||
expect((await post("/etc")).status).toBe(400);
|
||||
expect((await post(join(inbox, "ok"))).status).toBe(202);
|
||||
const info = (await (await call("/api/import/info", { as: "owner" })).json()) as any;
|
||||
expect(info.importRoots).toEqual([inbox]);
|
||||
void ctx;
|
||||
});
|
||||
|
||||
forEachDb("the default root is created under dataDir", async (db) => {
|
||||
const { dataDir } = await setup(db, { STC_IMPORT_ROOTS: undefined });
|
||||
expect((await stat(join(dataDir, "import-inbox"))).isDirectory()).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterAll, expect, test } from "bun:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { importPacktVideo } from "../src/jobs/packt-video";
|
||||
import { segmentsToMarkdown, transcribeCourse } from "../src/jobs/transcribe";
|
||||
import { makeEnv } from "./harness";
|
||||
import { makePacktZip, tmp } from "./fixtures";
|
||||
|
||||
let calls = 0;
|
||||
let auth: string | null = null;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
calls++;
|
||||
auth = req.headers.get("authorization");
|
||||
const form = await req.formData();
|
||||
const f = form.get("file") as File;
|
||||
if (!f || f.size < 100 || form.get("model") !== "whisper-1") return new Response("bad", { status: 400 });
|
||||
return Response.json({ text: "hello world", segments: [{ start: 0, end: 2, text: " hello" }, { start: 35, end: 40, text: " world" }] });
|
||||
},
|
||||
});
|
||||
afterAll(() => server.stop(true));
|
||||
|
||||
test("segments are grouped into timestamped paragraphs", () => {
|
||||
expect(segmentsToMarkdown("T", [{ start: 0, end: 1, text: "a" }, { start: 31, end: 32, text: "b" }, { start: 33, end: 34, text: "c" }], "")).toBe("# T\n\n[0:00] a\n\n[0:31] b c\n");
|
||||
});
|
||||
|
||||
test("skipped when STC_TRANSCRIBE_URL is unset", async () => {
|
||||
const env = await makeEnv();
|
||||
const r = await transcribeCourse(env.deps, { courseId: "x" }, env.jc);
|
||||
expect(r).toMatchObject({ skipped: true, transcribed: 0 });
|
||||
});
|
||||
|
||||
test("transcribes video lessons through a Whisper-compatible endpoint", async () => {
|
||||
const env = await makeEnv({ STC_TRANSCRIBE_URL: `http://127.0.0.1:${server.port}/v1/audio/transcriptions`, STC_TRANSCRIBE_KEY: "k" });
|
||||
const imp = await importPacktVideo(env.deps, { path: await makePacktZip(await tmp()), options: {} }, env.jc);
|
||||
const r = await transcribeCourse(env.deps, { courseId: imp.courseId }, env.jc);
|
||||
expect(r.warnings).toEqual([]);
|
||||
expect(r.transcribed).toBe(3);
|
||||
expect(calls).toBe(3);
|
||||
expect(auth).toBe("Bearer k");
|
||||
const c = env.deps.ctx.courses.get(imp.courseId)!;
|
||||
expect(c.status).toBe("ok");
|
||||
expect(c.manifest!.modules[0]!.lessons[0]!.transcript).toBe("transcripts/m1-l1.md");
|
||||
expect(await env.deps.ctx.courses.readLessonMarkdown(imp.courseId, "m1-l1")).toContain("[0:35] world");
|
||||
expect(JSON.parse(await readFile(join(env.dataDir, "cache/transcripts", imp.courseId, "m1-l1.json"), "utf8")).text).toBe("hello world");
|
||||
// a second run has nothing left to do
|
||||
expect((await transcribeCourse(env.deps, { courseId: imp.courseId }, env.jc)).transcribed).toBe(0);
|
||||
});
|
||||
|
||||
test("an endpoint failure becomes a warning, not a failed job", async () => {
|
||||
const env = await makeEnv({ STC_TRANSCRIBE_URL: `http://127.0.0.1:1/x` });
|
||||
const imp = await importPacktVideo(env.deps, { path: await makePacktZip(await tmp()), options: {} }, env.jc);
|
||||
const r = await transcribeCourse(env.deps, { courseId: imp.courseId }, env.jc);
|
||||
expect(r.transcribed).toBe(0);
|
||||
expect(r.warnings).toHaveLength(3);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { decodeEntities, naturalSort, slugify, uniqueId } from "../src/util/text";
|
||||
import { run } from "../src/util/exec";
|
||||
import { readRawManifest, writeManifest } from "../src/util/manifest";
|
||||
|
||||
test("slugify folds accents and falls back", () => {
|
||||
expect(slugify("Mastering Docker Essentials - Hands-on DevOps!")).toBe("mastering-docker-essentials-hands-on-devops");
|
||||
expect(slugify("Café Ünïcode")).toBe("cafe-unicode");
|
||||
expect(slugify("日本語")).toBe("course");
|
||||
});
|
||||
|
||||
test("uniqueId appends a counter", () => {
|
||||
expect(uniqueId("a", (x) => x === "a" || x === "a-2")).toBe("a-3");
|
||||
expect(uniqueId("a", () => false)).toBe("a");
|
||||
});
|
||||
|
||||
test("decodeEntities handles named and numeric entities", () => {
|
||||
expect(decodeEntities("you'll & <b> "x" A")).toBe(`you'll & <b> "x" A`);
|
||||
});
|
||||
|
||||
test("naturalSort orders numbers numerically", () => {
|
||||
expect(naturalSort(["10 b.mp4", "2 a.mp4", "1 z.mp4"])).toEqual(["1 z.mp4", "2 a.mp4", "10 b.mp4"]);
|
||||
});
|
||||
|
||||
test("run reports stderr on failure", async () => {
|
||||
expect(await run(["echo", "hi"])).toContain("hi");
|
||||
await expect(run(["sh", "-c", "echo oops >&2; exit 3"])).rejects.toThrow(/oops/);
|
||||
});
|
||||
|
||||
test("writeManifest validates and round-trips", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "stc-m-"));
|
||||
await mkdir(join(dir, "c"));
|
||||
const m = { schema: 1, id: "x", title: "T: with colon", visibility: "restricted", modules: [{ id: "m1", title: "S08 - Foo", lessons: [] }] };
|
||||
await writeManifest(join(dir, "c"), m);
|
||||
expect((await readRawManifest(join(dir, "c")) as any).modules[0].title).toBe("S08 - Foo");
|
||||
await expect(writeManifest(join(dir, "c"), { ...m, id: "BAD ID" })).rejects.toThrow(/id/);
|
||||
await writeFile(join(dir, "c", "marker"), "");
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { importPdfDeck } from "../src/jobs/pdf-deck";
|
||||
import { importVideoFolder, lessonTitle } from "../src/jobs/video-folder";
|
||||
import { makeEnv } from "./harness";
|
||||
import { makePdf, makeVideo, tmp } from "./fixtures";
|
||||
|
||||
test("lessonTitle strips ordering prefixes", () => {
|
||||
expect(lessonTitle("01 - Intro to Nix.mp4")).toBe("Intro to Nix");
|
||||
expect(lessonTitle("2_setup_env.mkv")).toBe("setup env");
|
||||
expect(lessonTitle("12.mp4")).toBe("12");
|
||||
});
|
||||
|
||||
async function folderCourse() {
|
||||
const env = await makeEnv();
|
||||
const dir = await tmp();
|
||||
const src = join(dir, "My Homelab Videos");
|
||||
await makeVideo(join(src, "10 - Last.mp4"), 1);
|
||||
await makeVideo(join(src, "2 - Second Clip.mp4"), 1);
|
||||
await makeVideo(join(src, "1 - Ünïcode first.mp4"), 1);
|
||||
const before = (await readdir(src)).sort();
|
||||
const r = await importVideoFolder(env.deps, { kind: "video-folder", path: src }, env.jc);
|
||||
return { env, src, before, r };
|
||||
}
|
||||
|
||||
test("a folder of videos becomes one lesson per file in natural order", async () => {
|
||||
const { env, src, before, r } = await folderCourse();
|
||||
expect(r.courseId).toBe("my-homelab-videos");
|
||||
const m = env.deps.ctx.courses.get(r.courseId)!;
|
||||
expect(m.status).toBe("ok");
|
||||
expect(m.manifest!.modules[0]!.lessons.map((l) => l.title)).toEqual(["Ünïcode first", "Second Clip", "Last"]);
|
||||
expect(m.manifest!.visibility).toBe("restricted");
|
||||
expect(m.manifest!.source?.kind).toBe("video-folder");
|
||||
expect(env.deps.ctx.courses.resolvePath(r.courseId, "media/1 - Ünïcode first.mp4")).toBeTruthy();
|
||||
expect((await readdir(src)).sort()).toEqual(before);
|
||||
});
|
||||
|
||||
test("a deck attaches to a lesson, builds page images and text, and leaves the PDF original alone", async () => {
|
||||
const { env, r } = await folderCourse();
|
||||
const d = await tmp();
|
||||
await makePdf(join(d, "Slides Deck.pdf"), ["Alpha slide", "Beta slide", "Gamma slide"]);
|
||||
const before = await readFile(join(d, "Slides Deck.pdf"));
|
||||
const res = await importPdfDeck(env.deps, { kind: "pdf-deck", path: join(d, "Slides Deck.pdf"), options: { courseId: r.courseId, attachTo: "l2", title: "My deck" } }, env.jc);
|
||||
expect(res.warnings).toEqual([]);
|
||||
const c = env.deps.ctx.courses.get(r.courseId)!;
|
||||
expect(c.status).toBe("ok");
|
||||
expect(c.manifest!.decks).toEqual([{ id: "d-slides-deck", file: "media/decks/Slides Deck.pdf", title: "My deck", attachTo: "l2" }]);
|
||||
const cache = join(env.dataDir, "cache/decks", r.courseId, "d-slides-deck");
|
||||
expect((await readdir(cache)).sort()).toEqual(["p-001.jpg", "p-002.jpg", "p-003.jpg", "text.json"]);
|
||||
expect(JSON.parse(await readFile(join(cache, "text.json"), "utf8"))[2].text).toContain("Gamma slide");
|
||||
expect((await stat(join(env.dataDir, "cache/covers", r.courseId, "deck-d-slides-deck.jpg"))).size).toBeGreaterThan(100);
|
||||
expect(Buffer.compare(await readFile(join(d, "Slides Deck.pdf")), before)).toBe(0);
|
||||
});
|
||||
|
||||
test("deck attach validates the target and refuses courses the importer did not create", async () => {
|
||||
const { env, r } = await folderCourse();
|
||||
const d = await tmp();
|
||||
await makePdf(join(d, "a.pdf"));
|
||||
await expect(importPdfDeck(env.deps, { path: join(d, "a.pdf"), options: { courseId: r.courseId, attachTo: "nope" } }, env.jc)).rejects.toThrow(/not a lesson or module/);
|
||||
await expect(importPdfDeck(env.deps, { path: join(d, "a.pdf"), options: { courseId: r.courseId } }, env.jc)).rejects.toThrow(/attachTo is required/);
|
||||
await expect(importPdfDeck(env.deps, { path: join(d, "a.pdf"), options: { courseId: "sample", attachTo: "x" } }, env.jc)).rejects.toThrow(/only edits courses it created/);
|
||||
await expect(importPdfDeck(env.deps, { path: join(d, "a.pdf"), options: { courseId: "../x", attachTo: "x" } }, env.jc)).rejects.toThrow(/only edits courses it created/);
|
||||
await expect(importPdfDeck(env.deps, { path: join(d, "missing.pdf"), options: {} }, env.jc)).rejects.toThrow(/not a PDF|outside the allowed/);
|
||||
});
|
||||
|
||||
test("without PyMuPDF the deck is still attached, with a warning", async () => {
|
||||
const { env, r } = await folderCourse();
|
||||
env.deps.python = "/nonexistent/python";
|
||||
const d = await tmp();
|
||||
await makePdf(join(d, "a.pdf"));
|
||||
const res = await importPdfDeck(env.deps, { path: join(d, "a.pdf"), options: { courseId: r.courseId, attachTo: "m1" } }, env.jc);
|
||||
expect(res.warnings.join()).toContain("page images and text could not be built");
|
||||
expect(env.deps.ctx.courses.get(r.courseId)!.manifest!.decks).toHaveLength(1);
|
||||
});
|
||||
Reference in New Issue
Block a user