241 lines
7.9 KiB
TypeScript
241 lines
7.9 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import JSZip from 'jszip';
|
|
import { extractKnowledgePack, extractQuizPack } from '@/lib/bundle/extract';
|
|
import { inlineHtmlAssets } from '@/lib/export/inline-assets';
|
|
import type { Scene, Stage } from '@/lib/types/stage';
|
|
|
|
export type MakeloreCoursePackage = {
|
|
bytes: Uint8Array;
|
|
contentHash: string;
|
|
archiveSha256: string;
|
|
archiveBytes: number;
|
|
sceneCount: number;
|
|
title: string;
|
|
language?: string;
|
|
capabilities: {
|
|
sceneKinds: Array<'slide' | 'interactive' | 'quiz' | 'pbl'>;
|
|
hasAudio: boolean;
|
|
hasWhiteboard: boolean;
|
|
hasAgent: boolean;
|
|
};
|
|
classroom: { stage: Stage; scenes: Scene[] };
|
|
};
|
|
|
|
const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex');
|
|
|
|
export async function packageUniqueMakeloreCourse(input: {
|
|
courseId: string;
|
|
stage: Stage;
|
|
scenes: Scene[];
|
|
fetchImpl?: typeof fetch;
|
|
}): Promise<MakeloreCoursePackage> {
|
|
const scenes = await Promise.all(
|
|
input.scenes.map(async (scene) => {
|
|
if (scene.content.type !== 'interactive' || !scene.content.html) return scene;
|
|
const inlined = await inlineHtmlAssets(scene.content.html, {
|
|
fetchImpl: input.fetchImpl,
|
|
keepImportmapFallbacks: false,
|
|
});
|
|
if (inlined.report.failed.length || inlined.unresolved.length) {
|
|
throw new Error(`Interactive scene ${scene.id} is not offline-complete`);
|
|
}
|
|
return { ...scene, content: { ...scene.content, html: inlined.html } } as Scene;
|
|
}),
|
|
);
|
|
const classroom = { stage: input.stage, scenes };
|
|
const knowledge = extractKnowledgePack(input.courseId, input.stage, scenes);
|
|
const quiz = extractQuizPack(scenes);
|
|
const classroomJson = JSON.stringify(classroom);
|
|
const knowledgeJson = JSON.stringify(knowledge);
|
|
const quizJson = JSON.stringify(quiz);
|
|
const contentHash = sha256(
|
|
JSON.stringify([
|
|
['classroom.json', sha256(classroomJson)],
|
|
['knowledge/knowledge.json', sha256(knowledgeJson)],
|
|
['quiz/quiz.json', sha256(quizJson)],
|
|
]),
|
|
);
|
|
const sceneKinds = [...new Set(scenes.map((scene) => scene.type))].filter(
|
|
(kind): kind is 'slide' | 'interactive' | 'quiz' | 'pbl' =>
|
|
['slide', 'interactive', 'quiz', 'pbl'].includes(kind),
|
|
);
|
|
const capabilities = {
|
|
sceneKinds,
|
|
hasAudio: scenes.some((scene) =>
|
|
scene.actions?.some((action) => action.type === 'speech' && Boolean(action.audioId)),
|
|
),
|
|
hasWhiteboard: Boolean(
|
|
input.stage.whiteboard?.length || scenes.some((scene) => Boolean(scene.whiteboards?.length)),
|
|
),
|
|
hasAgent: Boolean(input.stage.generatedAgentConfigs?.length),
|
|
};
|
|
const descriptor = {
|
|
schemaVersion: 1,
|
|
kind: 'makelore-course-package',
|
|
courseId: input.courseId,
|
|
contentHash,
|
|
formatVersion: 1,
|
|
minPlayerVersion: '2.0.0',
|
|
title: input.stage.name,
|
|
language: input.stage.languageDirective,
|
|
sceneCount: scenes.length,
|
|
capabilities,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
const zip = new JSZip();
|
|
zip.file('descriptor.json', JSON.stringify(descriptor, null, 2));
|
|
zip.file('classroom.json', classroomJson);
|
|
zip.file('knowledge/knowledge.json', knowledgeJson);
|
|
zip.file('quiz/quiz.json', quizJson);
|
|
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
|
return {
|
|
bytes,
|
|
contentHash,
|
|
archiveSha256: sha256(bytes),
|
|
archiveBytes: bytes.byteLength,
|
|
sceneCount: scenes.length,
|
|
title: input.stage.name,
|
|
language: input.stage.languageDirective,
|
|
capabilities,
|
|
classroom,
|
|
};
|
|
}
|
|
|
|
function packageRoot(): string {
|
|
const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data');
|
|
return resolve(
|
|
process.env.LEARNING_COURSE_PACKAGE_DIR ||
|
|
process.env.MAKELORE_COURSE_PACKAGE_DIR ||
|
|
resolve(dataRoot, 'makelore-packages'),
|
|
);
|
|
}
|
|
|
|
function courseStoreRoot(): string {
|
|
const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data');
|
|
return resolve(
|
|
process.env.LEARNING_COURSE_STORE_DIR ||
|
|
process.env.MAKELORE_COURSE_STORE_DIR ||
|
|
resolve(dataRoot, 'makelore-courses'),
|
|
);
|
|
}
|
|
|
|
function assertLearningCourseId(courseId: string): void {
|
|
if (!/^[A-Za-z0-9_-]{1,64}$/.test(courseId)) throw new Error('Invalid learning course id');
|
|
}
|
|
|
|
export async function persistUniqueMakeloreCourse(
|
|
courseId: string,
|
|
course: MakeloreCoursePackage,
|
|
): Promise<string> {
|
|
const packages = packageRoot();
|
|
const records = courseStoreRoot();
|
|
await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]);
|
|
const packagePath = resolve(packages, `${courseId}.zip`);
|
|
const packageTemp = `${packagePath}.${process.pid}.tmp`;
|
|
const recordPath = resolve(records, `${courseId}.json`);
|
|
const recordTemp = `${recordPath}.${process.pid}.tmp`;
|
|
await writeFile(packageTemp, course.bytes);
|
|
await writeFile(
|
|
recordTemp,
|
|
JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
courseId,
|
|
contentHash: course.contentHash,
|
|
title: course.title,
|
|
language: course.language,
|
|
classroom: course.classroom,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
await rename(packageTemp, packagePath);
|
|
await rename(recordTemp, recordPath);
|
|
return packagePath;
|
|
}
|
|
|
|
export async function readUniqueMakeloreCoursePackage(
|
|
courseId: string,
|
|
): Promise<Uint8Array | null> {
|
|
assertLearningCourseId(courseId);
|
|
try {
|
|
return new Uint8Array(await readFile(resolve(packageRoot(), `${courseId}.zip`)));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface FrozenLearningPackageRecord {
|
|
schemaVersion: 1 | 2;
|
|
courseId: string;
|
|
mode: 'single' | 'large';
|
|
sourceJobId: string;
|
|
/**
|
|
* Stable digest of the exact mutable source snapshot used to build this
|
|
* archive. Legacy records omit it and must never be reused solely by job id.
|
|
*/
|
|
sourceDigest?: string;
|
|
contentHash: string;
|
|
archiveSha256: string;
|
|
archiveBytes: number;
|
|
formatVersion: number;
|
|
minPlayerVersion: string;
|
|
title: string;
|
|
language?: string;
|
|
sceneCount: number;
|
|
moduleCount: number;
|
|
capabilities: Record<string, unknown>;
|
|
courseStructure?: Record<string, unknown>;
|
|
classroom?: { stage: Stage; scenes: Scene[] };
|
|
createdAt: string;
|
|
}
|
|
|
|
/** Persist exact frozen bytes plus the metadata Works uses for idempotent finalize. */
|
|
export async function persistFrozenLearningPackage(
|
|
record: FrozenLearningPackageRecord,
|
|
bytes: Uint8Array,
|
|
): Promise<void> {
|
|
assertLearningCourseId(record.courseId);
|
|
const packages = packageRoot();
|
|
const records = courseStoreRoot();
|
|
await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]);
|
|
const packagePath = resolve(packages, `${record.courseId}.zip`);
|
|
const recordPath = resolve(records, `${record.courseId}.json`);
|
|
const suffix = `${process.pid}.${Date.now()}.tmp`;
|
|
const packageTemp = `${packagePath}.${suffix}`;
|
|
const recordTemp = `${recordPath}.${suffix}`;
|
|
await writeFile(packageTemp, bytes);
|
|
await writeFile(recordTemp, JSON.stringify(record, null, 2));
|
|
await rename(packageTemp, packagePath);
|
|
await rename(recordTemp, recordPath);
|
|
}
|
|
|
|
export async function readFrozenLearningPackageRecord(
|
|
courseId: string,
|
|
): Promise<FrozenLearningPackageRecord | null> {
|
|
assertLearningCourseId(courseId);
|
|
try {
|
|
const value = JSON.parse(
|
|
await readFile(resolve(courseStoreRoot(), `${courseId}.json`), 'utf8'),
|
|
) as Partial<FrozenLearningPackageRecord>;
|
|
if (
|
|
value.courseId !== courseId ||
|
|
(value.mode !== 'single' && value.mode !== 'large') ||
|
|
(value.sourceDigest !== undefined && !/^[0-9a-f]{64}$/.test(value.sourceDigest)) ||
|
|
typeof value.contentHash !== 'string' ||
|
|
!/^[0-9a-f]{64}$/.test(value.contentHash) ||
|
|
typeof value.archiveSha256 !== 'string' ||
|
|
!/^[0-9a-f]{64}$/.test(value.archiveSha256) ||
|
|
typeof value.archiveBytes !== 'number'
|
|
) {
|
|
return null;
|
|
}
|
|
return value as FrozenLearningPackageRecord;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|