Files
OpenMAIC/lib/export/use-export-script.ts

260 lines
8.7 KiB
TypeScript

'use client';
/**
* `useExportScript` — download the classroom narration script (the
* `SpeechAction.text` per scene) as Markdown or a genuine OOXML `.docx` file.
*
* Issue #413: teachers want the TTS narration text as a local document for
* lesson prep/reference, not just the PPTX export. This is a pure client-side
* collection + serialization + download — no server route or media work.
*
* App-side / impure: store read, sonner toast, `saveAs` download.
*/
import { useCallback, useRef, useState } from 'react';
import { saveAs } from 'file-saver';
import { toast } from 'sonner';
import { useStageStore } from '@/lib/store';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useI18n } from '@/lib/hooks/use-i18n';
import { createLogger } from '@/lib/logger';
import { collectSpeechText } from './narration';
import type { Scene } from '@/lib/types/stage';
const log = createLogger('ExportScript');
/** One scene's narration, collected from its speech actions. */
export interface SceneScript {
sceneId: string;
sceneTitle: string;
sceneOrder: number;
text: string;
}
export type ScriptFormat = 'md' | 'docx';
export const SCRIPT_MIME_TYPES: Record<ScriptFormat, string> = {
md: 'text/markdown;charset=utf-8',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
};
interface ScriptExportStageState {
scenes: readonly unknown[];
generatingOutlines: readonly unknown[];
failedOutlines: readonly unknown[];
}
interface ScriptExportMediaTask {
status: string;
}
/** Keep render-time and click-time narration export readiness in sync. */
export function isScriptExportReady(
stageState: ScriptExportStageState,
mediaTasks: Record<string, ScriptExportMediaTask>,
): boolean {
return (
stageState.scenes.length > 0 &&
stageState.generatingOutlines.length === 0 &&
stageState.failedOutlines.length === 0 &&
Object.values(mediaTasks).every((task) => task.status === 'done' || task.status === 'failed')
);
}
/**
* Collect each scene's narration: concatenate its `SpeechAction.text` values in
* action order. Scenes with no speech text are omitted entirely. `slideFallback`
* supplies the locale-appropriate label for scenes with an empty title.
*
* The speech walk itself lives in `./narration` so this exporter and the PPTX
* speaker-notes exporter can't drift on what counts as narration (#1142).
* Whitespace-only speech is dropped and kept text is trimmed — see the
* `keepWhitespaceOnly` / `trim` options there.
*/
export function collectSceneScripts(
scenes: Scene[],
slideFallback: (order: number) => string,
): SceneScript[] {
const scripts: SceneScript[] = [];
for (const scene of scenes) {
const text = collectSpeechText(scene, { keepWhitespaceOnly: false, trim: true });
if (!text) continue;
scripts.push({
sceneId: scene.id,
sceneTitle: scene.title || slideFallback(scene.order),
sceneOrder: scene.order,
text,
});
}
return scripts;
}
/** Normalize CRLF and lone CR line endings for consistent cross-format output. */
function normalizeLineEndings(value: string): string {
return value.replace(/\r\n?/g, '\n');
}
/**
* Neutralize a scene/stage title before it's interpolated into a Markdown
* heading: flatten embedded newlines to spaces, then backslash-escape a
* leading `#` run so it renders as literal text instead of Markdown heading
* syntax. Escaping (not stripping) preserves titles that legitimately start
* with `#` (e.g. "#1 Introduction") while still neutralizing the character's
* special meaning. Newlines are flattened before the leading-`#` check so a
* title like "\n# Injected" can't dodge the escape by shielding its `#`
* behind whitespace that a later `trim()` would otherwise re-expose.
*/
function sanitizeMarkdownHeading(text: string): string {
const flattened = text.replace(/[\r\n]+/g, ' ').trim();
return flattened.replace(/^#+/, (hashes) =>
hashes
.split('')
.map((h) => `\\${h}`)
.join(''),
);
}
/** Serialize collected scripts as a Markdown document. */
export function buildMarkdown(stageName: string, scripts: SceneScript[]): string {
const lines = [`# ${sanitizeMarkdownHeading(stageName)}`];
for (const script of scripts) {
if (!script.text) continue;
lines.push('', `## ${sanitizeMarkdownHeading(script.sceneTitle)}`, '');
const paragraphs = normalizeLineEndings(script.text)
.split(/\n{2,}/)
.map((paragraph) => paragraph.replace(/\n/g, ' ').trim())
.filter(Boolean);
for (const paragraph of paragraphs) {
lines.push(paragraph, '');
}
}
return lines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
type DocxApi = Pick<typeof import('docx'), 'Document' | 'HeadingLevel' | 'Paragraph' | 'TextRun'>;
/**
* Build the document model used by the browser-only DOCX serializer.
* `docx` is passed in rather than imported at module evaluation time so the
* normal classroom header does not eagerly load the OOXML library.
*/
export function buildDocxDocument(
stageName: string,
scripts: SceneScript[],
api: DocxApi,
): InstanceType<DocxApi['Document']> {
const children = [new api.Paragraph({ text: stageName, heading: api.HeadingLevel.HEADING_1 })];
for (const script of scripts) {
if (!script.text) continue;
children.push(
new api.Paragraph({ text: script.sceneTitle, heading: api.HeadingLevel.HEADING_2 }),
);
for (const raw of normalizeLineEndings(script.text).split(/\n{2,}/)) {
const paragraph = raw.trim();
if (!paragraph) continue;
const lines = paragraph.split('\n');
children.push(
new api.Paragraph({
children: lines.map(
(line, index) => new api.TextRun({ text: line, break: index > 0 ? 1 : undefined }),
),
}),
);
}
}
return new api.Document({ sections: [{ children }] });
}
/** Build a genuine OOXML DOCX blob using the browser-compatible `docx` package. */
export async function buildDocxBlob(stageName: string, scripts: SceneScript[]): Promise<Blob> {
const api = await import('docx');
const document = buildDocxDocument(stageName, scripts, api);
return api.Packer.toBlob(document);
}
/**
* Build a safe download file name: `<stem>-script.<ext>`. Illegal filename
* characters are stripped, whitespace runs collapse to a single `-`, and an
* empty stem falls back to `script`.
*/
export function buildScriptFileName(stageName: string, ext: ScriptFormat): string {
const cleaned = stageName
.replace(/[\u0000-\u001f\u007f\u200b-\u200c\u200e-\u200f\ufeff\\/:*?"<>|]/gu, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return cleaned ? `${cleaned}-script.${ext}` : `script.${ext}`;
}
/** Shared export hook — exposes Markdown and DOCX downloads. */
export function useExportScript() {
const { t } = useI18n();
const [exporting, setExporting] = useState(false);
const exportingRef = useRef(false);
const downloadScript = useCallback(
async (format: ScriptFormat) => {
if (exportingRef.current) return;
const stageState = useStageStore.getState();
const mediaTasks = useMediaGenerationStore.getState().tasks;
if (!isScriptExportReady(stageState, mediaTasks)) {
toast.warning(t('share.notReady'));
return;
}
const { scenes, stage } = stageState;
const scripts = collectSceneScripts(scenes, (order) => t('export.slideFallback', { order }));
if (scripts.length === 0) {
toast.warning(t('export.nothingToExport'));
return;
}
const isAsync = format === 'docx';
if (isAsync) {
exportingRef.current = true;
setExporting(true);
}
try {
const fileName = stage?.name || 'classroom';
let blob: Blob;
if (format === 'md') {
blob = new Blob([buildMarkdown(fileName, scripts)], {
type: SCRIPT_MIME_TYPES.md,
});
} else {
blob = await buildDocxBlob(fileName, scripts);
// Some Blob implementations inherit a generic type from the packer;
// normalize it before handing the file to the browser download API.
if (blob.type !== SCRIPT_MIME_TYPES.docx) {
blob = new Blob([blob], { type: SCRIPT_MIME_TYPES.docx });
}
}
saveAs(blob, buildScriptFileName(fileName, format));
toast.success(t('export.exportSuccess'));
} catch (error) {
log.error(`Script export failed (${format}):`, error);
toast.error(t('export.exportFailed'));
} finally {
if (isAsync) {
exportingRef.current = false;
setExporting(false);
}
}
},
[t],
);
return {
exporting,
exportScriptDocx: () => void downloadScript('docx'),
exportScriptMd: () => void downloadScript('md'),
};
}