diff --git a/src/main/consts.ts b/src/main/consts.ts index 6bc78929..a33f2750 100644 --- a/src/main/consts.ts +++ b/src/main/consts.ts @@ -30,8 +30,8 @@ export const SELF_PARTIAL_STALE_MS = 15_000; // cost for no effect - and it grew for the whole interview. // // Deliberately larger than the backend's window, so a change there does not silently starve the -// prompt. This does NOT bound retained history: the end-of-interview summary and .docx export -// read the full transcript from app state. +// prompt. This does NOT bound retained history: the end-of-interview summary and the .docx/.md +// export read the full transcript from app state. export const TRANSCRIPT_UPLOAD_LIMIT = 60; // Suggestion constants diff --git a/src/main/ipc/tools.ts b/src/main/ipc/tools.ts index 5932a1a6..7ef267f7 100644 --- a/src/main/ipc/tools.ts +++ b/src/main/ipc/tools.ts @@ -2,10 +2,11 @@ import { dialog, ipcMain } from 'electron'; import fs from 'fs/promises'; import { toolsService } from '../services/tools.service.js'; +import { ExportFormat } from '../types/export.js'; export function registerToolsHandlers(): void { - ipcMain.handle('tools:export-transcript', async () => { - return toolsService.exportTranscript(); + ipcMain.handle('tools:export-transcript', async (_event, format: ExportFormat = 'docx') => { + return toolsService.exportTranscript(format); }); ipcMain.handle('tools:clear-all', async () => { await toolsService.clearAll(); diff --git a/src/main/preload.cts b/src/main/preload.cts index e5275d47..0d6e58b3 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -126,7 +126,8 @@ const electronApi = { }, tools: { - exportTranscript: () => ipcRenderer.invoke('tools:export-transcript'), + exportTranscript: (format: 'docx' | 'md') => + ipcRenderer.invoke('tools:export-transcript', format), clearAll: () => ipcRenderer.invoke('tools:clear-all'), setPlaceholderData: () => ipcRenderer.invoke('tools:set-placeholder-data'), saveImage: (opts: { filename: string; data: number[] }) => diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 59f87875..bf37643b 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -4,8 +4,9 @@ import fs from 'fs/promises'; import { LLMApi } from '../api/llm.js'; import { configStore } from '../store/config.store.js'; -import { Speaker } from '../types/app-state.js'; +import { ExportFormat } from '../types/export.js'; import { GenerateSummarizeRequest } from '../types/llm.js'; +import { buildExportMarkdown, generateExportFilename } from '../utils/export-markdown.js'; import { appStateService } from './app-state.service.js'; import { actionSuggestionService } from './suggestion-action.service.js'; import { liveSuggestionService } from './suggestion-live.service.js'; @@ -14,22 +15,7 @@ import { transcriptService } from './transcript.service.js'; class ToolsService { private llmApi: LLMApi = new LLMApi(); - private generateFilename(): string { - const d = new Date(); - - const pad = (n: number) => String(n).padStart(2, '0'); - - const yyyy = d.getFullYear(); - const mm = pad(d.getMonth() + 1); - const dd = pad(d.getDate()); - const hh = pad(d.getHours()); - const min = pad(d.getMinutes()); - const ss = pad(d.getSeconds()); - - return `report-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.docx`; - } - - async exportTranscript(): Promise { + async exportTranscript(format: ExportFormat = 'docx'): Promise { // Prepare request data const username = appStateService.getState().interviewConfig.fullName; const transcripts = appStateService.getState().transcripts; @@ -45,43 +31,30 @@ class ToolsService { throw new Error(response.error.message); } - const summaryPartRaw = response.data ?? ''; + const fullMarkdown = buildExportMarkdown({ + username, + summary: response.data ?? '', + transcripts, + suggestions, + }); - // Add Date/Time to summary (insert after first line) - let summaryPart = summaryPartRaw; - if (summaryPart) { - const lines = summaryPart.split('\n'); - if (lines.length > 0) { - const datetimeNow = new Date().toLocaleString(); - lines.splice(1, 0, `\n##### Date/Time: ${datetimeNow}`); - summaryPart = lines.join('\n'); - } - } + const isMarkdown = format === 'md'; - // Build Transcripts section - const transcriptLines: string[] = []; - for (const t of transcripts) { - const timeStr = new Date(t.timestamp).toLocaleString(); - const speakerName = t.speaker === Speaker.Self ? username : 'Interviewer'; - transcriptLines.push(`#### ***${timeStr} | ${speakerName}***\n${t.text}\n`); - } - const transcriptsPart = `# **Transcripts**\n\n${transcriptLines.join('\n')}`; + const { canceled, filePath } = await dialog.showSaveDialog({ + title: 'Save Transcript', + defaultPath: generateExportFilename(format), + filters: isMarkdown + ? [{ name: 'Markdown', extensions: ['md'] }] + : [{ name: 'Word Document', extensions: ['docx'] }], + }); - // Build Suggestions section - const suggestionLines: string[] = []; - for (const s of suggestions) { - const timeStr = new Date(s.timestamp).toLocaleString(); - suggestionLines.push( - `#### ***${timeStr} | Interviewer***\n${s.last_question}\n\n#### ***Suggestion***\n${s.answer}\n` - ); - } - const suggestionsPart = `# **Suggestions**\n\n${suggestionLines.join('\n')}`; + if (canceled || !filePath) return null; - // Combine all parts into final Markdown content - const fullMarkdown = - `${summaryPart}\n\n${transcripts.length > 0 ? transcriptsPart : ''}\n\n${suggestions.length > 0 ? suggestionsPart : ''}`.trim(); + if (isMarkdown) { + await fs.writeFile(filePath, fullMarkdown, 'utf8'); + return filePath; + } - // Convert Markdown to DOCX const docxBlob = await convertMarkdownToDocx(fullMarkdown, { documentType: 'document', style: { @@ -90,14 +63,6 @@ class ToolsService { }, }); - const { canceled, filePath } = await dialog.showSaveDialog({ - title: 'Save Transcript', - defaultPath: this.generateFilename(), - filters: [{ name: 'Word Document', extensions: ['docx'] }], - }); - - if (canceled || !filePath) return null; - await fs.writeFile(filePath, Buffer.from(await docxBlob.arrayBuffer())); return filePath; } diff --git a/src/main/types/export.ts b/src/main/types/export.ts new file mode 100644 index 00000000..8f86f74b --- /dev/null +++ b/src/main/types/export.ts @@ -0,0 +1 @@ +export type ExportFormat = 'docx' | 'md'; diff --git a/src/main/utils/export-markdown.ts b/src/main/utils/export-markdown.ts new file mode 100644 index 00000000..8bbc8a79 --- /dev/null +++ b/src/main/utils/export-markdown.ts @@ -0,0 +1,72 @@ +import { LiveSuggestion, Speaker, Transcript } from '../types/app-state.js'; +import { ExportFormat } from '../types/export.js'; + +interface ExportMarkdownInput { + username: string; + summary: string; + transcripts: Transcript[]; + suggestions: LiveSuggestion[]; +} + +/** + * Builds the report every export format is rendered from. Kept free of any `electron` import so + * it stays loadable outside an Electron process - see test/tools-export.test.mjs. + */ +export function buildExportMarkdown({ + username, + summary, + transcripts, + suggestions, +}: ExportMarkdownInput): string { + // Add Date/Time to summary (insert after first line) + let summaryPart = summary; + if (summaryPart) { + const lines = summaryPart.split('\n'); + if (lines.length > 0) { + const datetimeNow = new Date().toLocaleString(); + lines.splice(1, 0, `\n##### Date/Time: ${datetimeNow}`); + summaryPart = lines.join('\n'); + } + } + + // Build Transcripts section + const transcriptLines: string[] = []; + for (const t of transcripts) { + const timeStr = new Date(t.timestamp).toLocaleString(); + const speakerName = t.speaker === Speaker.Self ? username : 'Interviewer'; + transcriptLines.push(`#### ***${timeStr} | ${speakerName}***\n${t.text}\n`); + } + const transcriptsPart = `# **Transcripts**\n\n${transcriptLines.join('\n')}`; + + // Build Suggestions section + const suggestionLines: string[] = []; + for (const s of suggestions) { + const timeStr = new Date(s.timestamp).toLocaleString(); + suggestionLines.push( + `#### ***${timeStr} | Interviewer***\n${s.last_question}\n\n#### ***Suggestion***\n${s.answer}\n` + ); + } + const suggestionsPart = `# **Suggestions**\n\n${suggestionLines.join('\n')}`; + + return `${summaryPart}\n\n${transcripts.length > 0 ? transcriptsPart : ''}\n\n${suggestions.length > 0 ? suggestionsPart : ''}`.trim(); +} + +export function generateExportFilename(format: ExportFormat): string { + const d = new Date(); + + const pad = (n: number) => String(n).padStart(2, '0'); + + const yyyy = d.getFullYear(); + const mm = pad(d.getMonth() + 1); + const dd = pad(d.getDate()); + const hh = pad(d.getHours()); + const min = pad(d.getMinutes()); + const ss = pad(d.getSeconds()); + + // Anything that is not an explicit `md` falls back to docx: `format` arrives over IPC from the + // renderer, where the type annotation is erased, so an unexpected value must not reach the + // extension. + const ext = format === 'md' ? 'md' : 'docx'; + + return `report-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.${ext}`; +} diff --git a/src/renderer/components/custom/control-panel/tools-group.tsx b/src/renderer/components/custom/control-panel/tools-group.tsx index 2a25d0c5..45cf21c8 100644 --- a/src/renderer/components/custom/control-panel/tools-group.tsx +++ b/src/renderer/components/custom/control-panel/tools-group.tsx @@ -3,7 +3,9 @@ import { CaptionsOff, CircleCheck, FileIcon, + FileText, FolderOpenIcon, + Hash, Loader, Save, Trash2, @@ -13,6 +15,12 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppState } from '@/hooks/use-app-state'; import useTools from '@/hooks/use-tools'; @@ -20,6 +28,7 @@ import { useTranscriptPanel } from '@/hooks/use-transcript-panel'; import { Hotkey, HOTKEYS } from '@/lib/hotkeys'; import { getElectron } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; +import type { ExportFormat } from '@/types/export'; interface ToolsGroupProps { getDisabled: (state: RunningState, disableOnRunning?: boolean) => boolean; @@ -46,9 +55,9 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { } }; - const onExportTranscript = async () => { + const onExportTranscript = async (format: ExportFormat) => { try { - const filePath = await exportTranscript(); + const filePath = await exportTranscript(format); if (!filePath) return; const electron = getElectron(); const toastId = `export-${Date.now()}`; @@ -63,7 +72,9 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) { }} > - Interview exported + + Interview exported as {format === 'md' ? 'Markdown' : 'Word'} +
@@ -161,22 +172,44 @@ export function ToolsGroup({ getDisabled }: ToolsGroupProps) {

Clear

- - - - - -

Export Interview

-
-
+ {/* Non-modal for the same reason as the titlebar menu: a modal menu locks body pointer + events, and picking a format unmounts the menu before it releases the lock. */} + + + + + + + + +

Export Interview

+
+
+ {/* Opens upward, and not just for looks: the menu is portalled into the overflow-hidden +
from main-frame, and the control panel is the bottom-most thing in it, so a + downward menu would open past that edge and get clipped rather than merely flipped. */} + + void onExportTranscript('docx')}> + + Word Document (.docx) + + void onExportTranscript('md')}> + + Markdown (.md) + + +
); } diff --git a/src/renderer/hooks/use-tools.tsx b/src/renderer/hooks/use-tools.tsx index 92cfd3ad..1d921fe3 100644 --- a/src/renderer/hooks/use-tools.tsx +++ b/src/renderer/hooks/use-tools.tsx @@ -1,18 +1,19 @@ import { useState } from 'react'; import { getElectron } from '@/lib/utils'; +import type { ExportFormat } from '@/types/export'; export default function useTools() { const [exporting, setExporting] = useState(false); - const exportTranscript = async (): Promise => { + const exportTranscript = async (format: ExportFormat): Promise => { setExporting(true); try { const electron = getElectron(); if (!electron) { throw new Error('Electron API not available'); } - return await electron.tools.exportTranscript(); + return await electron.tools.exportTranscript(format); } catch (error) { console.error('Failed to export transcript:', error); throw error; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 12792009..9bb25035 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -1,5 +1,6 @@ import type { AppState } from './app-state'; import type { Config } from './config'; +import type { ExportFormat } from './export'; import type { LLMConfig, LLMConfigValidationResult, LLMModelInfo } from './llm'; import type { AvailableCurrency, @@ -131,7 +132,7 @@ declare global { // Tools management tools: { - exportTranscript: () => Promise; + exportTranscript: (format: ExportFormat) => Promise; clearAll: () => Promise; setPlaceholderData: () => Promise; saveImage: (opts: { diff --git a/src/renderer/types/export.ts b/src/renderer/types/export.ts new file mode 100644 index 00000000..8f86f74b --- /dev/null +++ b/src/renderer/types/export.ts @@ -0,0 +1 @@ +export type ExportFormat = 'docx' | 'md'; diff --git a/test/run.mjs b/test/run.mjs index a511784b..f97a56c9 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -19,6 +19,7 @@ for (const module of [ './account.test.mjs', './stealth-surface.test.mjs', './stealth-toggle.test.mjs', + './tools-export.test.mjs', ]) { const { run } = await import(module); failures.push(...(await run(userDataDir))); diff --git a/test/tools-export.test.mjs b/test/tools-export.test.mjs new file mode 100644 index 00000000..06d79b09 --- /dev/null +++ b/test/tools-export.test.mjs @@ -0,0 +1,68 @@ +/** + * Export is assembled as Markdown and only then converted, so the same string is what a .md + * export writes verbatim and what the .docx converter is fed. These pin that assembly and the + * filename extension, which is what decides the format the save dialog offers. + * + * Only the pure helpers are covered - exportTranscript() itself needs `dialog` and the LLM API, + * neither of which the electron stub provides. + */ +import { createChecker, loadMain } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('tools-export'); + + const { buildExportMarkdown, generateExportFilename } = await loadMain( + 'utils/export-markdown.js' + ); + + const transcripts = [ + { timestamp: Date.now(), text: 'I led the migration.', speaker: 'self' }, + { timestamp: Date.now(), text: 'Tell me about a hard bug.', speaker: 'other' }, + ]; + const suggestions = [ + { timestamp: Date.now(), last_question: 'Why did you leave?', answer: 'Growth.' }, + ]; + + const md = buildExportMarkdown({ + username: 'Ada Lovelace', + summary: '# **Report**\nBody text.', + transcripts, + suggestions, + }); + + check('keeps the summary', md.includes('# **Report**')); + check('stamps the export time under the title', md.includes('##### Date/Time:')); + check('emits the transcripts section', md.includes('# **Transcripts**')); + check('emits the suggestions section', md.includes('# **Suggestions**')); + check('labels the candidate by name', md.includes('| Ada Lovelace***')); + check('labels everyone else as the interviewer', md.includes('| Interviewer***')); + check( + 'carries the question and the suggested answer', + md.includes('Why did you leave?') && md.includes('Growth.') + ); + + const empty = buildExportMarkdown({ + username: 'Ada Lovelace', + summary: '# **Report**', + transcripts: [], + suggestions: [], + }); + check('omits the transcripts heading when there are none', !empty.includes('# **Transcripts**')); + check('omits the suggestions heading when there are none', !empty.includes('# **Suggestions**')); + + const docxName = generateExportFilename('docx'); + const mdName = generateExportFilename('md'); + check('names the Word export .docx', docxName.endsWith('.docx')); + check('names the Markdown export .md', mdName.endsWith('.md')); + check( + 'keeps the report- stem', + /^report-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.(docx|md)$/.test(mdName) + ); + // `format` crosses IPC from the renderer, where the type annotation is erased. + check( + 'falls back to docx for an unknown format', + generateExportFilename('exe').endsWith('.docx') + ); + + return failures; +}