From 622daba7806cb708446d032bb78dafb8139dd015 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Thu, 26 Jun 2025 17:00:45 +0100 Subject: [PATCH] Added convert tool --- .../public/locales/en-GB/translation.json | 65 ++- frontend/src/pages/HomePage.tsx | 4 + frontend/src/tools/Convert.tsx | 423 ++++++++++++++++++ 3 files changed, 479 insertions(+), 13 deletions(-) create mode 100644 frontend/src/tools/Convert.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 081f746ee..17db4bb4a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -347,6 +347,10 @@ "title": "Rotate", "desc": "Easily rotate your PDFs." }, + "convert": { + "title": "Convert", + "desc": "Convert files between different formats", + }, "imageToPdf": { "title": "Image to PDF", "desc": "Convert a image (PNG, JPEG, GIF) to PDF." @@ -643,6 +647,44 @@ "selectAngle": "Select rotation angle (in multiples of 90 degrees):", "submit": "Rotate" }, + "convert":{ + "title": "Convert", + "desc": "Convert files between different formats", + "convertFrom": "Convert from", + "convertTo": "Convert to", + "outputOptions": "Output Options", + "pdfOptions": "PDF Options", + "imageOptions": "Image Options", + "colorType": "Color Type", + "color": "Color", + "greyscale": "Greyscale", + "blackwhite": "Black & White", + "dpi": "DPI", + "output": "Output", + "single": "Single merged image", + "multiple": "Multiple images (one per page)", + "fileFormat": "File Format", + "wordDoc": "Word Document", + "wordDocExt": "Word Document (.docx)", + "odtExt": "OpenDocument Text (.odt)", + "pptExt": "PowerPoint (.pptx)", + "odpExt": "OpenDocument Presentation (.odp)", + "txtExt": "Plain Text (.txt)", + "rtfExt": "Rich Text Format (.rtf)", + "selectedFiles": "Selected files", + "noFileSelected": "No file selected. Use the file panel to add files.", + "convertFiles": "Convert Files", + "converting": "Converting...", + "downloadConverted": "Download Converted File", + "errorNoFiles": "Please select at least one file to convert.", + "errorNoFormat": "Please select both source and target formats.", + "errorNotSupported": "Conversion from {{from}} to {{to}} is not supported.", + "images": "Images", + "officeDocs": "Office Documents (Word, Excel, PowerPoint)", + "imagesExt": "Images (JPG, PNG, etc.)", + "markdown": "Markdown", + "textRtf": "Text/RTF" + }, "imageToPdf": { "tags": "conversion,img,jpg,picture,photo" }, @@ -1572,18 +1614,6 @@ "pageEditor": "Page Editor", "fileManager": "File Manager" }, - "fileManager": { - "dragDrop": "Drag & Drop files here", - "clickToUpload": "Click to upload files", - "selectedFiles": "Selected Files", - "clearAll": "Clear All", - "storage": "Storage", - "filesStored": "files stored", - "storageError": "Storage error occurred", - "storageLow": "Storage is running low. Consider removing old files.", - "uploadError": "Failed to upload some files.", - "supportMessage": "Powered by browser database storage for unlimited capacity" - }, "pageEditor": { "title": "Page Editor", "save": "Save Changes", @@ -1655,7 +1685,16 @@ "failedToLoad": "Failed to load file to active set.", "storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.", "clearAll": "Clear All", - "reloadFiles": "Reload Files" + "reloadFiles": "Reload Files", + "dragDrop": "Drag & Drop files here", + "clickToUpload": "Click to upload files", + "selectedFiles": "Selected Files", + "storage": "Storage", + "filesStored": "files stored", + "storageError": "Storage error occurred", + "storageLow": "Storage is running low. Consider removing old files.", + "supportMessage": "Powered by browser database storage for unlimited capacity", + "noFileSelected": "No files selected" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 515676bd8..4a6898d05 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -7,6 +7,7 @@ import { fileStorage } from "../services/fileStorage"; import AddToPhotosIcon from "@mui/icons-material/AddToPhotos"; import ContentCutIcon from "@mui/icons-material/ContentCut"; import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; +import SyncAltIcon from '@mui/icons-material/SyncAlt'; import { Group, Paper, Box, Button, useMantineTheme, Container } from "@mantine/core"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; import rainbowStyles from '../styles/rainbow.module.css'; @@ -24,6 +25,7 @@ import CompressPdfPanel from "../tools/Compress"; import MergePdfPanel from "../tools/Merge"; import ToolRenderer from "../components/tools/ToolRenderer"; import QuickAccessBar from "../components/shared/QuickAccessBar"; +import ConvertPanel from "../tools/Convert"; type ToolRegistryEntry = { icon: React.ReactNode; @@ -41,6 +43,7 @@ const baseToolRegistry = { split: { icon: , component: SplitPdfPanel, view: "viewer" }, compress: { icon: , component: CompressPdfPanel, view: "viewer" }, merge: { icon: , component: MergePdfPanel, view: "fileManager" }, + convert: { icon: , component: ConvertPanel, view: "fileManager" }, }; export default function HomePage() { @@ -114,6 +117,7 @@ export default function HomePage() { split: { ...baseToolRegistry.split, name: t("home.split.title", "Split PDF") }, compress: { ...baseToolRegistry.compress, name: t("home.compressPdfs.title", "Compress PDF") }, merge: { ...baseToolRegistry.merge, name: t("home.merge.title", "Merge PDFs") }, + convert: { ...baseToolRegistry.convert, name: t("home.convert.title", "Convert") }, }; // Handle tool selection diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx new file mode 100644 index 000000000..261ae1b3c --- /dev/null +++ b/frontend/src/tools/Convert.tsx @@ -0,0 +1,423 @@ +import React, { useState, useEffect } from "react"; +import { + Paper, + Button, + Stack, + Text, + Group, + Alert, + Divider, + Select, + TextInput, + Checkbox, + NumberInput, + Center, +} from "@mantine/core"; +import { ArrowDownward } from "@mui/icons-material"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { FileWithUrl } from "../types/file"; +import { fileStorage } from "../services/fileStorage"; + +export interface ConvertPanelProps { + files: FileWithUrl[]; + setDownloadUrl: (url: string) => void; + params: { + fromFormat: string; + toFormat: string; + imageOptions?: { + colorType: string; + dpi: number; + singleOrMultiple: string; + }; + officeOptions?: { + outputFormat: string; + }; + }; + updateParams: (newParams: Partial) => void; +} + +const ConvertPanel: React.FC = ({ files, setDownloadUrl, params, updateParams }) => { + const { t } = useTranslation(); + const [downloadUrl, setLocalDownloadUrl] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const [fromFormat, setFromFormat] = useState(params.fromFormat || ""); + const [toFormat, setToFormat] = useState(params.toFormat || ""); + const [colorType, setColorType] = useState(params.imageOptions?.colorType || "color"); + const [dpi, setDpi] = useState(params.imageOptions?.dpi || 300); + const [singleOrMultiple, setSingleOrMultiple] = useState(params.imageOptions?.singleOrMultiple || "multiple"); + const [outputFormat, setOutputFormat] = useState(params.officeOptions?.outputFormat || ""); + + useEffect(() => { + if (files.length > 0 && !fromFormat) { + const firstFile = files[0]; + const detectedFormat = detectFileFormat(firstFile.name); + setFromFormat(detectedFormat); + updateParams({ fromFormat: detectedFormat }); + } + }, [files, fromFormat]); + + const detectFileFormat = (filename: string): string => { + const extension = filename.split('.').pop()?.toLowerCase(); + switch (extension) { + case 'pdf': return 'pdf'; + case 'doc': case 'docx': return 'office'; + case 'xls': case 'xlsx': return 'office'; + case 'ppt': case 'pptx': return 'office'; + case 'odt': case 'ods': case 'odp': return 'office'; + case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': case 'tiff': case 'webp': return 'image'; + case 'html': case 'htm': return 'html'; + case 'md': return 'markdown'; + case 'txt': case 'rtf': return 'text'; + default: return 'unknown'; + } + }; + + const getAvailableToFormats = (from: string): string[] => { + switch (from) { + case 'pdf': + return ['image', 'office-word', 'office-presentation', 'office-text', 'html', 'xml']; + case 'office': + return ['pdf']; + case 'image': + return ['pdf']; + case 'html': + return ['pdf']; + case 'markdown': + return ['pdf']; + case 'text': + return ['pdf']; + default: + return []; + } + }; + + const getApiEndpoint = (from: string, to: string): string => { + if (from === 'office' && to === 'pdf') { + return '/api/v1/convert/file/pdf'; + } else if (from === 'pdf' && to === 'image') { + return '/api/v1/convert/pdf/img'; + } else if (from === 'image' && to === 'pdf') { + return '/api/v1/convert/img/pdf'; + } else if (from === 'pdf' && to === 'office-word') { + return '/api/v1/convert/pdf/word'; + } else if (from === 'pdf' && to === 'office-presentation') { + return '/api/v1/convert/pdf/presentation'; + } else if (from === 'pdf' && to === 'office-text') { + return '/api/v1/convert/pdf/text'; + } else if (from === 'pdf' && to === 'html') { + return '/api/v1/convert/pdf/html'; + } else if (from === 'pdf' && to === 'xml') { + return '/api/v1/convert/pdf/xml'; + } else if (from === 'html' && to === 'pdf') { + return '/api/v1/convert/html/pdf'; + } else if (from === 'markdown' && to === 'pdf') { + return '/api/v1/convert/markdown/pdf'; + } + return ''; + }; + + const handleConvert = async () => { + if (files.length === 0) { + setErrorMessage(t("convert.errorNoFiles", "Please select at least one file to convert.")); + return; + } + + if (!fromFormat || !toFormat) { + setErrorMessage(t("convert.errorNoFormat", "Please select both source and target formats.")); + return; + } + + const endpoint = getApiEndpoint(fromFormat, toFormat); + if (!endpoint) { + setErrorMessage( + t("convert.errorNotSupported", { from: fromFormat, to: toFormat, defaultValue: `Conversion from ${fromFormat} to ${toFormat} is not supported.` }) + ); + return; + } + + const formData = new FormData(); + + // Handle IndexedDB files + for (const file of files) { + if (!file.id) { + console.warn("File without ID found, skipping:", file.name); + continue; + } + const storedFile = await fileStorage.getFile(file.id); + if (!storedFile) { + console.warn("Stored file not found in IndexedDB for ID:", file.id); + continue; + } + const blob = new Blob([storedFile.data], { type: storedFile.type }); + const actualFile = new File([blob], storedFile.name, { + type: storedFile.type, + lastModified: storedFile.lastModified, + }); + formData.append("fileInput", actualFile); + + } + + // Add conversion-specific parameters + if (toFormat === 'image') { + formData.append("imageFormat", "png"); + formData.append("colorType", colorType); + formData.append("dpi", dpi.toString()); + formData.append("singleOrMultiple", singleOrMultiple); + } else if (fromFormat === 'pdf' && toFormat.startsWith('office')) { + if (toFormat === 'office-word') { + formData.append("outputFormat", outputFormat || "docx"); + } else if (toFormat === 'office-presentation') { + formData.append("outputFormat", outputFormat || "pptx"); + } else if (toFormat === 'office-text') { + formData.append("outputFormat", outputFormat || "txt"); + } + } else if (fromFormat === 'image' && toFormat === 'pdf') { + formData.append("fitOption", "fillPage"); + formData.append("colorType", colorType); + formData.append("autoRotate", "true"); + } + + setIsLoading(true); + setErrorMessage(null); + + try { + console.log("Converting files from", fromFormat, "to", toFormat, "using endpoint:", endpoint); + console.log("Form data:", Array.from(formData.entries()).map(([key, value]) => `${key}: ${value}`)); + const response = await fetch(endpoint, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Conversion failed: ${errorText}`); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + setDownloadUrl(url); + setLocalDownloadUrl(url); + } catch (error: any) { + setErrorMessage(error.message || "Unknown error occurred."); + } finally { + setIsLoading(false); + } + }; + + const handleFromFormatChange = (value: string | null) => { + if (value) { + setFromFormat(value); + setToFormat(""); + // Reset all format-specific options when source format changes + setColorType("color"); + setDpi(300); + setSingleOrMultiple("multiple"); + setOutputFormat(""); + updateParams({ + fromFormat: value, + toFormat: "", + imageOptions: { colorType: "color", dpi: 300, singleOrMultiple: "multiple" }, + officeOptions: { outputFormat: "" } + }); + } + }; + + const handleToFormatChange = (value: string | null) => { + if (value) { + setToFormat(value); + // Reset format-specific options when target format changes + setColorType("color"); + setDpi(300); + setSingleOrMultiple("multiple"); + setOutputFormat(""); + updateParams({ + fromFormat, + toFormat: value, + imageOptions: { colorType: "color", dpi: 300, singleOrMultiple: "multiple" }, + officeOptions: { outputFormat: "" } + }); + } + }; + + return ( + + + {t("convert.desc", "Convert files between different formats")} + + + + + +
+ + {t("convert.convertFrom", "Convert from")}: + + ({ + value: format, + label: format === 'office-word' ? t("convert.wordDoc", "Word Document") : + format === 'office-presentation' ? t("PowerPoint Presentation", "PowerPoint Presentation") : + format === 'office-text' ? t("convert.textRtf", "Text/RTF") : + format === 'image' ? t("convert.images", "Images") : + format === 'pdf' ? 'PDF' : + format.charAt(0).toUpperCase() + format.slice(1) + }))} + disabled={!fromFormat} + /> +
+
+ + {(toFormat === 'image' || (fromFormat === 'pdf' && toFormat?.startsWith('office')) || (fromFormat === 'image' && toFormat === 'pdf')) && ( + + )} + + {toFormat === 'image' && ( + + {t("convert.imageOptions", "Image Options")}: + + val && setSingleOrMultiple(val)} + data={[ + { value: 'single', label: t("convert.single") }, + { value: 'multiple', label: t("convert.multiple") }, + ]} + /> + + )} + + {fromFormat === 'pdf' && toFormat?.startsWith('office') && ( + + {t("convert.outputOptions", "Output Options")}: + val && setColorType(val)} + data={[ + { value: 'color', label: t("convert.color") }, + { value: 'greyscale', label: t("convert.greyscale") }, + { value: 'blackwhite', label: t("convert.blackwhite") }, + ]} + /> + + )} + + +
+ + {t("convert.selectedFiles", "Selected files")}: ({files.length}): + + + {files.map((file, index) => ( + + {file.name} + + ))} + {files.length === 0 && ( + + {t("convert.noFileSelected", "No files selected for conversion. Please add files to convert.")} + + )} + +
+ + + + {errorMessage && ( + + {errorMessage} + + )} + + {downloadUrl && ( + + )} +
+
+ ); +}; + +export default ConvertPanel;