add DOCX support (requires libreoffice) + upload test
This commit is contained in:
parent
4c5866c02b
commit
520236b95c
12 changed files with 172 additions and 10 deletions
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1
package-lock.json
generated
|
|
@ -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,
|
||||
|
|
|
|||
103
src/app/api/documents/docx-to-pdf/route.ts
Normal file
103
src/app/api/documents/docx-to-pdf/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
BIN
tests/files/sample.docx
Normal file
Binary file not shown.
|
|
@ -1,6 +1,6 @@
|
|||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
const DIR = './public/';
|
||||
const DIR = './tests/files/';
|
||||
|
||||
/**
|
||||
* Upload a sample epub or pdf
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue