add DOCX support (requires libreoffice) + upload test

This commit is contained in:
Richard Roberson 2025-03-04 22:47:37 -07:00
parent 4c5866c02b
commit 520236b95c
12 changed files with 172 additions and 10 deletions

View file

@ -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

View file

@ -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

View file

@ -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

1
package-lock.json generated
View file

@ -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,

View file

@ -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<void> {
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 }
);
}
}

View file

@ -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<string | null>(null);
const convertDocxToPdf = async (file: File): Promise<File> => {
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) {
<p className="text-sm sm:text-lg font-semibold text-foreground">
Uploading file...
</p>
) : isConverting ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">
Converting DOCX to PDF...
</p>
) : (
<>
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
{isDragActive ? 'Drop your file here' : 'Drop your file here, or click to select'}
</p>
<p className="text-xs sm:text-sm text-muted">
PDF and EPUB files are accepted
{isDev ? 'PDF, EPUB, and DOCX files are accepted' : 'PDF and EPUB files are accepted'}
</p>
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
</>

View file

@ -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;
}

BIN
tests/files/sample.docx Normal file

Binary file not shown.

View file

@ -1,6 +1,6 @@
import { Page, expect } from '@playwright/test';
const DIR = './public/';
const DIR = './tests/files/';
/**
* Upload a sample epub or pdf

View file

@ -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();