From d5fd5ac28d0bbe1f3382fff2b465a32c055f75e6 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 28 Feb 2026 06:06:48 -0600 Subject: [PATCH] feat: filename sanitization for text export * refactor PDF export option components to use state directly --- docs/roadmap.md | 8 +- src/__tests__/ExportDialog.test.tsx | 2 +- src/__tests__/useTextExport.test.tsx | 81 ++++++++++++++++++- .../export/ExportDialog/ExportDialog.tsx | 3 - .../export/ExportDialog/ExportOptions.tsx | 80 +++++++++--------- src/hooks/useTextExport.tsx | 21 ++++- 6 files changed, 137 insertions(+), 58 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index f3e0785..ae639f0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,13 +9,7 @@ Multi-format document export with live preview, building on the existing `@react ### Tasks -1. **Plaintext export** - - Strip all Markdown formatting from the raw document text (headings → plain text, remove `**`, `_`, `` ` ``, link syntax, etc.) - - Preserve logical structure: blank lines between paragraphs, indentation for list items, `---` for horizontal rules - - Save via Tauri `save` dialog with `.txt` filter - - Add "Plain Text" option to the export dialog - - Add option to save the markdown file anywhere. -2. **DOCX export** +1. **DOCX export** - Add [`docx-rs`](https://github.com/bokuweb/docx-rs) as a Cargo dependency in `src-tauri/Cargo.toml` - Implement a `markdown_ast_to_docx` Tauri command that accepts the Markdown AST (or raw text via `markdown_to_docx`), converts it to a `.docx` byte buffer using `docx-rs` (`Docx`, `Paragraph`, `Run`, heading levels, code blocks, lists, blockquotes), and returns `Vec` to the frontend - Support basic formatting: bold, italic, code font, ordered/unordered lists, blockquotes, headings 1-3 diff --git a/src/__tests__/ExportDialog.test.tsx b/src/__tests__/ExportDialog.test.tsx index f0b6ad4..5602699 100644 --- a/src/__tests__/ExportDialog.test.tsx +++ b/src/__tests__/ExportDialog.test.tsx @@ -17,7 +17,7 @@ const mockRenderResult: PdfRenderResult = { const mockOnExport = vi.fn(); vi.mock( - "$components/pdf/PdfPreview", + "$components/export/preview/PdfPreview", () => ({ PdfPreviewPanel: () =>
Preview Content
}), ); diff --git a/src/__tests__/useTextExport.test.tsx b/src/__tests__/useTextExport.test.tsx index 4d14e8f..0118bcb 100644 --- a/src/__tests__/useTextExport.test.tsx +++ b/src/__tests__/useTextExport.test.tsx @@ -73,12 +73,12 @@ describe(useTextExport, () => { }); }); - it("sanitizes filename by replacing special characters", async () => { + it("sanitizes filename allowing spaces, dashes, and dots", async () => { vi.mocked(save).mockResolvedValue("/tmp/output.txt"); const { result } = renderHook(() => useTextExport()); - const resultWithSpecialChars = { ...textRenderResult, title: "My Document: Version 1.0!" }; + const resultWithSpecialChars = { ...textRenderResult, title: "Version 1.1 - Final Draft" }; await act(async () => { await result.current(resultWithSpecialChars); @@ -86,7 +86,58 @@ describe(useTextExport, () => { expect(save).toHaveBeenCalledWith({ filters: [{ name: "Text", extensions: ["txt"] }], - defaultPath: "My_Document__Version_1_0_.txt", + defaultPath: "Version_1.1_-_Final_Draft.txt", + }); + }); + + it("condenses multiple spaces into single underscore", async () => { + vi.mocked(save).mockResolvedValue("/tmp/output.txt"); + + const { result } = renderHook(() => useTextExport()); + + const resultWithMultipleSpaces = { ...textRenderResult, title: "My Document Title" }; + + await act(async () => { + await result.current(resultWithMultipleSpaces); + }); + + expect(save).toHaveBeenCalledWith({ + filters: [{ name: "Text", extensions: ["txt"] }], + defaultPath: "My_Document_Title.txt", + }); + }); + + it("removes leading and trailing underscores", async () => { + vi.mocked(save).mockResolvedValue("/tmp/output.txt"); + + const { result } = renderHook(() => useTextExport()); + + const resultWithUnderscores = { ...textRenderResult, title: " My Document " }; + + await act(async () => { + await result.current(resultWithUnderscores); + }); + + expect(save).toHaveBeenCalledWith({ + filters: [{ name: "Text", extensions: ["txt"] }], + defaultPath: "My_Document.txt", + }); + }); + + it("uses default filename when title only contains special characters", async () => { + vi.mocked(save).mockResolvedValue("/tmp/output.txt"); + + const { result } = renderHook(() => useTextExport()); + + const resultWithOnlySpecialChars = { ...textRenderResult, title: "@#$%^&*()!" }; + + await act(async () => { + await result.current(resultWithOnlySpecialChars); + }); + + expect(save).toHaveBeenCalledWith({ + filters: [{ name: "Text", extensions: ["txt"] }], + defaultPath: "document.txt", }); }); @@ -124,6 +175,7 @@ describe(useTextExport, () => { describe(useMarkdownExport, () => { beforeEach(() => { vi.clearAllMocks(); + useAppStore.getState().resetTextExport(); }); it("exports markdown successfully", async () => { @@ -142,6 +194,8 @@ describe(useMarkdownExport, () => { defaultPath: "Test_Document.md", }); expect(writeFile).toHaveBeenCalledOnce(); + expect(useAppStore.getState().isExportingText).toBeFalsy(); + expect(useAppStore.getState().textExportError).toBeNull(); }); it("returns false when user cancels save dialog", async () => { @@ -156,6 +210,7 @@ describe(useMarkdownExport, () => { expect(didExport).toBeFalsy(); expect(writeFile).not.toHaveBeenCalled(); + expect(useAppStore.getState().isExportingText).toBeFalsy(); }); it("uses default filename when title is null", async () => { @@ -173,7 +228,22 @@ describe(useMarkdownExport, () => { }); }); - it("handles export errors", async () => { + it("sanitizes filename allowing spaces, dashes, and dots", async () => { + vi.mocked(save).mockResolvedValue("/tmp/output.md"); + + const { result } = renderHook(() => useMarkdownExport()); + + await act(async () => { + await result.current("# Markdown", "Version 1.1 - Final Draft"); + }); + + expect(save).toHaveBeenCalledWith({ + filters: [{ name: "Markdown", extensions: ["md"] }], + defaultPath: "Version_1.1_-_Final_Draft.md", + }); + }); + + it("handles export errors and sets error state", async () => { vi.mocked(save).mockRejectedValue(new Error("Permission denied")); const { result } = renderHook(() => useMarkdownExport()); @@ -181,5 +251,8 @@ describe(useMarkdownExport, () => { await act(async () => { await expect(result.current("# Markdown", "Test")).rejects.toThrow("Permission denied"); }); + + expect(useAppStore.getState().isExportingText).toBeFalsy(); + expect(useAppStore.getState().textExportError).toBe("Permission denied"); }); }); diff --git a/src/components/export/ExportDialog/ExportDialog.tsx b/src/components/export/ExportDialog/ExportDialog.tsx index 2da3936..028c136 100644 --- a/src/components/export/ExportDialog/ExportDialog.tsx +++ b/src/components/export/ExportDialog/ExportDialog.tsx @@ -1,6 +1,3 @@ -// TODO: make the options section collapsible -// TODO: pin the toolbar/make sticky or render above the scrollable portion of the preview -// FIXME: Fit Page and Fit Width do the same thing import { Button } from "$components/Button"; import { Dialog } from "$components/Dialog"; import { PdfPreviewPanel } from "$components/export/preview/PdfPreview"; diff --git a/src/components/export/ExportDialog/ExportOptions.tsx b/src/components/export/ExportDialog/ExportOptions.tsx index 5512e08..8662baa 100644 --- a/src/components/export/ExportDialog/ExportOptions.tsx +++ b/src/components/export/ExportDialog/ExportOptions.tsx @@ -1,10 +1,10 @@ import { ORIENTATIONS, PAGE_SIZES } from "$pdf/constants"; -import { MarginSide, Orientation, PageSize, type PdfExportOptions } from "$pdf/types"; +import { MarginSide, Orientation, PageSize } from "$pdf/types"; import { usePdfDialogUiState } from "$state/selectors"; import { useCallback } from "react"; -const PdfExportDialogPageSize = ({ options }: { options: PdfExportOptions }) => { - const { setPageSize } = usePdfDialogUiState(); +function PdfExportDialogPageSize() { + const { setPageSize, options } = usePdfDialogUiState(); const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { setPageSize(event.target.value as PageSize); @@ -21,10 +21,10 @@ const PdfExportDialogPageSize = ({ options }: { options: PdfExportOptions }) => ); -}; +} -const PdfExportDialogOrientation = ({ options }: { options: PdfExportOptions }) => { - const { setOrientation } = usePdfDialogUiState(); +function PdfExportDialogOrientation() { + const { setOrientation, options } = usePdfDialogUiState(); const handleOrientationChange = useCallback((event: React.ChangeEvent) => { setOrientation(event.target.value as Orientation); @@ -43,10 +43,10 @@ const PdfExportDialogOrientation = ({ options }: { options: PdfExportOptions }) ); -}; +} -const PdfExportDialogFontSize = ({ options }: { options: PdfExportOptions }) => { - const { setFontSize } = usePdfDialogUiState(); +function PdfExportDialogFontSize() { + const { setFontSize, options } = usePdfDialogUiState(); const handleFontSizeChange = useCallback((event: React.ChangeEvent) => { setFontSize(parseInt(event.target.value, 10)); @@ -65,9 +65,9 @@ const PdfExportDialogFontSize = ({ options }: { options: PdfExportOptions }) => className="w-full" /> ); -}; +} -const PdfMarginField = ({ side, value }: { side: MarginSide; value: number }) => { +function PdfMarginField({ side, value }: { side: MarginSide; value: number }) { const { setMargin } = usePdfDialogUiState(); const onChange = useCallback((event: React.ChangeEvent) => { @@ -86,22 +86,26 @@ const PdfMarginField = ({ side, value }: { side: MarginSide; value: number }) => className="w-full px-2 py-1 bg-layer-02 border border-border-subtle rounded text-text-primary text-sm" /> ); -}; +} -const PdfExportDialogMargins = ({ options }: { options: PdfExportOptions }) => ( -
-

Margins (px)

-
- - - - +function PdfExportDialogMargins() { + const { options } = usePdfDialogUiState(); + + return ( +
+

Margins (px)

+
+ + + + +
-
-); + ); +} -const PdfExportDialogHeaderFooter = ({ options }: { options: PdfExportOptions }) => { - const { setIncludeHeader, setIncludeFooter } = usePdfDialogUiState(); +function PdfExportDialogHeaderFooter() { + const { setIncludeHeader, setIncludeFooter, options } = usePdfDialogUiState(); const onHeaderChange = useCallback((event: React.ChangeEvent) => { setIncludeHeader(event.target.checked); @@ -123,20 +127,16 @@ const PdfExportDialogHeaderFooter = ({ options }: { options: PdfExportOptions })
); -}; - -export const PdfExportDialogOptions = () => { - const { options } = usePdfDialogUiState(); - - return ( -
-
- - - - - -
+} + +export const PdfExportDialogOptions = () => ( +
+
+ + + + +
- ); -}; +
+); diff --git a/src/hooks/useTextExport.tsx b/src/hooks/useTextExport.tsx index 246aaa5..9a1558a 100644 --- a/src/hooks/useTextExport.tsx +++ b/src/hooks/useTextExport.tsx @@ -9,6 +9,14 @@ import { writeFile } from "@tauri-apps/plugin-fs"; import * as logger from "@tauri-apps/plugin-log"; import { useCallback } from "react"; +function sanitizeExportFilename(title: string, extension: string): string { + const sanitized = title.replaceAll(/[^\w\s.-]/g, "").replaceAll(/\s+/g, "_").replaceAll(/_+/g, "_").replaceAll( + /^_|_$/g, + "", + ); + return sanitized ? `${sanitized}.${extension}` : `document.${extension}`; +} + export type ExportTextFn = (result: TextExportResultType) => Promise; export function useTextExport(): ExportTextFn { @@ -20,7 +28,7 @@ export function useTextExport(): ExportTextFn { try { const textBytes = new TextEncoder().encode(result.text); const uint8Array = new Uint8Array(textBytes); - const defaultFileName = result.title ? `${result.title.replaceAll(/[^a-zA-Z0-9]/g, "_")}.txt` : "document.txt"; + const defaultFileName = result.title ? sanitizeExportFilename(result.title, "txt") : "document.txt"; const filePath = await save({ filters: [{ name: "Text", extensions: ["txt"] }], defaultPath: defaultFileName }); if (!filePath) { @@ -48,11 +56,15 @@ export function useTextExport(): ExportTextFn { export type ExportMarkdownFn = (text: string, title: string | null) => Promise; export function useMarkdownExport(): ExportMarkdownFn { + const { startTextExport, finishTextExport, failTextExport } = useTextExportActions(); + const exportMarkdown = useCallback(async (text: string, title: string | null) => { + startTextExport(); + try { const textBytes = new TextEncoder().encode(text); const uint8Array = new Uint8Array(textBytes); - const defaultFileName = title ? `${title.replaceAll(/[^a-zA-Z0-9]/g, "_")}.md` : "document.md"; + const defaultFileName = title ? sanitizeExportFilename(title, "md") : "document.md"; const filePath = await save({ filters: [{ name: "Markdown", extensions: ["md"] }], defaultPath: defaultFileName, @@ -60,19 +72,22 @@ export function useMarkdownExport(): ExportMarkdownFn { if (!filePath) { logger.info("Markdown export canceled before writing file"); + finishTextExport(); return false; } await writeFile(filePath, uint8Array); showSuccessToast("Markdown saved successfully"); + finishTextExport(); return true; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to export markdown"; + failTextExport(errorMessage); showErrorToast(`Export failed: ${errorMessage}`); throw err; } - }, []); + }, [failTextExport, finishTextExport, startTextExport]); return exportMarkdown; } -- 2.51.2