Files
openmaic/OpenMAIC/lib/import/use-import-classroom.ts
2026-08-16 14:58:47 +08:00

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,
};
}