diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 62b82c8..c8a4230 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -13,6 +13,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: lts/* + - name: Install Deps (FFmpeg is install through Playwright) + run: | + sudo apt-get update + sudo apt-get install -y libreoffice - name: Install dependencies run: npm ci - name: Install Playwright Browsers diff --git a/Dockerfile b/Dockerfile index e1a3105..bfbde5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # Use Node.js slim image FROM node:current-alpine -# Add ffmpeg using Alpine package manager -RUN apk add --no-cache ffmpeg +# Add ffmpeg and libreoffice using Alpine package manager +RUN apk add --no-cache ffmpeg libreoffice # Create app directory WORKDIR /app diff --git a/README.md b/README.md index cf00d15..d08d0d3 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,11 @@ https://github.com/user-attachments/assets/262b9a01-c608-4fee-893c-9461dd48c99b ### Prerequisites - Node.js & npm (recommended: use [nvm](https://github.com/nvm-sh/nvm)) +Optionally required for different features: - [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only) +- [libreoffice](https://www.libreoffice.org) (required for DOCX files) + - On Linux: `sudo apt install ffmpeg libreoffice` + - On MacOS: `brew install ffmpeg libreoffice` ### Steps @@ -129,8 +133,6 @@ https://github.com/user-attachments/assets/262b9a01-c608-4fee-893c-9461dd48c99b Visit [http://localhost:3003](http://localhost:3003) to run the app. - > Dev server runs on port 3000 by default, while the production server runs on port 3003. - ## 💡 Feature requests diff --git a/package-lock.json b/package-lock.json index 7890d28..28d94a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3075,6 +3075,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/api/documents/docx-to-pdf/route.ts b/src/app/api/documents/docx-to-pdf/route.ts new file mode 100644 index 0000000..df3a382 --- /dev/null +++ b/src/app/api/documents/docx-to-pdf/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { writeFile, mkdir, unlink, readFile } from 'fs/promises'; +import { spawn } from 'child_process'; +import path from 'path'; +import { existsSync } from 'fs'; +import { randomUUID } from 'crypto'; + +const TEMP_DIR = path.join(process.cwd(), 'temp'); + +async function ensureTempDir() { + if (!existsSync(TEMP_DIR)) { + await mkdir(TEMP_DIR, { recursive: true }); + } +} + +async function convertDocxToPdf(inputPath: string, outputDir: string): Promise { + return new Promise((resolve, reject) => { + const process = spawn('soffice', [ + '--headless', + '--convert-to', 'pdf', + '--outdir', outputDir, + inputPath + ]); + + process.on('error', (error) => { + reject(error); + }); + + process.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`LibreOffice conversion failed with code ${code}`)); + } + }); + }); +} + +export async function POST(req: NextRequest) { + try { + await ensureTempDir(); + + const formData = await req.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json( + { error: 'No file provided' }, + { status: 400 } + ); + } + + if (!file.name.toLowerCase().endsWith('.docx')) { + return NextResponse.json( + { error: 'File must be a .docx document' }, + { status: 400 } + ); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const tempId = randomUUID(); + const inputPath = path.join(TEMP_DIR, `${tempId}.docx`); + const outputPath = path.join(TEMP_DIR, `${tempId}.pdf`); + + // Write the uploaded file + await writeFile(inputPath, buffer); + + try { + // Convert the file + await convertDocxToPdf(inputPath, TEMP_DIR); + + // Return the PDF file + const pdfContent = await readFile(outputPath); + + // Clean up temp files + await Promise.all([ + unlink(inputPath), + unlink(outputPath) + ]).catch(console.error); + + return new NextResponse(pdfContent, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${path.parse(file.name).name}.pdf"` + } + }); + } catch (error) { + // Clean up temp files on error + await Promise.all([ + unlink(inputPath), + unlink(outputPath) + ]).catch(console.error); + + throw error; + } + } catch (error) { + console.error('Error converting DOCX to PDF:', error); + return NextResponse.json( + { error: 'Failed to convert document' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/components/DocumentUploader.tsx b/src/components/DocumentUploader.tsx index a778a43..b755231 100644 --- a/src/components/DocumentUploader.tsx +++ b/src/components/DocumentUploader.tsx @@ -5,6 +5,8 @@ import { useDropzone } from 'react-dropzone'; import { UploadIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; +const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; + interface DocumentUploaderProps { className?: string; } @@ -12,8 +14,28 @@ interface DocumentUploaderProps { export function DocumentUploader({ className = '' }: DocumentUploaderProps) { const { addPDFDocument: addPDF, addEPUBDocument: addEPUB } = useDocuments(); const [isUploading, setIsUploading] = useState(false); + const [isConverting, setIsConverting] = useState(false); const [error, setError] = useState(null); + const convertDocxToPdf = async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/documents/docx-to-pdf', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('Failed to convert DOCX to PDF'); + } + + const pdfBlob = await response.blob(); + return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), { + type: 'application/pdf', + }); + }; + const onDrop = useCallback(async (acceptedFiles: File[]) => { const file = acceptedFiles[0]; if (!file) return; @@ -26,12 +48,18 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) { await addPDF(file); } else if (file.type === 'application/epub+zip') { await addEPUB(file); + } else if (isDev && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + setIsUploading(false); + setIsConverting(true); + const pdfFile = await convertDocxToPdf(file); + await addPDF(pdfFile); } } catch (err) { setError('Failed to upload file. Please try again.'); console.error('Upload error:', err); } finally { setIsUploading(false); + setIsConverting(false); } }, [addPDF, addEPUB]); @@ -39,10 +67,13 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) { onDrop, accept: { 'application/pdf': ['.pdf'], - 'application/epub+zip': ['.epub'] + 'application/epub+zip': ['.epub'], + ...(isDev ? { + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'] + } : {}) }, multiple: false, - disabled: isUploading + disabled: isUploading || isConverting }); return ( @@ -52,7 +83,7 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) { w-full py-5 px-3 border-2 border-dashed rounded-lg ${isDragActive ? 'border-accent bg-base' : 'border-muted'} transform trasition-transform duration-200 ease-in-out hover:scale-[1.008] - ${isUploading ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base'} + ${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base'} ${className} `} > @@ -64,13 +95,17 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {

Uploading file...

+ ) : isConverting ? ( +

+ Converting DOCX to PDF... +

) : ( <>

{isDragActive ? 'Drop your file here' : 'Drop your file here, or click to select'}

- PDF and EPUB files are accepted + {isDev ? 'PDF, EPUB, and DOCX files are accepted' : 'PDF and EPUB files are accepted'}

{error &&

{error}

} diff --git a/src/types/documents.ts b/src/types/documents.ts index 07cbd41..34eca60 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -1,4 +1,4 @@ -export type DocumentType = 'pdf' | 'epub'; +export type DocumentType = 'pdf' | 'epub' | 'docx'; export interface BaseDocument { id: string; @@ -7,6 +7,7 @@ export interface BaseDocument { lastModified: number; type: DocumentType; folderId?: string; + isConverting?: boolean; } export interface PDFDocument extends BaseDocument { @@ -19,6 +20,11 @@ export interface EPUBDocument extends BaseDocument { data: ArrayBuffer; } +export interface DOCXDocument extends BaseDocument { + type: 'docx'; + data: ArrayBuffer; +} + export interface DocumentListDocument extends BaseDocument { type: DocumentType; } diff --git a/tests/files/sample.docx b/tests/files/sample.docx new file mode 100644 index 0000000..b172c43 Binary files /dev/null and b/tests/files/sample.docx differ diff --git a/public/sample.epub b/tests/files/sample.epub similarity index 100% rename from public/sample.epub rename to tests/files/sample.epub diff --git a/public/sample.pdf b/tests/files/sample.pdf similarity index 100% rename from public/sample.pdf rename to tests/files/sample.pdf diff --git a/tests/helpers.ts b/tests/helpers.ts index daf48f6..6b07ef8 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,6 +1,6 @@ import { Page, expect } from '@playwright/test'; -const DIR = './public/'; +const DIR = './tests/files/'; /** * Upload a sample epub or pdf diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index d6c7906..bb37755 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -16,6 +16,17 @@ test.describe('Document Upload Tests', () => { await expect(page.getByText('sample.epub')).toBeVisible({ timeout: 10000 }); }); + test('uploads and converts a DOCX document', async ({ page }) => { + // This test only runs in development mode + test.skip(process.env.NODE_ENV === 'production', 'DOCX upload is only available in development mode'); + + await uploadFile(page, 'sample.docx'); + // Should see the converting message + await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible(); + // After conversion, should see the PDF with the same name + await expect(page.getByText('sample.pdf')).toBeVisible({ timeout: 15000 }); + }); + test('displays a PDF document', async ({ page }) => { await uploadAndDisplay(page, 'sample.pdf'); await expect(page.locator('.react-pdf__Document')).toBeVisible();