diff --git a/src/renderer/components/modals/WordExportDialog.tsx b/src/renderer/components/modals/WordExportDialog.tsx new file mode 100644 index 0000000..37c4f5f --- /dev/null +++ b/src/renderer/components/modals/WordExportDialog.tsx @@ -0,0 +1,114 @@ +import { useState, useEffect } from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { useAppStore } from '@/stores/app-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useExportSource } from '@/hooks/use-export-source'; +import { generateDocx } from '@/lib/docx-export'; +import { ipc } from '@/lib/ipc'; +import { toast } from '@/lib/toast'; + +export function WordExportDialog({ sourcePath }: { sourcePath: string }) { + const closeModal = useAppStore((s) => s.closeModal); + const setSetting = useSettingsStore((s) => s.setSetting); + const docxCustomTemplatePath = useSettingsStore((s) => s.docxCustomTemplatePath); + const source = useExportSource(); + + const [templateMode, setTemplateMode] = useState<'standard' | 'custom'>(docxCustomTemplatePath ? 'custom' : 'standard'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setTemplateMode(docxCustomTemplatePath ? 'custom' : 'standard'); + }, [docxCustomTemplatePath]); + + const handleChooseTemplate = async () => { + const result = await ipc.app.showSaveDialog?.({ title: 'Choose template path' }); + if (result?.ok && result.data) { + setSetting('docxCustomTemplatePath', result.data); + } + }; + + const handleSubmit = async () => { + if (!source) { setError('No file open.'); return; } + setSubmitting(true); + setError(null); + try { + const blob = await generateDocx({ + source: source.source, + title: source.title, + customTemplatePath: templateMode === 'custom' ? docxCustomTemplatePath : null, + }); + const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as Word document', defaultPath: source.path.replace(/\.md$/, '.docx') }); + if (!saveResult?.ok || !saveResult.data) { + setSubmitting(false); + return; + } + const buffer = new Uint8Array(await blob.arrayBuffer()); + const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer }); + if (!writeResult.ok) { + setError(writeResult.error.message); + setSubmitting(false); + return; + } + toast.success(`Exported ${source.title} to ${saveResult.data}`); + closeModal(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast.error(`Export failed: ${msg}`); + setError(msg); + setSubmitting(false); + } + }; + + return ( + !o && closeModal()}> + + + Export to Word (.docx) + {sourcePath} + +
+
+ + setTemplateMode(v as 'standard' | 'custom')}> +
+ + +
+
+ + +
+
+
+ {templateMode === 'custom' && ( +
+ {docxCustomTemplatePath ? ( + Template path: {docxCustomTemplatePath} + ) : ( + No template selected. Click "Choose template..." to pick a .dotx file. + )} + +
+ )} + {error && ( +
+ {error} +
+ )} +
+ + + + +
+
+ ); +} diff --git a/src/renderer/lib/ipc.ts b/src/renderer/lib/ipc.ts index 79b383c..af0db59 100644 --- a/src/renderer/lib/ipc.ts +++ b/src/renderer/lib/ipc.ts @@ -102,6 +102,8 @@ export const ipc = { safeCall('app', 'openExternal', url), showItemInFolder: (path: string): Promise> => safeCall('app', 'showItemInFolder', path), + showSaveDialog: (args?: { title?: string; defaultPath?: string }): Promise> => + safeCall('app', 'showSaveDialog', args), }, menu: { /** diff --git a/tests/component/modals/WordExportDialog.test.tsx b/tests/component/modals/WordExportDialog.test.tsx new file mode 100644 index 0000000..6bdb254 --- /dev/null +++ b/tests/component/modals/WordExportDialog.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { WordExportDialog } from '@/components/modals/WordExportDialog'; +import { useFileStore } from '@/stores/file-store'; +import { useEditorStore } from '@/stores/editor-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +vi.mock('@/lib/docx-export', () => ({ + generateDocx: vi.fn().mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })), +})); + +vi.mock('@/lib/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@/lib/ipc', () => ({ + ipc: { + app: { + showSaveDialog: vi.fn(), + }, + file: { + writeBuffer: vi.fn(), + }, + }, +})); + +import { generateDocx } from '@/lib/docx-export'; +import { toast } from '@/lib/toast'; +import { ipc } from '@/lib/ipc'; + +describe('WordExportDialog', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + useSettingsStore.setState(useSettingsStore.getInitialState()); + useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any); + useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any); + (ipc.app.showSaveDialog as any).mockResolvedValue({ ok: true, data: '/out.docx' }); + (ipc.file.writeBuffer as any).mockResolvedValue({ ok: true }); + }); + + it('renders with template selector (standard / custom)', () => { + render(); + expect(screen.getByText(/export to word/i)).toBeInTheDocument(); + expect(screen.getByText(/test\.md/)).toBeInTheDocument(); + }); + + it('submitting calls generateDocx and writes the file', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /^export$/i })); + expect(generateDocx).toHaveBeenCalledTimes(1); + expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Exported test.md')); + }); + + it('shows error message on write failure', async () => { + (ipc.file.writeBuffer as any).mockResolvedValue({ ok: false, error: { code: 'E', message: 'write failed' } }); + render(); + await userEvent.click(screen.getByRole('button', { name: /^export$/i })); + expect(await screen.findByText(/write failed/i)).toBeInTheDocument(); + }); + + it('switches to custom template path when "Custom" is selected', async () => { + useSettingsStore.setState({ ...useSettingsStore.getInitialState(), docxCustomTemplatePath: '/my.dotx' }); + render(); + expect(screen.getByText(/\/my\.dotx/)).toBeInTheDocument(); + }); +});