PDF & ZIP IMPORT

This commit is contained in:
Stefan Hardegger
2025-12-05 10:21:03 +01:00
parent b1b5bbbccd
commit 77aec8a849
18 changed files with 3490 additions and 22 deletions

View File

@@ -0,0 +1,829 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { DocumentArrowUpIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
import Button from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import ImportLayout from '@/components/layout/ImportLayout';
import AuthorSelector from '@/components/stories/AuthorSelector';
import SeriesSelector from '@/components/stories/SeriesSelector';
type FileType = 'epub' | 'pdf' | 'zip' | null;
interface ImportResponse {
success: boolean;
message: string;
storyId?: string;
storyTitle?: string;
fileName?: string;
fileType?: string;
wordCount?: number;
extractedImages?: number;
warnings?: string[];
errors?: string[];
}
interface ZIPAnalysisResponse {
success: boolean;
message: string;
zipFileName?: string;
totalFiles?: number;
validFiles?: number;
files?: FileInfo[];
warnings?: string[];
}
interface FileInfo {
fileName: string;
fileType: string;
fileSize: number;
extractedTitle?: string;
extractedAuthor?: string;
hasMetadata: boolean;
error?: string;
}
interface ZIPImportResponse {
success: boolean;
message: string;
totalFiles: number;
successfulImports: number;
failedImports: number;
results: ImportResponse[];
warnings?: string[];
}
export default function FileImportPage() {
const router = useRouter();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [fileType, setFileType] = useState<FileType>(null);
const [isLoading, setIsLoading] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [validationResult, setValidationResult] = useState<any>(null);
const [importResult, setImportResult] = useState<ImportResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// ZIP-specific state
const [zipAnalysis, setZipAnalysis] = useState<ZIPAnalysisResponse | null>(null);
const [zipSessionId, setZipSessionId] = useState<string | null>(null);
const [selectedZipFiles, setSelectedZipFiles] = useState<Set<string>>(new Set());
const [fileMetadata, setFileMetadata] = useState<Map<string, any>>(new Map());
const [zipImportResult, setZipImportResult] = useState<ZIPImportResponse | null>(null);
// Import options
const [authorName, setAuthorName] = useState<string>('');
const [authorId, setAuthorId] = useState<string | undefined>(undefined);
const [seriesName, setSeriesName] = useState<string>('');
const [seriesId, setSeriesId] = useState<string | undefined>(undefined);
const [seriesVolume, setSeriesVolume] = useState<string>('');
const [tags, setTags] = useState<string>('');
const [createMissingAuthor, setCreateMissingAuthor] = useState(true);
const [createMissingSeries, setCreateMissingSeries] = useState(true);
const [extractImages, setExtractImages] = useState(true);
const [preserveReadingPosition, setPreserveReadingPosition] = useState(true);
const detectFileType = (file: File): FileType => {
const filename = file.name.toLowerCase();
if (filename.endsWith('.epub')) return 'epub';
if (filename.endsWith('.pdf')) return 'pdf';
if (filename.endsWith('.zip')) return 'zip';
return null;
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setValidationResult(null);
setImportResult(null);
setZipAnalysis(null);
setZipSessionId(null);
setSelectedZipFiles(new Set());
setZipImportResult(null);
setError(null);
const detectedType = detectFileType(file);
setFileType(detectedType);
if (!detectedType) {
setError('Unsupported file type. Please select an EPUB, PDF, or ZIP file.');
return;
}
if (detectedType === 'zip') {
await analyzeZipFile(file);
} else {
await validateFile(file, detectedType);
}
}
};
const validateFile = async (file: File, type: FileType) => {
if (type === 'zip') return; // ZIP has its own analysis flow
setIsValidating(true);
try {
const token = localStorage.getItem('auth-token');
const formData = new FormData();
formData.append('file', file);
const endpoint = type === 'epub' ? '/api/stories/epub/validate' : '/api/stories/pdf/validate';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
},
body: formData,
});
if (response.ok) {
const result = await response.json();
setValidationResult(result);
if (!result.valid) {
setError(`${type?.toUpperCase() || 'File'} validation failed: ` + result.errors.join(', '));
}
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError(`Failed to validate ${type?.toUpperCase() || 'file'}`);
}
} catch (err) {
setError(`Error validating ${type?.toUpperCase() || 'file'}: ` + (err as Error).message);
} finally {
setIsValidating(false);
}
};
const analyzeZipFile = async (file: File) => {
setIsLoading(true);
try {
const token = localStorage.getItem('auth-token');
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/stories/zip/analyze', {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
},
body: formData,
});
if (response.ok) {
const result: ZIPAnalysisResponse = await response.json();
setZipAnalysis(result);
if (result.success && result.warnings && result.warnings.length > 0) {
// Extract session ID from warnings
const sessionWarning = result.warnings.find(w => w.includes('Session ID:'));
if (sessionWarning) {
const match = sessionWarning.match(/Session ID: ([a-f0-9-]+)/);
if (match) {
setZipSessionId(match[1]);
}
}
}
if (!result.success) {
setError(result.message);
} else if (result.files && result.files.length === 0) {
setError('No valid EPUB or PDF files found in ZIP');
}
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError('Failed to analyze ZIP file');
}
} catch (err) {
setError('Error analyzing ZIP file: ' + (err as Error).message);
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile) {
setError('Please select a file');
return;
}
if (fileType === 'zip') {
await handleZipImport();
return;
}
if (validationResult && !validationResult.valid) {
setError(`Cannot import invalid ${fileType?.toUpperCase()} file`);
return;
}
// Check PDF requires author
if (fileType === 'pdf' && !authorName.trim()) {
setError('PDF import requires an author name. Please provide an author name or ensure the PDF has author metadata.');
return;
}
setIsLoading(true);
setError(null);
try {
const token = localStorage.getItem('auth-token');
const formData = new FormData();
formData.append('file', selectedFile);
if (authorId) {
formData.append('authorId', authorId);
} else if (authorName) {
formData.append('authorName', authorName);
}
if (seriesId) {
formData.append('seriesId', seriesId);
} else if (seriesName) {
formData.append('seriesName', seriesName);
}
if (seriesVolume) formData.append('seriesVolume', seriesVolume);
if (tags) {
const tagList = tags.split(',').map(t => t.trim()).filter(t => t.length > 0);
tagList.forEach(tag => formData.append('tags', tag));
}
formData.append('createMissingAuthor', createMissingAuthor.toString());
formData.append('createMissingSeries', createMissingSeries.toString());
if (fileType === 'epub') {
formData.append('preserveReadingPosition', preserveReadingPosition.toString());
} else if (fileType === 'pdf') {
formData.append('extractImages', extractImages.toString());
}
const endpoint = fileType === 'epub' ? '/api/stories/epub/import' : '/api/stories/pdf/import';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
},
body: formData,
});
const result = await response.json();
if (response.ok && result.success) {
setImportResult(result);
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError(result.message || `Failed to import ${fileType?.toUpperCase()}`);
}
} catch (err) {
setError(`Error importing ${fileType?.toUpperCase()}: ` + (err as Error).message);
} finally {
setIsLoading(false);
}
};
const handleZipImport = async () => {
if (!zipSessionId) {
setError('ZIP session expired. Please re-upload the ZIP file.');
return;
}
if (selectedZipFiles.size === 0) {
setError('Please select at least one file to import');
return;
}
setIsLoading(true);
setError(null);
try {
const token = localStorage.getItem('auth-token');
const requestBody: any = {
zipSessionId: zipSessionId,
selectedFiles: Array.from(selectedZipFiles),
defaultAuthorId: authorId || undefined,
defaultAuthorName: authorName || undefined,
defaultSeriesId: seriesId || undefined,
defaultSeriesName: seriesName || undefined,
defaultTags: tags ? tags.split(',').map(t => t.trim()).filter(t => t.length > 0) : undefined,
createMissingAuthor,
createMissingSeries,
extractImages,
};
// Add per-file metadata if any
if (fileMetadata.size > 0) {
const metadata: any = {};
fileMetadata.forEach((value, key) => {
metadata[key] = value;
});
requestBody.fileMetadata = metadata;
}
const response = await fetch('/api/stories/zip/import', {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const result: ZIPImportResponse = await response.json();
if (response.ok) {
setZipImportResult(result);
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError(result.message || 'Failed to import files from ZIP');
}
} catch (err) {
setError('Error importing from ZIP: ' + (err as Error).message);
} finally {
setIsLoading(false);
}
};
const toggleZipFileSelection = (fileName: string) => {
const newSelection = new Set(selectedZipFiles);
if (newSelection.has(fileName)) {
newSelection.delete(fileName);
} else {
newSelection.add(fileName);
}
setSelectedZipFiles(newSelection);
};
const selectAllZipFiles = () => {
if (zipAnalysis?.files) {
const validFiles = zipAnalysis.files.filter(f => !f.error);
setSelectedZipFiles(new Set(validFiles.map(f => f.fileName)));
}
};
const deselectAllZipFiles = () => {
setSelectedZipFiles(new Set());
};
const resetForm = () => {
setSelectedFile(null);
setFileType(null);
setValidationResult(null);
setImportResult(null);
setZipAnalysis(null);
setZipSessionId(null);
setSelectedZipFiles(new Set());
setFileMetadata(new Map());
setZipImportResult(null);
setError(null);
setAuthorName('');
setAuthorId(undefined);
setSeriesName('');
setSeriesId(undefined);
setSeriesVolume('');
setTags('');
};
const handleAuthorChange = (name: string, id?: string) => {
setAuthorName(name);
setAuthorId(id);
};
const handleSeriesChange = (name: string, id?: string) => {
setSeriesName(name);
setSeriesId(id);
};
// Show success screen for single file import
if (importResult?.success) {
return (
<ImportLayout
title="Import Successful"
description="Your file has been successfully imported into StoryCove"
>
<div className="space-y-6">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6">
<h2 className="text-xl font-semibold text-green-600 dark:text-green-400 mb-2">Import Completed</h2>
<p className="theme-text">
Your {importResult.fileType || fileType?.toUpperCase()} file has been successfully imported.
</p>
</div>
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="space-y-4">
<div>
<span className="font-semibold theme-header">Story Title:</span>
<p className="theme-text">{importResult.storyTitle}</p>
</div>
{importResult.wordCount && (
<div>
<span className="font-semibold theme-header">Word Count:</span>
<p className="theme-text">{importResult.wordCount.toLocaleString()} words</p>
</div>
)}
{importResult.extractedImages !== undefined && importResult.extractedImages > 0 && (
<div>
<span className="font-semibold theme-header">Extracted Images:</span>
<p className="theme-text">{importResult.extractedImages}</p>
</div>
)}
{importResult.warnings && importResult.warnings.length > 0 && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<strong className="text-yellow-800 dark:text-yellow-200">Warnings:</strong>
<ul className="list-disc list-inside mt-2 text-yellow-700 dark:text-yellow-300">
{importResult.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
<div className="flex gap-4 mt-6">
<Button
onClick={() => router.push(`/stories/${importResult.storyId}`)}
>
View Story
</Button>
<Button
onClick={resetForm}
variant="secondary"
>
Import Another File
</Button>
</div>
</div>
</div>
</div>
</ImportLayout>
);
}
// Show success screen for ZIP import
if (zipImportResult) {
return (
<ImportLayout
title="ZIP Import Complete"
description="Import results from your ZIP file"
>
<div className="space-y-6">
<div className={`border rounded-lg p-6 ${
zipImportResult.failedImports === 0
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
}`}>
<h2 className={`text-xl font-semibold mb-2 ${
zipImportResult.failedImports === 0
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}`}>
{zipImportResult.message}
</h2>
<p className="theme-text">
{zipImportResult.successfulImports} of {zipImportResult.totalFiles} files imported successfully
</p>
</div>
<div className="theme-card theme-shadow rounded-lg p-6">
<h3 className="text-lg font-semibold theme-header mb-4">Import Results</h3>
<div className="space-y-3">
{zipImportResult.results.map((result, index) => (
<div key={index} className={`p-4 rounded-lg border ${
result.success
? 'bg-green-50 dark:bg-green-900/10 border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800'
}`}>
<div className="flex items-start gap-3">
{result.success ? (
<CheckCircleIcon className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
) : (
<XCircleIcon className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<p className="font-medium theme-header">
{result.fileName || result.storyTitle || 'Unknown file'}
</p>
{result.success && result.storyTitle && (
<p className="text-sm theme-text">
Imported as: {result.storyTitle}
{result.storyId && (
<button
onClick={() => router.push(`/stories/${result.storyId}`)}
className="ml-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
View
</button>
)}
</p>
)}
{!result.success && (
<p className="text-sm text-red-600 dark:text-red-400">{result.message}</p>
)}
</div>
</div>
</div>
))}
</div>
<div className="flex gap-4 mt-6">
<Button
onClick={() => router.push('/library')}
>
Go to Library
</Button>
<Button
onClick={resetForm}
variant="secondary"
>
Import Another File
</Button>
</div>
</div>
</div>
</ImportLayout>
);
}
return (
<ImportLayout
title="Import from File"
description="Upload an EPUB, PDF, or ZIP file to import stories into your library"
>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<p className="text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* File Upload */}
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="mb-4">
<h3 className="text-lg font-semibold theme-header mb-2">Select File</h3>
<p className="theme-text">
Choose an EPUB, PDF, or ZIP file from your device to import.
</p>
</div>
<div className="space-y-4">
<div>
<label htmlFor="import-file" className="block text-sm font-medium theme-header mb-1">
File (EPUB, PDF, or ZIP)
</label>
<Input
id="import-file"
type="file"
accept=".epub,.pdf,.zip,application/epub+zip,application/pdf,application/zip"
onChange={handleFileChange}
disabled={isLoading || isValidating}
/>
</div>
{selectedFile && (
<div className="flex items-center gap-2">
<DocumentArrowUpIcon className="h-5 w-5 theme-text" />
<span className="text-sm theme-text">
{selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
{fileType && <span className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200">
{fileType.toUpperCase()}
</span>}
</span>
</div>
)}
{isValidating && (
<div className="text-sm theme-accent">
Validating file...
</div>
)}
{validationResult && fileType !== 'zip' && (
<div className="text-sm">
{validationResult.valid ? (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-200">
Valid {fileType?.toUpperCase()}
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-200">
Invalid {fileType?.toUpperCase()}
</span>
)}
</div>
)}
</div>
</div>
{/* ZIP File Selection */}
{fileType === 'zip' && zipAnalysis?.success && zipAnalysis.files && (
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold theme-header mb-2">Select Files to Import</h3>
<p className="theme-text">
{zipAnalysis.validFiles} valid files found in ZIP ({zipAnalysis.totalFiles} total)
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={selectAllZipFiles}
>
Select All
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={deselectAllZipFiles}
>
Deselect All
</Button>
</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{zipAnalysis.files.map((file, index) => (
<div
key={index}
className={`p-3 rounded-lg border ${
file.error
? 'bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800 opacity-50'
: selectedZipFiles.has(file.fileName)
? 'bg-blue-50 dark:bg-blue-900/10 border-blue-300 dark:border-blue-700'
: 'theme-card border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start gap-3">
{!file.error && (
<input
type="checkbox"
checked={selectedZipFiles.has(file.fileName)}
onChange={() => toggleZipFileSelection(file.fileName)}
className="mt-1"
/>
)}
<div className="flex-1">
<p className="font-medium theme-header">{file.fileName}</p>
<p className="text-xs theme-text mt-1">
{file.fileType} {(file.fileSize / 1024).toFixed(2)} KB
{file.extractedTitle && `${file.extractedTitle}`}
</p>
{file.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{file.error}</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Import Options - Show for all file types */}
{fileType && (!zipAnalysis || (zipAnalysis && selectedZipFiles.size > 0)) && (
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="mb-4">
<h3 className="text-lg font-semibold theme-header mb-2">Import Options</h3>
<p className="theme-text">
Configure how the {fileType === 'zip' ? 'files' : 'file'} should be imported.
{fileType === 'zip' && ' These settings apply to all selected files.'}
</p>
</div>
<div className="space-y-4">
<AuthorSelector
value={authorName}
onChange={handleAuthorChange}
placeholder={fileType === 'epub' ? 'Leave empty to use file metadata' : 'Required for PDF import'}
required={fileType === 'pdf'}
label={`Author${fileType === 'pdf' ? ' *' : ''}${fileType === 'zip' ? ' (Default)' : ''}`}
error={fileType === 'pdf' && !authorName ? 'PDF import requires an author name. Select an existing author or enter a new one.' : undefined}
/>
<SeriesSelector
value={seriesName}
onChange={handleSeriesChange}
placeholder="Optional: Add to a series"
label={`Series${fileType === 'zip' ? ' (Default)' : ''}`}
authorId={authorId}
/>
{seriesName && (
<div>
<label htmlFor="series-volume" className="block text-sm font-medium theme-header mb-1">Series Volume</label>
<Input
id="series-volume"
type="number"
value={seriesVolume}
onChange={(e) => setSeriesVolume(e.target.value)}
placeholder="Volume number in series"
/>
</div>
)}
<div>
<label htmlFor="tags" className="block text-sm font-medium theme-header mb-1">
Tags {fileType === 'zip' && '(Default)'}
</label>
<Input
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Comma-separated tags (e.g., fantasy, adventure, romance)"
/>
</div>
<div className="space-y-3">
{fileType === 'epub' && (
<div className="flex items-center">
<input
type="checkbox"
id="preserve-reading-position"
checked={preserveReadingPosition}
onChange={(e) => setPreserveReadingPosition(e.target.checked)}
className="mr-2"
/>
<label htmlFor="preserve-reading-position" className="text-sm theme-text">
Preserve reading position from EPUB metadata
</label>
</div>
)}
{(fileType === 'pdf' || fileType === 'zip') && (
<div className="flex items-center">
<input
type="checkbox"
id="extract-images"
checked={extractImages}
onChange={(e) => setExtractImages(e.target.checked)}
className="mr-2"
/>
<label htmlFor="extract-images" className="text-sm theme-text">
Extract and store embedded images from PDFs
</label>
</div>
)}
<div className="flex items-center">
<input
type="checkbox"
id="create-missing-author"
checked={createMissingAuthor}
onChange={(e) => setCreateMissingAuthor(e.target.checked)}
className="mr-2"
/>
<label htmlFor="create-missing-author" className="text-sm theme-text">
Create author if not found
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="create-missing-series"
checked={createMissingSeries}
onChange={(e) => setCreateMissingSeries(e.target.checked)}
className="mr-2"
/>
<label htmlFor="create-missing-series" className="text-sm theme-text">
Create series if not found
</label>
</div>
</div>
</div>
</div>
)}
{/* Submit Button */}
{fileType && fileType !== 'zip' && (
<div className="flex justify-end">
<Button
type="submit"
disabled={!selectedFile || isLoading || isValidating || (validationResult && !validationResult.valid)}
loading={isLoading}
>
{isLoading ? 'Importing...' : `Import ${fileType.toUpperCase()}`}
</Button>
</div>
)}
{fileType === 'zip' && zipAnalysis?.success && (
<div className="flex justify-end">
<Button
type="submit"
disabled={selectedZipFiles.size === 0 || isLoading}
loading={isLoading}
>
{isLoading ? 'Importing...' : `Import ${selectedZipFiles.size} File${selectedZipFiles.size !== 1 ? 's' : ''}`}
</Button>
</div>
)}
</form>
</ImportLayout>
);
}

View File

@@ -27,9 +27,9 @@ export default function Header() {
description: 'Import a single story from a website'
},
{
href: '/import/epub',
label: 'Import EPUB',
description: 'Import a story from an EPUB file'
href: '/import/file',
label: 'Import from File',
description: 'Import from EPUB, PDF, or ZIP file'
},
{
href: '/import/bulk',

View File

@@ -31,10 +31,10 @@ const importTabs: ImportTab[] = [
description: 'Import a single story from a website'
},
{
id: 'epub',
label: 'Import EPUB',
href: '/import/epub',
description: 'Import a story from an EPUB file'
id: 'file',
label: 'Import from File',
href: '/import/file',
description: 'Import from EPUB, PDF, or ZIP file'
},
{
id: 'bulk',

File diff suppressed because one or more lines are too long