'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 } from '@/lib/utils/database'; import { mutateDocument } from '@/lib/document-store'; import { removeAsset } from '@/lib/media/asset-pool'; import { createLogger } from '@/lib/logger'; import { buildImportedDocument, materializeImportedAudio, materializeImportedMedia, } from './import-classroom-core'; import type { ClassroomManifest } from '@/lib/export/classroom-zip-types'; const log = createLogger('ImportClassroom'); export type ImportPhase = | 'idle' | 'parsing' | 'validating' | 'writingMedia' | 'writingCourse' | 'done'; export function useImportClassroom(onSuccess?: (importedStageId: string) => 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')); let importedStageId: string | undefined; const importedPoolIds: string[] = []; let importCommitted = false; 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(); importedStageId = newStageId; const now = Date.now(); // Agent ID mapping: index → new ID const newAgentIds: string[] = (manifest.agents ?? []).map(() => nanoid()); const studentAgentIndex = manifest.agents?.findIndex((agent) => agent.role === 'student') ?? -1; const nonTeacherAgentIndex = manifest.agents?.findIndex((agent) => agent.role !== 'teacher') ?? -1; const fallbackDiscussionAgentIndex = studentAgentIndex >= 0 ? studentAgentIndex : nonTeacherAgentIndex >= 0 ? nonTeacherAgentIndex : undefined; // 4. Write media to IndexedDB setPhase('writingMedia'); toast.loading(t('import.writingMedia'), { id: toastId }); const audioRefToNewId = await materializeImportedAudio( zip, manifest, newStageId, now, importedPoolIds, ); const mediaMappings = await materializeImportedMedia( zip, manifest, newStageId, now, importedPoolIds, ); // 5. Write course data setPhase('writingCourse'); toast.loading(t('import.writingCourse'), { id: toastId }); const document = buildImportedDocument(manifest, { stageId: newStageId, now, newAgentIds, audioRefToNewId, mediaMappings, fallbackDiscussionAgentIndex, }); // The document is the commit point: one aggregate write under its per-stage lock. await mutateDocument(newStageId, async (_existing, store) => store.saveDocument(document)); importCommitted = true; setPhase('done'); } 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 { // Media files cannot join the aggregate document transaction. Until the // document commit point, compensate every row/allocation individually. const cleanup = async (label: string, operation: () => Promise) => { try { await operation(); } catch (cleanupError) { log.error(`Failed to undo imported ${label}:`, cleanupError); } }; if (!importCommitted && importedStageId) { const stageId = importedStageId; await cleanup('document', async () => { await mutateDocument(stageId, async (_document, store) => store.deleteDocument(stageId), ); }); await cleanup('generated media', () => db.mediaFiles.where('stageId').equals(stageId).delete(), ); await cleanup('audio files', () => db.audioFiles.where('stageId').equals(stageId).delete(), ); } if (!importCommitted) { for (const id of importedPoolIds) { await cleanup(`asset pool entry ${id}`, () => removeAsset(id)); } } setImporting(false); setPhase('idle'); } // A consumer callback is outside the rollback region: its exception // cannot make a fully committed classroom lose its already-owned assets. if (importCommitted) { toast.success(t('import.success'), { id: toastId }); onSuccess?.(importedStageId!); } }, [t, onSuccess], ); return { importing, phase, fileInputRef, triggerFileSelect, handleFileChange, }; }