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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
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
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Use Node.js slim image
|
# Use Node.js slim image
|
||||||
FROM node:current-alpine
|
FROM node:current-alpine
|
||||||
|
|
||||||
# Add ffmpeg using Alpine package manager
|
# Add ffmpeg and libreoffice using Alpine package manager
|
||||||
RUN apk add --no-cache ffmpeg
|
RUN apk add --no-cache ffmpeg libreoffice
|
||||||
|
|
||||||
# Create app directory
|
# Create app directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,11 @@ https://github.com/user-attachments/assets/262b9a01-c608-4fee-893c-9461dd48c99b
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js & npm (recommended: use [nvm](https://github.com/nvm-sh/nvm))
|
- 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)
|
- [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
|
### 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.
|
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
|
## 💡 Feature requests
|
||||||
|
|
||||||
|
|
|
||||||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -3075,6 +3075,7 @@
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"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 { UploadIcon } from '@/components/icons/Icons';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
|
|
||||||
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
interface DocumentUploaderProps {
|
interface DocumentUploaderProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -12,8 +14,28 @@ interface DocumentUploaderProps {
|
||||||
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||||
const { addPDFDocument: addPDF, addEPUBDocument: addEPUB } = useDocuments();
|
const { addPDFDocument: addPDF, addEPUBDocument: addEPUB } = useDocuments();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isConverting, setIsConverting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||||
const file = acceptedFiles[0];
|
const file = acceptedFiles[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
@ -26,12 +48,18 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||||
await addPDF(file);
|
await addPDF(file);
|
||||||
} else if (file.type === 'application/epub+zip') {
|
} else if (file.type === 'application/epub+zip') {
|
||||||
await addEPUB(file);
|
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) {
|
} catch (err) {
|
||||||
setError('Failed to upload file. Please try again.');
|
setError('Failed to upload file. Please try again.');
|
||||||
console.error('Upload error:', err);
|
console.error('Upload error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
setIsConverting(false);
|
||||||
}
|
}
|
||||||
}, [addPDF, addEPUB]);
|
}, [addPDF, addEPUB]);
|
||||||
|
|
||||||
|
|
@ -39,10 +67,13 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||||
onDrop,
|
onDrop,
|
||||||
accept: {
|
accept: {
|
||||||
'application/pdf': ['.pdf'],
|
'application/pdf': ['.pdf'],
|
||||||
'application/epub+zip': ['.epub']
|
'application/epub+zip': ['.epub'],
|
||||||
|
...(isDev ? {
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx']
|
||||||
|
} : {})
|
||||||
},
|
},
|
||||||
multiple: false,
|
multiple: false,
|
||||||
disabled: isUploading
|
disabled: isUploading || isConverting
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -52,7 +83,7 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||||
w-full py-5 px-3 border-2 border-dashed rounded-lg
|
w-full py-5 px-3 border-2 border-dashed rounded-lg
|
||||||
${isDragActive ? 'border-accent bg-base' : 'border-muted'}
|
${isDragActive ? 'border-accent bg-base' : 'border-muted'}
|
||||||
transform trasition-transform duration-200 ease-in-out hover:scale-[1.008]
|
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}
|
${className}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|
@ -64,13 +95,17 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||||
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
||||||
Uploading file...
|
Uploading file...
|
||||||
</p>
|
</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">
|
<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'}
|
{isDragActive ? 'Drop your file here' : 'Drop your file here, or click to select'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs sm:text-sm text-muted">
|
<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>
|
</p>
|
||||||
{error && <p className="mt-2 text-sm text-red-500">{error}</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 {
|
export interface BaseDocument {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -7,6 +7,7 @@ export interface BaseDocument {
|
||||||
lastModified: number;
|
lastModified: number;
|
||||||
type: DocumentType;
|
type: DocumentType;
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
|
isConverting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PDFDocument extends BaseDocument {
|
export interface PDFDocument extends BaseDocument {
|
||||||
|
|
@ -19,6 +20,11 @@ export interface EPUBDocument extends BaseDocument {
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DOCXDocument extends BaseDocument {
|
||||||
|
type: 'docx';
|
||||||
|
data: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocumentListDocument extends BaseDocument {
|
export interface DocumentListDocument extends BaseDocument {
|
||||||
type: DocumentType;
|
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';
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
const DIR = './public/';
|
const DIR = './tests/files/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a sample epub or pdf
|
* Upload a sample epub or pdf
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,17 @@ test.describe('Document Upload Tests', () => {
|
||||||
await expect(page.getByText('sample.epub')).toBeVisible({ timeout: 10000 });
|
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 }) => {
|
test('displays a PDF document', async ({ page }) => {
|
||||||
await uploadAndDisplay(page, 'sample.pdf');
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
await expect(page.locator('.react-pdf__Document')).toBeVisible();
|
await expect(page.locator('.react-pdf__Document')).toBeVisible();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue