diff --git a/app/page.tsx b/app/page.tsx index 091a7328d..dc01d95dd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -18,6 +18,7 @@ import { Monitor, BotOff, ChevronUp, + Upload, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { LanguageSwitcher } from '@/components/language-switcher'; @@ -48,6 +49,7 @@ import { toast } from 'sonner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useDraftCache } from '@/lib/hooks/use-draft-cache'; import { SpeechButton } from '@/components/audio/speech-button'; +import { useImportClassroom } from '@/lib/import/use-import-classroom'; const log = createLogger('Home'); @@ -149,6 +151,12 @@ function HomePage() { } }; + const { importing, fileInputRef, triggerFileSelect, handleFileChange } = useImportClassroom( + () => { + loadClassrooms(); + }, + ); + useEffect(() => { // Clear stale media store to prevent cross-course thumbnail contamination. // The store may hold tasks from a previously visited classroom whose elementIds @@ -319,6 +327,13 @@ function HomePage() { return (
+ {/* ═══ Top-right pill (unchanged) ═══ */}
)} + + {/* ── Import button (empty state) ── */} + {classrooms.length === 0 && ( + + )} {/* ═══ Recent classrooms — collapsible ═══ */} @@ -554,32 +581,44 @@ function HomePage() { className="relative z-10 mt-10 w-full max-w-6xl flex flex-col items-center" > {/* Trigger — divider-line with centered text */} - + +
- +
{/* Expandable content */} diff --git a/components/header.tsx b/components/header.tsx index 84c0c3e99..00ae7f605 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -10,6 +10,7 @@ import { Download, FileDown, Package, + Archive, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useTheme } from '@/lib/hooks/use-theme'; @@ -21,6 +22,7 @@ import { cn } from '@/lib/utils'; import { useStageStore } from '@/lib/store/stage'; import { useMediaGenerationStore } from '@/lib/store/media-generation'; import { useExportPPTX } from '@/lib/export/use-export-pptx'; +import { useExportClassroom } from '@/lib/export/use-export-classroom'; interface HeaderProps { readonly currentSceneTitle: string; @@ -35,6 +37,7 @@ export function Header({ currentSceneTitle }: HeaderProps) { // Export const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX(); + const { exporting: isExportingZip, exportClassroomZip } = useExportClassroom(); const [exportMenuOpen, setExportMenuOpen] = useState(false); const exportRef = useRef(null); const scenes = useStageStore((s) => s.scenes); @@ -177,24 +180,24 @@ export function Header({ currentSceneTitle }: HeaderProps) {
+ )} diff --git a/lib/export/classroom-zip-types.ts b/lib/export/classroom-zip-types.ts new file mode 100644 index 000000000..60316f807 --- /dev/null +++ b/lib/export/classroom-zip-types.ts @@ -0,0 +1,66 @@ +// lib/export/classroom-zip-types.ts +import type { SceneType, SceneContent } from '@/lib/types/stage'; +import type { Action } from '@/lib/types/action'; +import type { Slide } from '@/lib/types/slides'; + +export const CLASSROOM_ZIP_FORMAT_VERSION = 1; +export const CLASSROOM_ZIP_EXTENSION = '.maic.zip'; + +export interface ClassroomManifest { + formatVersion: number; + exportedAt: string; + appVersion: string; + stage: ManifestStage; + agents: ManifestAgent[]; + scenes: ManifestScene[]; + mediaIndex: Record; +} + +export interface ManifestStage { + name: string; + description?: string; + language?: string; + style?: string; + createdAt: number; + updatedAt: number; +} + +export interface ManifestAgent { + name: string; + role: string; + persona: string; + avatar: string; + color: string; + priority: number; + /** Reserved for forward-compat. Not currently persisted in GeneratedAgentRecord DB schema. */ + voiceConfig?: { providerId: string; voiceId: string }; +} + +export interface ManifestScene { + type: SceneType; + title: string; + order: number; + content: SceneContent; + actions?: ManifestAction[]; + whiteboards?: Slide[]; + multiAgent?: { + enabled: boolean; + agentIndices: number[]; + directorPrompt?: string; + }; +} + +export type ManifestAction = Omit & { + audioRef?: string; +}; + +export interface MediaIndexEntry { + type: 'audio' | 'image' | 'generated'; + mimeType?: string; + format?: string; + duration?: number; + voice?: string; + size?: number; + prompt?: string; + missing?: boolean; +} diff --git a/lib/export/classroom-zip-utils.ts b/lib/export/classroom-zip-utils.ts new file mode 100644 index 000000000..f1069bac9 --- /dev/null +++ b/lib/export/classroom-zip-utils.ts @@ -0,0 +1,89 @@ +import type { Action, SpeechAction } from '@/lib/types/action'; +import type { ManifestAction } from './classroom-zip-types'; +import { db } from '@/lib/utils/database'; +import type { AudioFileRecord, MediaFileRecord } from '@/lib/utils/database'; +import type { Scene } from '@/lib/types/stage'; + +// ─── Export: Collect Media ───────────────────────────────────── + +export interface CollectedAudio { + zipPath: string; + record: AudioFileRecord; +} + +export interface CollectedMedia { + zipPath: string; + record: MediaFileRecord; + elementId: string; +} + +export async function collectAudioFiles(scenes: Scene[]): Promise { + const audioIds = new Set(); + for (const scene of scenes) { + for (const action of scene.actions ?? []) { + if (action.type === 'speech' && (action as SpeechAction).audioId) { + audioIds.add((action as SpeechAction).audioId!); + } + } + } + const collected: CollectedAudio[] = []; + for (const audioId of audioIds) { + const record = await db.audioFiles.get(audioId); + if (record) { + const ext = record.format || 'mp3'; + collected.push({ zipPath: `audio/${audioId}.${ext}`, record }); + } + } + return collected; +} + +export async function collectMediaFiles(stageId: string): Promise { + const records = await db.mediaFiles.where('stageId').equals(stageId).toArray(); + const collected: CollectedMedia[] = []; + for (const record of records) { + const elementId = record.id.includes(':') ? record.id.split(':').slice(1).join(':') : record.id; + const ext = record.mimeType?.split('/')[1] || 'jpg'; + collected.push({ zipPath: `media/${elementId}.${ext}`, record, elementId }); + } + return collected; +} + +// ─── Export: Action Serialization ────────────────────────────── + +export function actionsToManifest( + actions: Action[], + audioIdToPath: Map, +): ManifestAction[] { + return actions.map((action) => { + if (action.type === 'speech') { + const speech = action as SpeechAction; + const { audioId, ...rest } = speech; + const audioRef = audioId ? audioIdToPath.get(audioId) : undefined; + return { + ...rest, + ...(audioRef ? { audioRef } : {}), + ...(speech.audioUrl ? { audioUrl: speech.audioUrl } : {}), + } as ManifestAction; + } + return action as ManifestAction; + }); +} + +// ─── Import: Reference Rewriting ─────────────────────────────── + +export function rewriteAudioRefsToIds( + actions: ManifestAction[], + audioRefMap: Record, +): Action[] { + return actions.map((action) => { + if (action.type === 'speech' && 'audioRef' in action) { + const { audioRef, ...rest } = action; + const audioId = audioRef ? audioRefMap[audioRef] : undefined; + return { + ...rest, + ...(audioId ? { audioId } : {}), + } as Action; + } + return action as Action; + }); +} diff --git a/lib/export/use-export-classroom.ts b/lib/export/use-export-classroom.ts new file mode 100644 index 000000000..b65392ad0 --- /dev/null +++ b/lib/export/use-export-classroom.ts @@ -0,0 +1,186 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { saveAs } from 'file-saver'; +import { toast } from 'sonner'; +import { useStageStore } from '@/lib/store/stage'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { getGeneratedAgentsByStageId } from '@/lib/utils/database'; +import { + CLASSROOM_ZIP_FORMAT_VERSION, + CLASSROOM_ZIP_EXTENSION, + type ClassroomManifest, + type ManifestStage, + type ManifestAgent, + type ManifestScene, + type MediaIndexEntry, +} from './classroom-zip-types'; +import { collectAudioFiles, collectMediaFiles, actionsToManifest } from './classroom-zip-utils'; +import type { SpeechAction } from '@/lib/types/action'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('ExportClassroom'); + +export function useExportClassroom() { + const [exporting, setExporting] = useState(false); + const { t } = useI18n(); + + const exportClassroomZip = useCallback(async () => { + const { stage, scenes } = useStageStore.getState(); + if (!stage?.id || scenes.length === 0) return; + + setExporting(true); + const toastId = toast.loading(t('export.exporting')); + + try { + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + // 1. Collect agents from DB + const agentRecords = await getGeneratedAgentsByStageId(stage.id); + + // 2. Collect audio files + const audioFiles = await collectAudioFiles(scenes); + + // 3. Collect media files (generated images/videos) + const mediaFiles = await collectMediaFiles(stage.id); + + // 4. Build audioId → zipPath mapping for manifest + const audioIdToPath = new Map(); + for (const af of audioFiles) { + audioIdToPath.set(af.record.id, af.zipPath); + } + + // 5. Build manifest + const manifestStage: ManifestStage = { + name: stage.name, + description: stage.description, + language: stage.languageDirective, + style: stage.style, + createdAt: stage.createdAt, + updatedAt: stage.updatedAt, + }; + + const manifestAgents: ManifestAgent[] = agentRecords.map((a) => ({ + name: a.name, + role: a.role, + persona: a.persona, + avatar: a.avatar, + color: a.color, + priority: a.priority, + })); + + // Also include generatedAgentConfigs from stage if agents not in DB + if (manifestAgents.length === 0 && stage.generatedAgentConfigs?.length) { + for (const a of stage.generatedAgentConfigs) { + manifestAgents.push({ + name: a.name, + role: a.role, + persona: a.persona, + avatar: a.avatar, + color: a.color, + priority: a.priority, + }); + } + } + + // Build agent ID → index mapping for multiAgent references + const agentIdToIndex = new Map(); + agentRecords.forEach((a, i) => agentIdToIndex.set(a.id, i)); + if (stage.generatedAgentConfigs?.length && agentRecords.length === 0) { + stage.generatedAgentConfigs.forEach((a, i) => agentIdToIndex.set(a.id, i)); + } + + const manifestScenes: ManifestScene[] = scenes.map((scene) => ({ + type: scene.type, + title: scene.title, + order: scene.order, + content: scene.content, + actions: scene.actions ? actionsToManifest(scene.actions, audioIdToPath) : undefined, + whiteboards: scene.whiteboards, + ...(scene.multiAgent?.enabled + ? { + multiAgent: { + enabled: true, + agentIndices: (scene.multiAgent.agentIds ?? []) + .map((id) => agentIdToIndex.get(id)) + .filter((i): i is number => i !== undefined), + directorPrompt: scene.multiAgent.directorPrompt, + }, + } + : {}), + })); + + // 6. Build mediaIndex + const mediaIndex: Record = {}; + + for (const af of audioFiles) { + mediaIndex[af.zipPath] = { + type: 'audio', + format: af.record.format, + duration: af.record.duration, + voice: af.record.voice, + }; + } + for (const mf of mediaFiles) { + mediaIndex[mf.zipPath] = { + type: 'generated', + mimeType: mf.record.mimeType, + size: mf.record.size, + prompt: mf.record.prompt, + }; + } + + // Check for missing audio references + for (const scene of scenes) { + for (const action of scene.actions ?? []) { + if (action.type === 'speech') { + const audioId = (action as SpeechAction).audioId; + if (audioId && !audioIdToPath.has(audioId)) { + const missingPath = `audio/${audioId}.mp3`; + mediaIndex[missingPath] = { type: 'audio', missing: true }; + } + } + } + } + + // 7. Assemble manifest + const manifest: ClassroomManifest = { + formatVersion: CLASSROOM_ZIP_FORMAT_VERSION, + exportedAt: new Date().toISOString(), + appVersion: process.env.npm_package_version || '0.0.0', + stage: manifestStage, + agents: manifestAgents, + scenes: manifestScenes, + mediaIndex, + }; + + zip.file('manifest.json', JSON.stringify(manifest, null, 2)); + + // 8. Add media blobs to ZIP + for (const af of audioFiles) { + zip.file(af.zipPath, af.record.blob); + } + for (const mf of mediaFiles) { + zip.file(mf.zipPath, mf.record.blob); + if (mf.record.poster) { + zip.file(mf.zipPath.replace(/\.\w+$/, '.poster.jpg'), mf.record.poster); + } + } + + // 9. Generate and download + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const safeName = stage.name.replace(/[\\/:*?"<>|]/g, '_') || 'classroom'; + saveAs(zipBlob, `${safeName}${CLASSROOM_ZIP_EXTENSION}`); + + toast.success(t('export.exportSuccess'), { id: toastId }); + } catch (error) { + log.error('Classroom ZIP export failed:', error); + toast.error(t('export.exportFailed'), { id: toastId }); + } finally { + setExporting(false); + } + }, [t]); + + return { exporting, exportClassroomZip }; +} diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 979b4dab2..d6aa9d2f3 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -34,7 +34,23 @@ "resourcePackDesc": "PPTX + interactive pages", "exporting": "Exporting...", "exportSuccess": "Export successful", - "exportFailed": "Export failed" + "exportFailed": "Export failed", + "classroomZip": "Export Classroom ZIP", + "classroomZipDesc": "Course structure + media files" + }, + "import": { + "classroom": "Import Classroom", + "parsing": "Parsing ZIP...", + "validating": "Validating data...", + "writingMedia": "Writing media files...", + "writingCourse": "Writing course data...", + "success": "Classroom imported successfully", + "error": { + "invalidZip": "Invalid file. Please select a valid .maic.zip file.", + "invalidManifest": "Invalid classroom file: manifest.json is missing or corrupted.", + "missingData": "Invalid classroom file: missing required course data.", + "storageFull": "Import failed: browser storage is full. Try clearing old classrooms." + } }, "chat": { "lecture": "Lecture", diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 21aea354a..d6c37b9a6 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -34,7 +34,23 @@ "resourcePackDesc": "PPTX+インタラクティブページ", "exporting": "エクスポート中...", "exportSuccess": "エクスポートが完了しました", - "exportFailed": "エクスポートに失敗しました" + "exportFailed": "エクスポートに失敗しました", + "classroomZip": "教室ZIPをエクスポート", + "classroomZipDesc": "コース構造 + メディアファイル" + }, + "import": { + "classroom": "教室をインポート", + "parsing": "ZIP を解析中...", + "validating": "データを検証中...", + "writingMedia": "メディアファイルを書き込み中...", + "writingCourse": "コースデータを書き込み中...", + "success": "教室のインポートが完了しました", + "error": { + "invalidZip": "無効なファイルです。有効な .maic.zip ファイルを選択してください。", + "invalidManifest": "無効な教室ファイル:manifest.json が見つからないか破損しています。", + "missingData": "無効な教室ファイル:必要なコースデータが不足しています。", + "storageFull": "インポート失敗:ブラウザのストレージが一杯です。古い教室を削除してください。" + } }, "chat": { "lecture": "講義", diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 296a09d9d..2c1becdaf 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -34,7 +34,23 @@ "resourcePackDesc": "PPTX + интерактивные страницы", "exporting": "Экспорт...", "exportSuccess": "Экспорт успешен", - "exportFailed": "Ошибка экспорта" + "exportFailed": "Ошибка экспорта", + "classroomZip": "Экспорт ZIP класса", + "classroomZipDesc": "Структура курса + медиафайлы" + }, + "import": { + "classroom": "Импорт класса", + "parsing": "Анализ ZIP...", + "validating": "Проверка данных...", + "writingMedia": "Запись медиафайлов...", + "writingCourse": "Запись данных курса...", + "success": "Класс успешно импортирован", + "error": { + "invalidZip": "Недопустимый файл. Выберите корректный файл .maic.zip.", + "invalidManifest": "Недопустимый файл класса: manifest.json отсутствует или повреждён.", + "missingData": "Недопустимый файл класса: отсутствуют необходимые данные курса.", + "storageFull": "Импорт не удался: хранилище браузера заполнено. Удалите старые классы." + } }, "chat": { "lecture": "Лекция", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 1a3b07b0f..f74a25030 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -34,7 +34,23 @@ "resourcePackDesc": "PPTX + 交互式页面", "exporting": "正在导出...", "exportSuccess": "导出成功", - "exportFailed": "导出失败" + "exportFailed": "导出失败", + "classroomZip": "导出课堂 ZIP", + "classroomZipDesc": "课程结构 + 媒体文件" + }, + "import": { + "classroom": "导入课堂", + "parsing": "正在解析 ZIP...", + "validating": "正在验证数据...", + "writingMedia": "正在写入媒体文件...", + "writingCourse": "正在写入课程数据...", + "success": "课堂导入成功", + "error": { + "invalidZip": "无效文件,请选择有效的 .maic.zip 文件。", + "invalidManifest": "无效课堂文件:manifest.json 缺失或已损坏。", + "missingData": "无效课堂文件:缺少必需的课程数据。", + "storageFull": "导入失败:浏览器存储空间已满,请清理旧课堂后重试。" + } }, "chat": { "lecture": "授课", diff --git a/lib/import/use-import-classroom.ts b/lib/import/use-import-classroom.ts new file mode 100644 index 000000000..d087381a9 --- /dev/null +++ b/lib/import/use-import-classroom.ts @@ -0,0 +1,247 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { nanoid } from 'nanoid'; +import { toast } from 'sonner'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { db, mediaFileKey } from '@/lib/utils/database'; +import type { AudioFileRecord, MediaFileRecord, GeneratedAgentRecord } from '@/lib/utils/database'; +import type { ClassroomManifest, ManifestScene } from '@/lib/export/classroom-zip-types'; +import { rewriteAudioRefsToIds } from '@/lib/export/classroom-zip-utils'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('ImportClassroom'); + +export type ImportPhase = + | 'idle' + | 'parsing' + | 'validating' + | 'writingMedia' + | 'writingCourse' + | 'done'; + +export function useImportClassroom(onSuccess?: () => void) { + const [importing, setImporting] = useState(false); + const [phase, setPhase] = useState('idle'); + const fileInputRef = useRef(null); + const { t } = useI18n(); + + const triggerFileSelect = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Reset input so same file can be re-selected + e.target.value = ''; + + setImporting(true); + setPhase('parsing'); + const toastId = toast.loading(t('import.parsing')); + + try { + // 0. Size check — warn for files over 200MB + const MAX_SAFE_SIZE = 200 * 1024 * 1024; + if (file.size > MAX_SAFE_SIZE) { + log.warn(`Large ZIP file: ${(file.size / 1024 / 1024).toFixed(0)}MB`); + } + + // 1. Parse ZIP + const JSZip = (await import('jszip')).default; + const zip = await JSZip.loadAsync(file); + + const manifestFile = zip.file('manifest.json'); + if (!manifestFile) { + toast.error(t('import.error.invalidManifest'), { id: toastId }); + return; + } + + // 2. Validate + setPhase('validating'); + toast.loading(t('import.validating'), { id: toastId }); + + const manifestText = await manifestFile.async('text'); + let manifest: ClassroomManifest; + try { + manifest = JSON.parse(manifestText); + } catch { + toast.error(t('import.error.invalidManifest'), { id: toastId }); + return; + } + + if (!manifest.stage || !manifest.scenes || !Array.isArray(manifest.scenes)) { + toast.error(t('import.error.missingData'), { id: toastId }); + return; + } + + // 3. Generate new IDs + const newStageId = nanoid(); + const now = Date.now(); + + // Agent ID mapping: index → new ID + const newAgentIds: string[] = (manifest.agents ?? []).map(() => nanoid()); + + // Audio ref → new ID mapping + const audioRefToNewId: Record = {}; + for (const [zipPath, entry] of Object.entries(manifest.mediaIndex ?? {})) { + if (entry.type === 'audio' && !entry.missing) { + audioRefToNewId[zipPath] = nanoid(); + } + } + + // Media ref → new ID mapping + const mediaRefToNewId: Record = {}; + for (const [zipPath, entry] of Object.entries(manifest.mediaIndex ?? {})) { + if ((entry.type === 'generated' || entry.type === 'image') && !entry.missing) { + const filename = zipPath.split('/').pop() ?? ''; + const elementId = filename.replace(/\.\w+$/, ''); + mediaRefToNewId[zipPath] = mediaFileKey(newStageId, elementId); + } + } + + // 4. Write media to IndexedDB + setPhase('writingMedia'); + toast.loading(t('import.writingMedia'), { id: toastId }); + + // Write audio files one at a time + for (const [zipPath, newId] of Object.entries(audioRefToNewId)) { + const zipEntry = zip.file(zipPath); + if (!zipEntry) continue; + const blob = await zipEntry.async('blob'); + const meta = manifest.mediaIndex[zipPath]; + const record: AudioFileRecord = { + id: newId, + blob, + format: meta.format || 'mp3', + duration: meta.duration, + voice: meta.voice, + createdAt: now, + }; + await db.audioFiles.put(record); + } + + // Write generated media files one at a time + for (const [zipPath, newId] of Object.entries(mediaRefToNewId)) { + const zipEntry = zip.file(zipPath); + if (!zipEntry) continue; + const blob = await zipEntry.async('blob'); + const meta = manifest.mediaIndex[zipPath]; + + const record: MediaFileRecord = { + id: newId, + stageId: newStageId, + type: meta.mimeType?.startsWith('video/') ? 'video' : 'image', + blob, + mimeType: meta.mimeType || 'image/jpeg', + size: meta.size || blob.size, + prompt: meta.prompt || '', + params: '', + createdAt: now, + }; + + // Check for poster before writing to avoid redundant put + const posterPath = zipPath.replace(/\.\w+$/, '.poster.jpg'); + const posterEntry = zip.file(posterPath); + if (posterEntry) { + record.poster = await posterEntry.async('blob'); + } + + await db.mediaFiles.put(record); + } + + // 5. Write course data + setPhase('writingCourse'); + toast.loading(t('import.writingCourse'), { id: toastId }); + + // Write stage + await db.stages.put({ + id: newStageId, + name: manifest.stage.name || 'Imported Classroom', + description: manifest.stage.description, + languageDirective: manifest.stage.language, + style: manifest.stage.style, + createdAt: manifest.stage.createdAt || now, + updatedAt: now, + agentIds: newAgentIds.length > 0 ? newAgentIds : undefined, + }); + + // Write agents + if (manifest.agents?.length) { + const agentRecords: GeneratedAgentRecord[] = manifest.agents.map((a, i) => ({ + id: newAgentIds[i], + stageId: newStageId, + name: a.name, + role: a.role, + persona: a.persona, + avatar: a.avatar, + color: a.color, + priority: a.priority, + createdAt: now, + })); + await db.generatedAgents.bulkPut(agentRecords); + } + + // Write scenes with rewritten references + const sceneRecords = manifest.scenes.map((mScene: ManifestScene, index: number) => { + const newSceneId = nanoid(); + + const actions = mScene.actions + ? rewriteAudioRefsToIds(mScene.actions, audioRefToNewId) + : undefined; + + let multiAgent = undefined; + if (mScene.multiAgent?.enabled) { + multiAgent = { + enabled: true, + agentIds: (mScene.multiAgent.agentIndices ?? []) + .map((idx) => newAgentIds[idx]) + .filter(Boolean), + directorPrompt: mScene.multiAgent.directorPrompt, + }; + } + + return { + id: newSceneId, + stageId: newStageId, + type: mScene.type, + title: mScene.title, + order: mScene.order ?? index, + content: mScene.content, + actions, + whiteboard: mScene.whiteboards, + multiAgent, + createdAt: now, + updatedAt: now, + }; + }); + await db.scenes.bulkPut(sceneRecords); + + // 6. Done + setPhase('done'); + toast.success(t('import.success'), { id: toastId }); + onSuccess?.(); + } catch (error) { + log.error('Classroom ZIP import failed:', error); + const isQuotaError = error instanceof DOMException && error.name === 'QuotaExceededError'; + toast.error(isQuotaError ? t('import.error.storageFull') : t('import.error.invalidZip'), { + id: toastId, + }); + } finally { + setImporting(false); + setPhase('idle'); + } + }, + [t, onSuccess], + ); + + return { + importing, + phase, + fileInputRef, + triggerFileSelect, + handleFileChange, + }; +} diff --git a/tests/export/classroom-zip.test.ts b/tests/export/classroom-zip.test.ts new file mode 100644 index 000000000..e9df43ecd --- /dev/null +++ b/tests/export/classroom-zip.test.ts @@ -0,0 +1,146 @@ +import { describe, test, expect } from 'vitest'; +import { rewriteAudioRefsToIds, actionsToManifest } from '@/lib/export/classroom-zip-utils'; +import { + CLASSROOM_ZIP_FORMAT_VERSION, + type ClassroomManifest, +} from '@/lib/export/classroom-zip-types'; +import type { SpeechAction, SpotlightAction } from '@/lib/types/action'; + +// ─── rewriteAudioRefsToIds ──────────────────────────────────── + +describe('rewriteAudioRefsToIds', () => { + test('replaces audioRef with new audioId in speech actions', () => { + const actions = [ + { id: 'a1', type: 'speech' as const, text: 'Hello', audioRef: 'audio/abc.mp3' }, + { id: 'a2', type: 'spotlight' as const, elementId: 'el1' }, + ]; + const audioRefMap = { 'audio/abc.mp3': 'new-audio-id-1' }; + const result = rewriteAudioRefsToIds(actions, audioRefMap); + expect(result[0]).toMatchObject({ + type: 'speech', + text: 'Hello', + audioId: 'new-audio-id-1', + }); + expect(result[1]).toMatchObject({ type: 'spotlight', elementId: 'el1' }); + }); + + test('skips speech actions without audioRef', () => { + const actions = [ + { id: 'a1', type: 'speech' as const, text: 'Hello', audioUrl: 'https://example.com/a.mp3' }, + ]; + const result = rewriteAudioRefsToIds(actions, {}); + expect(result[0]).toMatchObject({ + type: 'speech', + text: 'Hello', + audioUrl: 'https://example.com/a.mp3', + }); + }); +}); + +// ─── actionsToManifest ──────────────────────────────────────── + +describe('actionsToManifest', () => { + test('converts audioId to audioRef for speech actions', () => { + const actions = [ + { + id: 'act1', + type: 'speech' as const, + text: 'Hello', + audioId: 'audio-123', + voice: 'alloy', + speed: 1, + } as SpeechAction, + { id: 'act2', type: 'spotlight' as const, elementId: 'el1' } as SpotlightAction, + ]; + const audioIdToPath = new Map([['audio-123', 'audio/audio-123.mp3']]); + + const result = actionsToManifest(actions, audioIdToPath); + + expect(result[0]).toMatchObject({ + type: 'speech', + text: 'Hello', + audioRef: 'audio/audio-123.mp3', + voice: 'alloy', + }); + expect(result[0]).not.toHaveProperty('audioId'); + expect(result[1]).toMatchObject({ type: 'spotlight', elementId: 'el1' }); + }); + + test('preserves audioUrl when audioId is absent', () => { + const actions = [ + { + id: 'act1', + type: 'speech' as const, + text: 'Hi', + audioUrl: 'https://cdn.example.com/hi.mp3', + } as SpeechAction, + ]; + const result = actionsToManifest(actions, new Map()); + expect(result[0]).toMatchObject({ + type: 'speech', + text: 'Hi', + audioUrl: 'https://cdn.example.com/hi.mp3', + }); + expect(result[0]).not.toHaveProperty('audioRef'); + }); +}); + +// ─── Manifest round-trip ────────────────────────────────────── + +describe('manifest round-trip', () => { + test('manifest structure is valid JSON-serializable', () => { + const manifest: ClassroomManifest = { + formatVersion: CLASSROOM_ZIP_FORMAT_VERSION, + exportedAt: new Date().toISOString(), + appVersion: '0.1.0', + stage: { + name: 'Test Course', + description: 'A test', + language: 'en-US', + style: 'professional', + createdAt: Date.now(), + updatedAt: Date.now(), + }, + agents: [ + { + name: 'Prof', + role: 'lecturer', + persona: 'Friendly professor', + avatar: '👨‍🏫', + color: '#4A90D9', + priority: 1, + }, + ], + scenes: [ + { + type: 'slide', + title: 'Intro', + order: 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + content: { type: 'slide', canvas: { id: 's1', elements: [] } } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + actions: [{ id: 'a1', type: 'speech', text: 'Welcome', audioRef: 'audio/a1.mp3' } as any], + }, + ], + mediaIndex: { + 'audio/a1.mp3': { type: 'audio', format: 'mp3', duration: 5.2 }, + }, + }; + + const serialized = JSON.stringify(manifest); + const deserialized = JSON.parse(serialized) as ClassroomManifest; + + expect(deserialized.formatVersion).toBe(CLASSROOM_ZIP_FORMAT_VERSION); + expect(deserialized.stage.name).toBe('Test Course'); + expect(deserialized.agents).toHaveLength(1); + expect(deserialized.scenes).toHaveLength(1); + expect(deserialized.scenes[0].actions?.[0]).toMatchObject({ + type: 'speech', + audioRef: 'audio/a1.mp3', + }); + expect(deserialized.mediaIndex['audio/a1.mp3']).toMatchObject({ + type: 'audio', + duration: 5.2, + }); + }); +});