diff --git a/src/components/ExportControls.test.tsx b/src/components/ExportControls.test.tsx new file mode 100644 index 0000000..710ff04 --- /dev/null +++ b/src/components/ExportControls.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { ExportControls } from './ExportControls.js'; + +describe('ExportControls', () => { + it('offers every supported export and invokes the selected format', async () => { + const user = userEvent.setup(); + const onExport = vi.fn().mockResolvedValue(undefined); + render(); + + expect(screen.getAllByRole('option')).toHaveLength(5); + await user.selectOptions(screen.getByLabelText('Format'), 'markdown'); + await user.click(screen.getByRole('button', { name: 'Create export' })); + + expect(onExport).toHaveBeenCalledWith('markdown'); + expect(screen.getByText('The export download is ready.')).toBeVisible(); + }); + + it('keeps a failed export recoverable', async () => { + const user = userEvent.setup(); + const onExport = vi.fn().mockRejectedValue(new Error('Export limit hit')); + render(); + + await user.click(screen.getByRole('button', { name: 'Create export' })); + + expect(screen.getByText('Export limit hit')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Create export' })).toBeEnabled(); + }); +}); diff --git a/src/components/ExportControls.tsx b/src/components/ExportControls.tsx new file mode 100644 index 0000000..98cc268 --- /dev/null +++ b/src/components/ExportControls.tsx @@ -0,0 +1,99 @@ +import { useId, useState, type ChangeEvent } from 'react'; + +export type OneNoteExportFormat = + 'static-zip' | 'json' | 'text' | 'markdown' | 'html'; + +interface ExportControlsProps { + onExport(format: OneNoteExportFormat): Promise; +} + +const EXPORT_OPTIONS: ReadonlyArray<{ + value: OneNoteExportFormat; + label: string; +}> = [ + { value: 'static-zip', label: 'Static notebook ZIP (recommended)' }, + { value: 'json', label: 'Structured JSON' }, + { value: 'text', label: 'Plain text' }, + { value: 'markdown', label: 'Markdown' }, + { value: 'html', label: 'Semantic HTML' }, +]; + +type ExportStatus = + | { phase: 'idle' } + | { phase: 'preparing' } + | { phase: 'complete' } + | { phase: 'error'; message: string }; + +export function ExportControls({ onExport }: ExportControlsProps) { + const selectId = useId(); + const [format, setFormat] = useState('static-zip'); + const [status, setStatus] = useState({ phase: 'idle' }); + + const changeFormat = (event: ChangeEvent) => { + setFormat(event.target.value as OneNoteExportFormat); + setStatus({ phase: 'idle' }); + }; + + const createExport = async () => { + setStatus({ phase: 'preparing' }); + try { + await onExport(format); + setStatus({ phase: 'complete' }); + } catch (error) { + setStatus({ + phase: 'error', + message: + error instanceof Error + ? error.message + : 'The local export could not be created.', + }); + } + }; + + return ( +
+
+

Local export

+

Save readable notebook content

+

+ Exports include parser warnings and an unsupported-content summary. + They do not write OneNote files. +

+
+
+ + + +
+

+ {status.phase === 'preparing' + ? 'Building the export in the local parser worker…' + : status.phase === 'complete' + ? 'The export download is ready.' + : status.phase === 'error' + ? status.message + : 'No notebook content leaves this browser.'} +

+
+ ); +}