feat(import): Packt parser and packt-video job
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
34f27d05c9
commit
07e2223c8a
8 files changed
+473
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user