feat: Add EPUB Export Support

This commit is contained in:
Rajarshee Chatterjee
2025-12-08 23:41:48 +05:30
parent 2db6d4ac8f
commit 36b06d1de8
7 changed files with 653 additions and 85 deletions
+57 -19
View File
@@ -1,5 +1,12 @@
import React, { useState, useEffect } from 'react';
import { BookOpen, ChevronLeft, ChevronRight, Copy, Zap } from 'lucide-react';
import {
BookOpen,
ChevronLeft,
ChevronRight,
Copy,
Zap,
Download,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
@@ -14,6 +21,7 @@ import {
} from '@/components/ui/tooltip';
import { useAppStore } from '@/store';
import { Plugin } from '@/types/plugin';
import { useEpubExport } from '@/hooks/useEpubExport';
type ParseNovelSectionProps = {
onNavigateToParseChapter?: () => void;
@@ -38,6 +46,13 @@ export default function ParseNovelSection({
const [currentPage, setCurrentPage] = useState(1);
const [fetchError, setFetchError] = useState('');
const { exportEpub, isExporting } = useEpubExport({
plugin: plugin || null,
sourceNovel,
chapters,
novelPath,
});
const fetchNovelByPath = async (path: string) => {
if (plugin && path.trim()) {
setLoading(true);
@@ -267,24 +282,47 @@ export default function ParseNovelSection({
</div>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
onClick={() =>
copyToClipboard(sourceNovel.path, 'Novel path')
}
>
<Copy className="w-4 h-4" />
Copy Path
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy novel path to clipboard</p>
</TooltipContent>
</Tooltip>
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
onClick={() =>
copyToClipboard(sourceNovel.path, 'Novel path')
}
>
<Copy className="w-4 h-4" />
Copy Path
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy novel path to clipboard</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
onClick={exportEpub}
disabled={isExporting || chapters.length === 0}
>
<Download className="w-4 h-4" />
{isExporting ? 'Exporting...' : 'Export EPUB'}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{isExporting
? 'Exporting chapters to EPUB...'
: 'Export all chapters as EPUB file'}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
+151
View File
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { Plugin } from '@/types/plugin';
import { createEpub, downloadBlob } from '@/lib/epub';
interface UseEpubExportOptions {
plugin: Plugin.PluginBase | null;
sourceNovel: (Plugin.SourceNovel & { totalPages?: number }) | undefined;
chapters: Plugin.ChapterItem[];
novelPath: string;
}
export function useEpubExport({
plugin,
sourceNovel,
chapters,
novelPath,
}: UseEpubExportOptions) {
const [isExporting, setIsExporting] = useState(false);
const exportEpub = async () => {
if (!plugin || !sourceNovel || chapters.length === 0) {
toast.error('No novel or chapters available to export');
return;
}
setIsExporting(true);
const toastId = toast.loading('Starting EPUB export...', {
description: `Preparing to export ${chapters.length} chapters`,
});
try {
const allChapters: Plugin.ChapterItem[] = [];
if (sourceNovel.totalPages && sourceNovel.totalPages > 1) {
toast.loading('Fetching all chapters...', {
id: toastId,
description: `Found ${sourceNovel.totalPages} pages`,
});
for (let page = 1; page <= sourceNovel.totalPages; page++) {
try {
const pageResult = await (plugin as Plugin.PagePlugin).parsePage(
novelPath,
page.toString(),
);
allChapters.push(...pageResult.chapters);
toast.loading('Fetching chapters...', {
id: toastId,
description: `Page ${page}/${sourceNovel.totalPages} - ${allChapters.length} chapters collected`,
});
} catch (error) {
console.error(`Error fetching page ${page}:`, error);
}
}
} else {
allChapters.push(...chapters);
}
if (allChapters.length === 0) {
toast.error('No chapters found to export', { id: toastId });
setIsExporting(false);
return;
}
toast.loading('Fetching chapter content...', {
id: toastId,
description: `0/${allChapters.length} chapters processed`,
});
const chapterContents: Array<{
title: string;
content: string;
path: string;
}> = [];
for (let i = 0; i < allChapters.length; i++) {
const chapter = allChapters[i];
try {
const content = await plugin.parseChapter(chapter.path);
chapterContents.push({
title: chapter.name,
content: content || '<p>No content available</p>',
path: chapter.path,
});
const progress = Math.round(((i + 1) / allChapters.length) * 100);
toast.loading('Fetching chapter content...', {
id: toastId,
description: `${i + 1}/${allChapters.length} chapters processed (${progress}%)`,
});
} catch (error) {
console.error(`Error fetching chapter ${i + 1}:`, error);
chapterContents.push({
title: chapter.name,
content: `<p>Error: Failed to fetch chapter content</p>`,
path: chapter.path,
});
}
}
toast.loading('Generating EPUB file...', {
id: toastId,
description: 'Creating EPUB structure',
});
let coverUrl = sourceNovel.cover;
if (coverUrl && plugin.resolveUrl) {
coverUrl = plugin.resolveUrl(coverUrl, true);
} else if (
coverUrl &&
!coverUrl.startsWith('http://') &&
!coverUrl.startsWith('https://')
) {
coverUrl = coverUrl.startsWith('/') ? coverUrl : '/' + coverUrl;
}
const epubBlob = await createEpub(chapterContents, {
title: sourceNovel.name,
author: sourceNovel.author,
description: sourceNovel.summary,
cover: coverUrl,
language: 'en',
});
const filename = `${sourceNovel.name.replace(/[^a-z0-9]/gi, '_')}.epub`;
downloadBlob(epubBlob, filename);
toast.success('EPUB exported successfully!', {
id: toastId,
description: `Downloaded ${allChapters.length} chapters as ${filename}`,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to export EPUB';
toast.error('Export failed', {
id: toastId,
description: errorMessage,
});
console.error('Error exporting EPUB:', error);
} finally {
setIsExporting(false);
}
};
return {
exportEpub,
isExporting,
};
}
-36
View File
@@ -1,36 +0,0 @@
import { useCallback } from 'react';
export const useQueryParams = () => {
const getParam = useCallback((key: string): string | null => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(key);
}, []);
const setParam = useCallback((key: string, value: string) => {
const url = new URL(window.location.href);
url.searchParams.set(key, value);
window.history.pushState({}, '', url);
}, []);
const removeParam = useCallback((key: string) => {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.pushState({}, '', url);
}, []);
const getAllParams = useCallback((): Record<string, string> => {
const urlParams = new URLSearchParams(window.location.search);
const result: Record<string, string> = {};
urlParams.forEach((value, key) => {
result[key] = value;
});
return result;
}, []);
return {
getParam,
setParam,
removeParam,
getAllParams,
};
};
+285
View File
@@ -0,0 +1,285 @@
import JSZip from 'jszip';
import { Plugin } from '@/types/plugin';
export interface EpubOptions {
title: string;
author?: string;
description?: string;
cover?: string;
language?: string;
}
export interface ChapterData {
title: string;
content: string;
path: string;
}
function sanitizeHtml(html: string): string {
// Remove script tags and their content
html = html.replace(
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
'',
);
// Remove style tags but keep inline styles
html = html.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
// Ensure images have proper attributes
html = html.replace(/<img([^>]*)>/gi, (match, attrs) => {
if (!attrs.includes('alt=')) {
return `<img${attrs} alt="">`;
}
return match;
});
return html;
}
function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
async function fetchCoverImage(coverUrl?: string): Promise<{
data: ArrayBuffer | null;
mimeType: string;
extension: string;
}> {
if (!coverUrl) {
return { data: null, mimeType: '', extension: '' };
}
try {
let url = coverUrl;
if (!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://')) {
url = coverUrl.startsWith('/') ? coverUrl : '/' + coverUrl;
}
const response = await fetch(url);
if (!response.ok) {
return { data: null, mimeType: '', extension: '' };
}
const arrayBuffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || 'image/jpeg';
let extension = 'jpg';
if (contentType.includes('png')) extension = 'png';
else if (contentType.includes('gif')) extension = 'gif';
else if (contentType.includes('webp')) extension = 'webp';
if (extension === 'jpg') {
const urlLower = coverUrl.toLowerCase();
if (urlLower.includes('.png')) extension = 'png';
else if (urlLower.includes('.gif')) extension = 'gif';
else if (urlLower.includes('.webp')) extension = 'webp';
}
return {
data: arrayBuffer,
mimeType: contentType,
extension,
};
} catch (error) {
console.error('Error fetching cover image:', error);
return { data: null, mimeType: '', extension: '' };
}
}
export async function createEpub(
chapters: ChapterData[],
options: EpubOptions,
): Promise<Blob> {
const zip = new JSZip();
const uuid = generateUUID();
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
const coverImage = await fetchCoverImage(options.cover);
const hasCover = coverImage.data !== null;
zip.file('mimetype', 'application/epub+zip');
zip.file(
'META-INF/container.xml',
`<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`,
);
if (hasCover && coverImage.data) {
zip.file(`OEBPS/cover.${coverImage.extension}`, coverImage.data);
}
const manifestItems = [
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml"/>',
];
if (hasCover) {
manifestItems.push(
`<item id="cover-image" href="cover.${coverImage.extension}" media-type="${coverImage.mimeType}"/>`,
'<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>',
);
}
manifestItems.push(
...chapters.map(
(_, i) =>
`<item id="chapter${i + 1}" href="chapter${i + 1}.xhtml" media-type="application/xhtml+xml"/>`,
),
);
const spineItems = hasCover
? ['<itemref idref="cover"/>', '<itemref idref="nav"/>']
: ['<itemref idref="nav"/>'];
spineItems.push(
...chapters.map((_, i) => `<itemref idref="chapter${i + 1}"/>`),
);
const contentOpf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:identifier id="bookid">urn:uuid:${uuid}</dc:identifier>
<dc:title>${escapeXml(options.title)}</dc:title>
${options.author ? `<dc:creator>${escapeXml(options.author)}</dc:creator>` : ''}
${options.description ? `<dc:description>${escapeXml(options.description)}</dc:description>` : ''}
<dc:language>${options.language || 'en'}</dc:language>
<dc:date>${now}</dc:date>
<meta property="dcterms:modified">${now}</meta>
${hasCover ? '<meta name="cover" content="cover-image"/>' : ''}
</metadata>
<manifest>
${manifestItems.join('\n ')}
</manifest>
<spine toc="ncx">
${spineItems.join('\n ')}
</spine>
</package>`;
zip.file('OEBPS/content.opf', contentOpf);
const tocNcx = `<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="urn:uuid:${uuid}"/>
<meta name="dtb:depth" content="1"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle>
<text>${escapeXml(options.title)}</text>
</docTitle>
<navMap>
${chapters
.map(
(chapter, i) => `
<navPoint id="navpoint-${i + 1}" playOrder="${i + 1}">
<navLabel>
<text>${escapeXml(chapter.title)}</text>
</navLabel>
<content src="chapter${i + 1}.xhtml"/>
</navPoint>`,
)
.join('')}
</navMap>
</ncx>`;
zip.file('OEBPS/toc.ncx', tocNcx);
const navXhtml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<title>Navigation</title>
<meta charset="UTF-8"/>
</head>
<body>
<nav epub:type="toc">
<h1>Table of Contents</h1>
<ol>
${chapters.map((chapter, i) => `<li><a href="chapter${i + 1}.xhtml">${escapeXml(chapter.title)}</a></li>`).join('\n ')}
</ol>
</nav>
</body>
</html>`;
zip.file('OEBPS/nav.xhtml', navXhtml);
if (hasCover) {
const coverXhtml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Cover</title>
<meta charset="UTF-8"/>
<style type="text/css">
body { margin: 0; padding: 0; text-align: center; }
img { max-width: 100%; height: auto; display: block; margin: 0 auto; }
</style>
</head>
<body>
<img src="cover.${coverImage.extension}" alt="Cover"/>
</body>
</html>`;
zip.file('OEBPS/cover.xhtml', coverXhtml);
}
chapters.forEach((chapter, i) => {
const sanitizedContent = sanitizeHtml(chapter.content);
const chapterXhtml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>${escapeXml(chapter.title)}</title>
<meta charset="UTF-8"/>
<style type="text/css">
body { font-family: serif; line-height: 1.6; margin: 1em; }
h1, h2, h3 { margin-top: 1em; margin-bottom: 0.5em; }
p { margin: 0.5em 0; text-align: justify; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>
<h1>${escapeXml(chapter.title)}</h1>
${sanitizedContent}
</body>
</html>`;
zip.file(`OEBPS/chapter${i + 1}.xhtml`, chapterXhtml);
});
const blob = await zip.generateAsync({
type: 'blob',
mimeType: 'application/epub+zip',
});
return blob;
}
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}