199 lines
6.4 KiB
TypeScript
199 lines
6.4 KiB
TypeScript
'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<ImportPhase>('idle');
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const { t } = useI18n();
|
|
|
|
const triggerFileSelect = useCallback(() => {
|
|
fileInputRef.current?.click();
|
|
}, []);
|
|
|
|
const handleFileChange = useCallback(
|
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
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<unknown>) => {
|
|
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,
|
|
};
|
|
}
|