mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 01:19:24 +00:00
Delete a bunch more dead files
This commit is contained in:
parent
deccfbaea0
commit
9738c4ca03
@ -1,76 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Card, Group, Text, Button, Progress } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import StorageIcon from "@mui/icons-material/Storage";
|
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import { StorageStats } from "../services/fileStorage";
|
|
||||||
import { formatFileSize } from "../utils/fileUtils";
|
|
||||||
import { getStorageUsagePercent } from "../utils/storageUtils";
|
|
||||||
|
|
||||||
interface StorageStatsCardProps {
|
|
||||||
storageStats: StorageStats | null;
|
|
||||||
filesCount: number;
|
|
||||||
onClearAll: () => void;
|
|
||||||
onReloadFiles: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
|
||||||
storageStats,
|
|
||||||
filesCount,
|
|
||||||
onClearAll,
|
|
||||||
onReloadFiles,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!storageStats) return null;
|
|
||||||
|
|
||||||
const storageUsagePercent = getStorageUsagePercent(storageStats);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder p="sm" mb="md" style={{ width: "90%", maxWidth: 600 }}>
|
|
||||||
<Group align="center" gap="md">
|
|
||||||
<StorageIcon />
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)}
|
|
||||||
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
|
|
||||||
</Text>
|
|
||||||
{storageStats.quota && (
|
|
||||||
<Progress
|
|
||||||
value={storageUsagePercent}
|
|
||||||
color={storageUsagePercent > 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"}
|
|
||||||
size="sm"
|
|
||||||
mt={4}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{storageStats.fileCount} {t("fileManager.filesStored", "files stored")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Group gap="xs">
|
|
||||||
{filesCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
size="xs"
|
|
||||||
onClick={onClearAll}
|
|
||||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
|
||||||
>
|
|
||||||
{t("fileManager.clearAll", "Clear All")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="blue"
|
|
||||||
size="xs"
|
|
||||||
onClick={onReloadFiles}
|
|
||||||
>
|
|
||||||
Reload Files
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StorageStatsCard;
|
|
@ -1,360 +0,0 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
|
||||||
import { ActionIcon, CheckboxIndicator } from '@mantine/core';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
|
||||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
|
||||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
|
||||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
|
||||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
|
||||||
|
|
||||||
import styles from './PageEditor.module.css';
|
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
|
||||||
import { FileId } from '../../types/file';
|
|
||||||
|
|
||||||
interface FileItem {
|
|
||||||
id: FileId;
|
|
||||||
name: string;
|
|
||||||
pageCount: number;
|
|
||||||
thumbnail: string | null;
|
|
||||||
size: number;
|
|
||||||
modifiedAt?: number | string | Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileThumbnailProps {
|
|
||||||
file: FileItem;
|
|
||||||
index: number;
|
|
||||||
totalFiles: number;
|
|
||||||
selectedFiles: string[];
|
|
||||||
selectionMode: boolean;
|
|
||||||
onToggleFile: (fileId: FileId) => void;
|
|
||||||
onDeleteFile: (fileId: FileId) => void;
|
|
||||||
onViewFile: (fileId: FileId) => void;
|
|
||||||
onSetStatus: (status: string) => void;
|
|
||||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
|
||||||
onDownloadFile?: (fileId: FileId) => void;
|
|
||||||
toolMode?: boolean;
|
|
||||||
isSupported?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileThumbnail = ({
|
|
||||||
file,
|
|
||||||
index,
|
|
||||||
selectedFiles,
|
|
||||||
onToggleFile,
|
|
||||||
onDeleteFile,
|
|
||||||
onSetStatus,
|
|
||||||
onReorderFiles,
|
|
||||||
onDownloadFile,
|
|
||||||
isSupported = true,
|
|
||||||
}: FileThumbnailProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
|
||||||
|
|
||||||
// ---- Drag state ----
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
|
|
||||||
const [showActions, setShowActions] = useState(false);
|
|
||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
|
||||||
const actualFile = useMemo(() => {
|
|
||||||
return activeFiles.find(f => f.fileId === file.id);
|
|
||||||
}, [activeFiles, file.id]);
|
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
|
||||||
|
|
||||||
const downloadSelectedFile = useCallback(() => {
|
|
||||||
// Prefer parent-provided handler if available
|
|
||||||
if (typeof onDownloadFile === 'function') {
|
|
||||||
onDownloadFile(file.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: attempt to download using the File object if provided
|
|
||||||
const maybeFile = (file as unknown as { file?: File }).file;
|
|
||||||
if (maybeFile instanceof File) {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = URL.createObjectURL(maybeFile);
|
|
||||||
link.download = maybeFile.name || file.name || 'download';
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we can't find a way to download, surface a status message
|
|
||||||
onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item');
|
|
||||||
}, [file, onDownloadFile, onSetStatus, t]);
|
|
||||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
|
||||||
|
|
||||||
// ---- Selection ----
|
|
||||||
const isSelected = selectedFiles.includes(file.id);
|
|
||||||
|
|
||||||
// ---- Drag & drop wiring ----
|
|
||||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
dragElementRef.current = element;
|
|
||||||
|
|
||||||
const dragCleanup = draggable({
|
|
||||||
element,
|
|
||||||
getInitialData: () => ({
|
|
||||||
type: 'file',
|
|
||||||
fileId: file.id,
|
|
||||||
fileName: file.name,
|
|
||||||
selectedFiles: [file.id] // Always drag only this file, ignore selection state
|
|
||||||
}),
|
|
||||||
onDragStart: () => {
|
|
||||||
setIsDragging(true);
|
|
||||||
},
|
|
||||||
onDrop: () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropCleanup = dropTargetForElements({
|
|
||||||
element,
|
|
||||||
getData: () => ({
|
|
||||||
type: 'file',
|
|
||||||
fileId: file.id
|
|
||||||
}),
|
|
||||||
canDrop: ({ source }) => {
|
|
||||||
const sourceData = source.data;
|
|
||||||
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
|
||||||
},
|
|
||||||
onDrop: ({ source }) => {
|
|
||||||
const sourceData = source.data;
|
|
||||||
if (sourceData.type === 'file' && onReorderFiles) {
|
|
||||||
const sourceFileId = sourceData.fileId as FileId;
|
|
||||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
|
||||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
dragCleanup();
|
|
||||||
dropCleanup();
|
|
||||||
};
|
|
||||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
|
||||||
|
|
||||||
// Update dropdown width on resize
|
|
||||||
useEffect(() => {
|
|
||||||
const update = () => {
|
|
||||||
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
|
||||||
};
|
|
||||||
update();
|
|
||||||
window.addEventListener('resize', update);
|
|
||||||
return () => window.removeEventListener('resize', update);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close the actions dropdown when hovering outside this file card (and its dropdown)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showActions) return;
|
|
||||||
|
|
||||||
const isInsideCard = (target: EventTarget | null) => {
|
|
||||||
const container = dragElementRef.current;
|
|
||||||
if (!container) return false;
|
|
||||||
return target instanceof Node && container.contains(target);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!isInsideCard(e.target)) {
|
|
||||||
setShowActions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchStart = (e: TouchEvent) => {
|
|
||||||
// On touch devices, close if the touch target is outside the card
|
|
||||||
if (!isInsideCard(e.target)) {
|
|
||||||
setShowActions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
|
||||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
document.removeEventListener('touchstart', handleTouchStart);
|
|
||||||
};
|
|
||||||
}, [showActions]);
|
|
||||||
|
|
||||||
// ---- Card interactions ----
|
|
||||||
const handleCardClick = () => {
|
|
||||||
if (!isSupported) return;
|
|
||||||
onToggleFile(file.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={fileElementRef}
|
|
||||||
data-file-id={file.id}
|
|
||||||
data-testid="file-thumbnail"
|
|
||||||
data-selected={isSelected}
|
|
||||||
data-supported={isSupported}
|
|
||||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
|
||||||
style={{
|
|
||||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
|
||||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
role="listitem"
|
|
||||||
aria-selected={isSelected}
|
|
||||||
onClick={handleCardClick}
|
|
||||||
>
|
|
||||||
{/* Header bar */}
|
|
||||||
<div
|
|
||||||
className={`${styles.header} ${
|
|
||||||
isSelected ? styles.headerSelected : styles.headerResting
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Logo/checkbox area */}
|
|
||||||
<div className={styles.logoMark}>
|
|
||||||
{isSupported ? (
|
|
||||||
<CheckboxIndicator
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => onToggleFile(file.id)}
|
|
||||||
color="var(--checkbox-checked-bg)"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={styles.unsupportedPill}>
|
|
||||||
<span>
|
|
||||||
{t('unsupported', 'Unsupported')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Centered index */}
|
|
||||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Kebab menu */}
|
|
||||||
<ActionIcon
|
|
||||||
aria-label={t('moreOptions', 'More options')}
|
|
||||||
variant="subtle"
|
|
||||||
className={styles.kebab}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowActions((v) => !v);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MoreVertIcon fontSize="small" />
|
|
||||||
</ActionIcon>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions overlay */}
|
|
||||||
{showActions && (
|
|
||||||
<div
|
|
||||||
className={styles.actionsOverlay}
|
|
||||||
style={{ width: actionsWidth }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className={styles.actionRow}
|
|
||||||
onClick={() => {
|
|
||||||
if (actualFile) {
|
|
||||||
if (isPinned) {
|
|
||||||
unpinFile(actualFile);
|
|
||||||
onSetStatus?.(`Unpinned ${file.name}`);
|
|
||||||
} else {
|
|
||||||
pinFile(actualFile);
|
|
||||||
onSetStatus?.(`Pinned ${file.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setShowActions(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
|
||||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={styles.actionRow}
|
|
||||||
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
|
|
||||||
>
|
|
||||||
<DownloadOutlinedIcon fontSize="small" />
|
|
||||||
<span>{t('download', 'Download')}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className={styles.actionsDivider} />
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
|
||||||
onClick={() => {
|
|
||||||
onDeleteFile(file.id);
|
|
||||||
onSetStatus(`Deleted ${file.name}`);
|
|
||||||
setShowActions(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
|
||||||
<span>{t('delete', 'Delete')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File content area */}
|
|
||||||
<div className="file-container w-[90%] h-[80%] relative">
|
|
||||||
{/* Stacked file effect - multiple shadows to simulate pages */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid var(--mantine-color-gray-3)',
|
|
||||||
padding: 4,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{file.thumbnail && (
|
|
||||||
<img
|
|
||||||
src={file.thumbnail}
|
|
||||||
alt={file.name}
|
|
||||||
draggable={false}
|
|
||||||
onError={(e) => {
|
|
||||||
// Hide broken image if blob URL was revoked
|
|
||||||
const img = e.target as HTMLImageElement;
|
|
||||||
img.style.display = 'none';
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
maxWidth: '80%',
|
|
||||||
maxHeight: '80%',
|
|
||||||
objectFit: 'contain',
|
|
||||||
borderRadius: 0,
|
|
||||||
background: '#ffffff',
|
|
||||||
border: '1px solid var(--border-default)',
|
|
||||||
display: 'block',
|
|
||||||
marginLeft: 'auto',
|
|
||||||
marginRight: 'auto',
|
|
||||||
alignSelf: 'start'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pin indicator (bottom-left) */}
|
|
||||||
{isPinned && (
|
|
||||||
<span className={styles.pinIndicator} aria-hidden>
|
|
||||||
<PushPinIcon fontSize="small" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
|
||||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
|
||||||
<DragIndicatorIcon fontSize="small" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default React.memo(FileThumbnail);
|
|
@ -1,140 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
|
|
||||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
|
||||||
|
|
||||||
interface AppConfigModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
|
||||||
const { config, loading, error, refetch } = useAppConfig();
|
|
||||||
|
|
||||||
const renderConfigSection = (title: string, data: any) => {
|
|
||||||
if (!data || typeof data !== 'object') return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xs" mb="md">
|
|
||||||
<Text fw={600} size="md" c="blue">{title}</Text>
|
|
||||||
<Stack gap="xs" pl="md">
|
|
||||||
{Object.entries(data).map(([key, value]) => (
|
|
||||||
<Group key={key} wrap="nowrap" align="flex-start">
|
|
||||||
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
|
||||||
{key}:
|
|
||||||
</Text>
|
|
||||||
{typeof value === 'boolean' ? (
|
|
||||||
<Badge color={value ? 'green' : 'red'} size="sm">
|
|
||||||
{value ? 'true' : 'false'}
|
|
||||||
</Badge>
|
|
||||||
) : typeof value === 'object' ? (
|
|
||||||
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
|
||||||
) : (
|
|
||||||
String(value) || 'null'
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const basicConfig = config ? {
|
|
||||||
appName: config.appName,
|
|
||||||
appNameNavbar: config.appNameNavbar,
|
|
||||||
baseUrl: config.baseUrl,
|
|
||||||
contextPath: config.contextPath,
|
|
||||||
serverPort: config.serverPort,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
const securityConfig = config ? {
|
|
||||||
enableLogin: config.enableLogin,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
const systemConfig = config ? {
|
|
||||||
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
|
||||||
enableAnalytics: config.enableAnalytics,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
const premiumConfig = config ? {
|
|
||||||
premiumEnabled: config.premiumEnabled,
|
|
||||||
premiumKey: config.premiumKey ? '***hidden***' : null,
|
|
||||||
runningProOrHigher: config.runningProOrHigher,
|
|
||||||
runningEE: config.runningEE,
|
|
||||||
license: config.license,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
const integrationConfig = config ? {
|
|
||||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
|
||||||
SSOAutoLogin: config.SSOAutoLogin,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
const legalConfig = config ? {
|
|
||||||
termsAndConditions: config.termsAndConditions,
|
|
||||||
privacyPolicy: config.privacyPolicy,
|
|
||||||
cookiePolicy: config.cookiePolicy,
|
|
||||||
impressum: config.impressum,
|
|
||||||
accessibilityStatement: config.accessibilityStatement,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title="App Configuration (Testing)"
|
|
||||||
size="lg"
|
|
||||||
style={{ zIndex: 1000 }}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
This modal shows the current application configuration for testing purposes only.
|
|
||||||
</Text>
|
|
||||||
<Button size="xs" variant="light" onClick={refetch}>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<Stack align="center" py="md">
|
|
||||||
<Loader size="sm" />
|
|
||||||
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert color="red" title="Error">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{config && (
|
|
||||||
<ScrollArea h={400}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
{renderConfigSection('Basic Configuration', basicConfig)}
|
|
||||||
{renderConfigSection('Security Configuration', securityConfig)}
|
|
||||||
{renderConfigSection('System Configuration', systemConfig)}
|
|
||||||
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
|
|
||||||
{renderConfigSection('Integration Configuration', integrationConfig)}
|
|
||||||
{renderConfigSection('Legal Configuration', legalConfig)}
|
|
||||||
|
|
||||||
{config.error && (
|
|
||||||
<Alert color="yellow" title="Configuration Warning">
|
|
||||||
{config.error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
|
|
||||||
<Code block style={{ fontSize: '11px' }}>
|
|
||||||
{JSON.stringify(config, null, 2)}
|
|
||||||
</Code>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppConfigModal;
|
|
@ -1,214 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
|
||||||
import StorageIcon from "@mui/icons-material/Storage";
|
|
||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
|
||||||
|
|
||||||
import { StirlingFileStub } from "../../types/fileContext";
|
|
||||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
|
||||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
|
||||||
|
|
||||||
interface FileCardProps {
|
|
||||||
file: File;
|
|
||||||
record?: StirlingFileStub;
|
|
||||||
onRemove: () => void;
|
|
||||||
onDoubleClick?: () => void;
|
|
||||||
onView?: () => void;
|
|
||||||
onEdit?: () => void;
|
|
||||||
isSelected?: boolean;
|
|
||||||
onSelect?: () => void;
|
|
||||||
isSupported?: boolean; // Whether the file format is supported by the current tool
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
|
||||||
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
|
|
||||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
|
||||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
shadow="xs"
|
|
||||||
radius="md"
|
|
||||||
withBorder
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
width: 225,
|
|
||||||
minWidth: 180,
|
|
||||||
maxWidth: 260,
|
|
||||||
cursor: onDoubleClick && isSupported ? "pointer" : undefined,
|
|
||||||
position: 'relative',
|
|
||||||
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
|
|
||||||
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined,
|
|
||||||
opacity: isSupported ? 1 : 0.5,
|
|
||||||
filter: isSupported ? 'none' : 'grayscale(50%)'
|
|
||||||
}}
|
|
||||||
onDoubleClick={onDoubleClick}
|
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
onClick={onSelect}
|
|
||||||
data-testid="file-card"
|
|
||||||
>
|
|
||||||
<Stack gap={6} align="center">
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
border: "2px solid #e0e0e0",
|
|
||||||
borderRadius: 8,
|
|
||||||
width: 90,
|
|
||||||
height: 120,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
margin: "0 auto",
|
|
||||||
background: "#fafbfc",
|
|
||||||
position: 'relative'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Hover action buttons */}
|
|
||||||
{isHovered && (onView || onEdit) && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
display: 'flex',
|
|
||||||
gap: 4,
|
|
||||||
zIndex: 10,
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: 2
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{onView && (
|
|
||||||
<Tooltip label="View in Viewer">
|
|
||||||
<ActionIcon
|
|
||||||
size="sm"
|
|
||||||
variant="subtle"
|
|
||||||
color="blue"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onView();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VisibilityIcon style={{ fontSize: 16 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{onEdit && (
|
|
||||||
<Tooltip label="Open in File Editor">
|
|
||||||
<ActionIcon
|
|
||||||
size="sm"
|
|
||||||
variant="subtle"
|
|
||||||
color="orange"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon style={{ fontSize: 16 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{thumb ? (
|
|
||||||
<Image
|
|
||||||
src={thumb}
|
|
||||||
alt="PDF thumbnail"
|
|
||||||
height={110}
|
|
||||||
width={80}
|
|
||||||
fit="contain"
|
|
||||||
radius="sm"
|
|
||||||
/>
|
|
||||||
) : isGenerating ? (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
border: '2px solid #ddd',
|
|
||||||
borderTop: '2px solid #666',
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'spin 1s linear infinite',
|
|
||||||
marginBottom: 8
|
|
||||||
}} />
|
|
||||||
<Text size="xs" c="dimmed">Generating...</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
<ThemeIcon
|
|
||||||
variant="light"
|
|
||||||
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
|
|
||||||
size={60}
|
|
||||||
radius="sm"
|
|
||||||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
||||||
>
|
|
||||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
|
||||||
</ThemeIcon>
|
|
||||||
{file.size > 100 * 1024 * 1024 && (
|
|
||||||
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
|
||||||
{file.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Group gap="xs" justify="center">
|
|
||||||
<Badge color="red" variant="light" size="sm">
|
|
||||||
{getFileSize(file)}
|
|
||||||
</Badge>
|
|
||||||
<Badge color="blue" variant="light" size="sm">
|
|
||||||
{getFileDate(file)}
|
|
||||||
</Badge>
|
|
||||||
{record?.id && (
|
|
||||||
<Badge
|
|
||||||
color="green"
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
|
|
||||||
>
|
|
||||||
DB
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!isSupported && (
|
|
||||||
<Badge color="orange" variant="filled" size="sm">
|
|
||||||
{t("fileManager.unsupported", "Unsupported")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove();
|
|
||||||
}}
|
|
||||||
mt={4}
|
|
||||||
>
|
|
||||||
{t("delete", "Remove")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileCard;
|
|
@ -1,182 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
|
||||||
import SortIcon from "@mui/icons-material/Sort";
|
|
||||||
import FileCard from "./FileCard";
|
|
||||||
import { StirlingFileStub } from "../../types/fileContext";
|
|
||||||
import { FileId } from "../../types/file";
|
|
||||||
|
|
||||||
interface FileGridProps {
|
|
||||||
files: Array<{ file: File; record?: StirlingFileStub }>;
|
|
||||||
onRemove?: (index: number) => void;
|
|
||||||
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
|
|
||||||
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
|
|
||||||
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
|
|
||||||
onSelect?: (fileId: FileId) => void;
|
|
||||||
selectedFiles?: FileId[];
|
|
||||||
showSearch?: boolean;
|
|
||||||
showSort?: boolean;
|
|
||||||
maxDisplay?: number; // If set, shows only this many files with "Show All" option
|
|
||||||
onShowAll?: () => void;
|
|
||||||
showingAll?: boolean;
|
|
||||||
onDeleteAll?: () => void;
|
|
||||||
isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported
|
|
||||||
}
|
|
||||||
|
|
||||||
type SortOption = 'date' | 'name' | 'size';
|
|
||||||
|
|
||||||
const FileGrid = ({
|
|
||||||
files,
|
|
||||||
onRemove,
|
|
||||||
onDoubleClick,
|
|
||||||
onView,
|
|
||||||
onEdit,
|
|
||||||
onSelect,
|
|
||||||
selectedFiles = [],
|
|
||||||
showSearch = false,
|
|
||||||
showSort = false,
|
|
||||||
maxDisplay,
|
|
||||||
onShowAll,
|
|
||||||
showingAll = false,
|
|
||||||
onDeleteAll,
|
|
||||||
isFileSupported
|
|
||||||
}: FileGridProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('date');
|
|
||||||
|
|
||||||
// Filter files based on search term
|
|
||||||
const filteredFiles = files.filter(item =>
|
|
||||||
item.file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort files
|
|
||||||
const sortedFiles = [...filteredFiles].sort((a, b) => {
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'date':
|
|
||||||
return (b.file.lastModified || 0) - (a.file.lastModified || 0);
|
|
||||||
case 'name':
|
|
||||||
return a.file.name.localeCompare(b.file.name);
|
|
||||||
case 'size':
|
|
||||||
return (b.file.size || 0) - (a.file.size || 0);
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply max display limit if specified
|
|
||||||
const displayFiles = maxDisplay && !showingAll
|
|
||||||
? sortedFiles.slice(0, maxDisplay)
|
|
||||||
: sortedFiles;
|
|
||||||
|
|
||||||
const hasMoreFiles = maxDisplay && !showingAll && sortedFiles.length > maxDisplay;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box >
|
|
||||||
{/* Search and Sort Controls */}
|
|
||||||
{(showSearch || showSort || onDeleteAll) && (
|
|
||||||
<Group mb="md" justify="space-between" wrap="wrap" gap="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
{showSearch && (
|
|
||||||
<TextInput
|
|
||||||
placeholder={t("fileManager.searchFiles", "Search files...")}
|
|
||||||
leftSection={<SearchIcon fontSize="small" />}
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.currentTarget.value)}
|
|
||||||
style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSort && (
|
|
||||||
<Select
|
|
||||||
data={[
|
|
||||||
{ value: 'date', label: t("fileManager.sortByDate", "Sort by Date") },
|
|
||||||
{ value: 'name', label: t("fileManager.sortByName", "Sort by Name") },
|
|
||||||
{ value: 'size', label: t("fileManager.sortBySize", "Sort by Size") }
|
|
||||||
]}
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(value) => setSortBy(value as SortOption)}
|
|
||||||
leftSection={<SortIcon fontSize="small" />}
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{onDeleteAll && (
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
size="sm"
|
|
||||||
onClick={onDeleteAll}
|
|
||||||
>
|
|
||||||
{t("fileManager.deleteAll", "Delete All")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File Grid */}
|
|
||||||
<Flex
|
|
||||||
direction="row"
|
|
||||||
wrap="wrap"
|
|
||||||
gap="md"
|
|
||||||
h="30rem"
|
|
||||||
style={{ overflowY: "auto", width: "100%" }}
|
|
||||||
>
|
|
||||||
{displayFiles
|
|
||||||
.filter(item => {
|
|
||||||
if (!item.record?.id) {
|
|
||||||
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map((item, idx) => {
|
|
||||||
const fileId = item.record!.id; // Safe to assert after filter
|
|
||||||
const originalIdx = files.findIndex(f => f.record?.id === fileId);
|
|
||||||
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
|
||||||
return (
|
|
||||||
<FileCard
|
|
||||||
key={fileId + idx}
|
|
||||||
file={item.file}
|
|
||||||
record={item.record}
|
|
||||||
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
|
||||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
|
|
||||||
onView={onView && supported ? () => onView(item) : undefined}
|
|
||||||
onEdit={onEdit && supported ? () => onEdit(item) : undefined}
|
|
||||||
isSelected={selectedFiles.includes(fileId)}
|
|
||||||
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
|
|
||||||
isSupported={supported}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* Show All Button */}
|
|
||||||
{hasMoreFiles && onShowAll && (
|
|
||||||
<Group justify="center" mt="md">
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
onClick={onShowAll}
|
|
||||||
>
|
|
||||||
{t("fileManager.showAll", "Show All")} ({sortedFiles.length} files)
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{displayFiles.length === 0 && (
|
|
||||||
<Box style={{ textAlign: 'center', padding: '2rem' }}>
|
|
||||||
<Text c="dimmed">
|
|
||||||
{searchTerm
|
|
||||||
? t("fileManager.noFilesFound", "No files found matching your search")
|
|
||||||
: t("fileManager.noFiles", "No files available")
|
|
||||||
}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileGrid;
|
|
@ -1,88 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Box, Group, Text, Button } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface MultiSelectControlsProps {
|
|
||||||
selectedCount: number;
|
|
||||||
onClearSelection: () => void;
|
|
||||||
onOpenInFileEditor?: () => void;
|
|
||||||
onOpenInPageEditor?: () => void;
|
|
||||||
onAddToUpload?: () => void;
|
|
||||||
onDeleteAll?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MultiSelectControls = ({
|
|
||||||
selectedCount,
|
|
||||||
onClearSelection,
|
|
||||||
onOpenInFileEditor,
|
|
||||||
onOpenInPageEditor,
|
|
||||||
onAddToUpload,
|
|
||||||
onDeleteAll
|
|
||||||
}: MultiSelectControlsProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (selectedCount === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm">
|
|
||||||
{selectedCount} {t("fileManager.filesSelected", "files selected")}
|
|
||||||
</Text>
|
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={onClearSelection}
|
|
||||||
>
|
|
||||||
{t("fileManager.clearSelection", "Clear Selection")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{onAddToUpload && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="green"
|
|
||||||
onClick={onAddToUpload}
|
|
||||||
>
|
|
||||||
{t("fileManager.addToUpload", "Add to Upload")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onOpenInFileEditor && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="orange"
|
|
||||||
onClick={onOpenInFileEditor}
|
|
||||||
disabled={selectedCount === 0}
|
|
||||||
>
|
|
||||||
{t("fileManager.openInFileEditor", "Open in File Editor")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onOpenInPageEditor && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="blue"
|
|
||||||
onClick={onOpenInPageEditor}
|
|
||||||
disabled={selectedCount === 0}
|
|
||||||
>
|
|
||||||
{t("fileManager.openInPageEditor", "Open in Page Editor")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onDeleteAll && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="red"
|
|
||||||
onClick={onDeleteAll}
|
|
||||||
>
|
|
||||||
{t("fileManager.deleteAll", "Delete All")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MultiSelectControls;
|
|
@ -1,63 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Stack, Text, NumberInput } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
|
|
||||||
|
|
||||||
interface WatermarkStyleSettingsProps {
|
|
||||||
parameters: AddWatermarkParameters;
|
|
||||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = false }: WatermarkStyleSettingsProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{/* Appearance Settings */}
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm" fw={500}>{t('watermark.settings.rotation', 'Rotation (degrees)')}</Text>
|
|
||||||
<NumberInput
|
|
||||||
value={parameters.rotation}
|
|
||||||
onChange={(value) => onParameterChange('rotation', typeof value === 'number' ? value : (parseInt(value as string, 10) || 0))}
|
|
||||||
min={-360}
|
|
||||||
max={360}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text size="sm" fw={500}>{t('watermark.settings.opacity', 'Opacity (%)')}</Text>
|
|
||||||
<NumberInput
|
|
||||||
value={parameters.opacity}
|
|
||||||
onChange={(value) => onParameterChange('opacity', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Spacing Settings */}
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm" fw={500}>{t('watermark.settings.spacing.width', 'Width Spacing')}</Text>
|
|
||||||
<NumberInput
|
|
||||||
value={parameters.widthSpacer}
|
|
||||||
onChange={(value) => onParameterChange('widthSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
|
|
||||||
min={0}
|
|
||||||
max={200}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text size="sm" fw={500}>{t('watermark.settings.spacing.height', 'Height Spacing')}</Text>
|
|
||||||
<NumberInput
|
|
||||||
value={parameters.heightSpacer}
|
|
||||||
onChange={(value) => onParameterChange('heightSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
|
|
||||||
min={0}
|
|
||||||
max={200}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WatermarkStyleSettings;
|
|
@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { RemoveCertificateSignParameters } from '../../../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters';
|
|
||||||
|
|
||||||
interface RemoveCertificateSignSettingsProps {
|
|
||||||
parameters: RemoveCertificateSignParameters;
|
|
||||||
onParameterChange: <K extends keyof RemoveCertificateSignParameters>(parameter: K, value: RemoveCertificateSignParameters[K]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = (_) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="remove-certificate-sign-settings">
|
|
||||||
<p className="text-muted">
|
|
||||||
{t('removeCertSign.description', 'This tool will remove digital certificate signatures from your PDF document.')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RemoveCertificateSignSettings;
|
|
@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { SingleLargePageParameters } from '../../../hooks/tools/singleLargePage/useSingleLargePageParameters';
|
|
||||||
|
|
||||||
interface SingleLargePageSettingsProps {
|
|
||||||
parameters: SingleLargePageParameters;
|
|
||||||
onParameterChange: <K extends keyof SingleLargePageParameters>(parameter: K, value: SingleLargePageParameters[K]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = (_) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="single-large-page-settings">
|
|
||||||
<p className="text-muted">
|
|
||||||
{t('pdfToSinglePage.description', 'This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SingleLargePageSettings;
|
|
@ -1,62 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { TooltipContent } from '../../types/tips';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reusable tooltip for page selection functionality.
|
|
||||||
* Can be used by any tool that uses the GeneralUtils.parsePageList syntax.
|
|
||||||
*/
|
|
||||||
export const usePageSelectionTips = (): TooltipContent => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return {
|
|
||||||
header: {
|
|
||||||
title: t("pageSelection.tooltip.header.title", "Page Selection Guide")
|
|
||||||
},
|
|
||||||
tips: [
|
|
||||||
{
|
|
||||||
description: t("pageSelection.tooltip.description", "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("pageSelection.tooltip.individual.title", "Individual Pages"),
|
|
||||||
description: t("pageSelection.tooltip.individual.description", "Enter numbers separated by commas."),
|
|
||||||
bullets: [
|
|
||||||
t("pageSelection.tooltip.individual.bullet1", "<strong>1,3,5</strong> → selects pages 1, 3, 5"),
|
|
||||||
t("pageSelection.tooltip.individual.bullet2", "<strong>2,7,12</strong> → selects pages 2, 7, 12")
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("pageSelection.tooltip.ranges.title", "Page Ranges"),
|
|
||||||
description: t("pageSelection.tooltip.ranges.description", "Use - for consecutive pages."),
|
|
||||||
bullets: [
|
|
||||||
t("pageSelection.tooltip.ranges.bullet1", "<strong>3-6</strong> → selects pages 3–6"),
|
|
||||||
t("pageSelection.tooltip.ranges.bullet2", "<strong>10-15</strong> → selects pages 10–15"),
|
|
||||||
t("pageSelection.tooltip.ranges.bullet3", "<strong>5-</strong> → selects pages 5 to end")
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("pageSelection.tooltip.mathematical.title", "Mathematical Functions"),
|
|
||||||
description: t("pageSelection.tooltip.mathematical.description", "Use n in formulas for patterns."),
|
|
||||||
bullets: [
|
|
||||||
t("pageSelection.tooltip.mathematical.bullet2", "<strong>2n-1</strong> → all odd pages (1, 3, 5…)"),
|
|
||||||
t("pageSelection.tooltip.mathematical.bullet1", "<strong>2n</strong> → all even pages (2, 4, 6…)"),
|
|
||||||
t("pageSelection.tooltip.mathematical.bullet3", "<strong>3n</strong> → every 3rd page (3, 6, 9…)"),
|
|
||||||
t("pageSelection.tooltip.mathematical.bullet4", "<strong>4n-1</strong> → pages 3, 7, 11, 15…")
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("pageSelection.tooltip.special.title", "Special Keywords"),
|
|
||||||
bullets: [
|
|
||||||
t("pageSelection.tooltip.special.bullet1", "<strong>all</strong> → selects all pages"),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("pageSelection.tooltip.complex.title", "Complex Combinations"),
|
|
||||||
description: t("pageSelection.tooltip.complex.description", "Mix different types."),
|
|
||||||
bullets: [
|
|
||||||
t("pageSelection.tooltip.complex.bullet1", "<strong>1,3-5,8,2n</strong> → pages 1, 3–5, 8, plus evens"),
|
|
||||||
t("pageSelection.tooltip.complex.bullet2", "<strong>10-,2n-1</strong> → from page 10 to end + odd pages")
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,67 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
export interface OperationResult {
|
|
||||||
files: File[];
|
|
||||||
thumbnails: string[];
|
|
||||||
isGeneratingThumbnails: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OperationResultsHook {
|
|
||||||
results: OperationResult;
|
|
||||||
downloadUrl: string | null;
|
|
||||||
status: string;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
|
|
||||||
setResults: (results: OperationResult) => void;
|
|
||||||
setDownloadUrl: (url: string | null) => void;
|
|
||||||
setStatus: (status: string) => void;
|
|
||||||
setErrorMessage: (error: string | null) => void;
|
|
||||||
setIsLoading: (loading: boolean) => void;
|
|
||||||
|
|
||||||
resetResults: () => void;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialResults: OperationResult = {
|
|
||||||
files: [],
|
|
||||||
thumbnails: [],
|
|
||||||
isGeneratingThumbnails: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useOperationResults = (): OperationResultsHook => {
|
|
||||||
const [results, setResults] = useState<OperationResult>(initialResults);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const resetResults = useCallback(() => {
|
|
||||||
setResults(initialResults);
|
|
||||||
setDownloadUrl(null);
|
|
||||||
setStatus('');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
results,
|
|
||||||
downloadUrl,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
isLoading,
|
|
||||||
|
|
||||||
setResults,
|
|
||||||
setDownloadUrl,
|
|
||||||
setStatus,
|
|
||||||
setErrorMessage,
|
|
||||||
setIsLoading,
|
|
||||||
|
|
||||||
resetResults,
|
|
||||||
clearError,
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,312 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { ProcessedFile, ProcessingState, ProcessingConfig } from '../types/processing';
|
|
||||||
import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
|
|
||||||
import { FileHasher } from '../utils/fileHash';
|
|
||||||
|
|
||||||
interface UseEnhancedProcessedFilesResult {
|
|
||||||
processedFiles: Map<File, ProcessedFile>;
|
|
||||||
processingStates: Map<string, ProcessingState>;
|
|
||||||
isProcessing: boolean;
|
|
||||||
hasProcessingErrors: boolean;
|
|
||||||
processingProgress: {
|
|
||||||
overall: number;
|
|
||||||
fileProgress: Map<string, number>;
|
|
||||||
estimatedTimeRemaining: number;
|
|
||||||
};
|
|
||||||
cacheStats: {
|
|
||||||
entries: number;
|
|
||||||
totalSizeBytes: number;
|
|
||||||
maxSizeBytes: number;
|
|
||||||
};
|
|
||||||
metrics: {
|
|
||||||
totalFiles: number;
|
|
||||||
completedFiles: number;
|
|
||||||
failedFiles: number;
|
|
||||||
averageProcessingTime: number;
|
|
||||||
cacheHitRate: number;
|
|
||||||
};
|
|
||||||
actions: {
|
|
||||||
cancelProcessing: (fileKey: string) => void;
|
|
||||||
retryProcessing: (file: File) => void;
|
|
||||||
clearCache: () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEnhancedProcessedFiles(
|
|
||||||
activeFiles: File[],
|
|
||||||
config?: Partial<ProcessingConfig>
|
|
||||||
): UseEnhancedProcessedFilesResult {
|
|
||||||
const [processedFiles, setProcessedFiles] = useState<Map<File, ProcessedFile>>(new Map());
|
|
||||||
const fileHashMapRef = useRef<Map<File, string>>(new Map()); // Use ref to avoid state update loops
|
|
||||||
const [processingStates, setProcessingStates] = useState<Map<string, ProcessingState>>(new Map());
|
|
||||||
|
|
||||||
// Subscribe to processing state changes once
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = enhancedPDFProcessingService.onProcessingChange(setProcessingStates);
|
|
||||||
return unsubscribe;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Process files when activeFiles changes
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files');
|
|
||||||
|
|
||||||
if (activeFiles.length === 0) {
|
|
||||||
console.log('useEnhancedProcessedFiles: No active files, clearing processed cache');
|
|
||||||
setProcessedFiles(new Map());
|
|
||||||
// Clear any ongoing processing when no files
|
|
||||||
enhancedPDFProcessingService.clearAllProcessing();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const processFiles = async () => {
|
|
||||||
const newProcessedFiles = new Map<File, ProcessedFile>();
|
|
||||||
|
|
||||||
for (const file of activeFiles) {
|
|
||||||
// Generate hash for this file
|
|
||||||
const fileHash = await FileHasher.generateHybridHash(file);
|
|
||||||
fileHashMapRef.current.set(file, fileHash);
|
|
||||||
|
|
||||||
// First, check if we have this exact File object cached
|
|
||||||
let existing = processedFiles.get(file);
|
|
||||||
|
|
||||||
// If not found by File object, try to find by hash in case File was recreated
|
|
||||||
if (!existing) {
|
|
||||||
for (const [cachedFile, processed] of processedFiles.entries()) {
|
|
||||||
const cachedHash = fileHashMapRef.current.get(cachedFile);
|
|
||||||
if (cachedHash === fileHash) {
|
|
||||||
existing = processed;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
newProcessedFiles.set(file, existing);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const processed = await enhancedPDFProcessingService.processFile(file, config);
|
|
||||||
if (processed) {
|
|
||||||
newProcessedFiles.set(file, processed);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to start processing for ${file.name}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update if the content actually changed
|
|
||||||
const hasChanged = newProcessedFiles.size !== processedFiles.size ||
|
|
||||||
Array.from(newProcessedFiles.keys()).some(file => !processedFiles.has(file));
|
|
||||||
|
|
||||||
if (hasChanged) {
|
|
||||||
setProcessedFiles(newProcessedFiles);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
processFiles();
|
|
||||||
}, [activeFiles]); // Only depend on activeFiles to avoid infinite loops
|
|
||||||
|
|
||||||
// Listen for processing completion
|
|
||||||
useEffect(() => {
|
|
||||||
const checkForCompletedFiles = async () => {
|
|
||||||
let hasNewFiles = false;
|
|
||||||
const updatedFiles = new Map(processedFiles);
|
|
||||||
|
|
||||||
// Generate file keys for all files first
|
|
||||||
const fileKeyPromises = activeFiles.map(async (file) => ({
|
|
||||||
file,
|
|
||||||
key: await FileHasher.generateHybridHash(file)
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fileKeyPairs = await Promise.all(fileKeyPromises);
|
|
||||||
|
|
||||||
for (const { file, key } of fileKeyPairs) {
|
|
||||||
// Only check files that don't have processed results yet
|
|
||||||
if (!updatedFiles.has(file)) {
|
|
||||||
const processingState = processingStates.get(key);
|
|
||||||
|
|
||||||
// Check for both processing and recently completed files
|
|
||||||
// This ensures we catch completed files before they're cleaned up
|
|
||||||
if (processingState?.status === 'processing' || processingState?.status === 'completed') {
|
|
||||||
try {
|
|
||||||
const processed = await enhancedPDFProcessingService.processFile(file, config);
|
|
||||||
if (processed) {
|
|
||||||
updatedFiles.set(file, processed);
|
|
||||||
hasNewFiles = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors in completion check
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNewFiles) {
|
|
||||||
setProcessedFiles(updatedFiles);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check every 500ms for completed processing
|
|
||||||
const interval = setInterval(checkForCompletedFiles, 500);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [activeFiles, processingStates]);
|
|
||||||
|
|
||||||
|
|
||||||
// Cleanup when activeFiles changes
|
|
||||||
useEffect(() => {
|
|
||||||
const currentFiles = new Set(activeFiles);
|
|
||||||
const previousFiles = Array.from(processedFiles.keys());
|
|
||||||
const removedFiles = previousFiles.filter(file => !currentFiles.has(file));
|
|
||||||
|
|
||||||
if (removedFiles.length > 0) {
|
|
||||||
// Clean up processing service cache
|
|
||||||
enhancedPDFProcessingService.cleanup(removedFiles);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setProcessedFiles(prev => {
|
|
||||||
const updated = new Map();
|
|
||||||
for (const [file, processed] of prev) {
|
|
||||||
if (currentFiles.has(file)) {
|
|
||||||
updated.set(file, processed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [activeFiles]);
|
|
||||||
|
|
||||||
// Calculate derived state
|
|
||||||
const isProcessing = processingStates.size > 0;
|
|
||||||
const hasProcessingErrors = Array.from(processingStates.values()).some(state => state.status === 'error');
|
|
||||||
|
|
||||||
// Calculate overall progress
|
|
||||||
const processingProgress = calculateProcessingProgress(processingStates);
|
|
||||||
|
|
||||||
// Get cache stats and metrics
|
|
||||||
const cacheStats = enhancedPDFProcessingService.getCacheStats();
|
|
||||||
const metrics = enhancedPDFProcessingService.getMetrics();
|
|
||||||
|
|
||||||
// Action handlers
|
|
||||||
const actions = {
|
|
||||||
cancelProcessing: (fileKey: string) => {
|
|
||||||
enhancedPDFProcessingService.cancelProcessing(fileKey);
|
|
||||||
},
|
|
||||||
|
|
||||||
retryProcessing: async (file: File) => {
|
|
||||||
try {
|
|
||||||
await enhancedPDFProcessingService.processFile(file, config);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to retry processing for ${file.name}:`, error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clearCache: () => {
|
|
||||||
enhancedPDFProcessingService.clearAll();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
enhancedPDFProcessingService.clearAllProcessing();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
processedFiles,
|
|
||||||
processingStates,
|
|
||||||
isProcessing,
|
|
||||||
hasProcessingErrors,
|
|
||||||
processingProgress,
|
|
||||||
cacheStats,
|
|
||||||
metrics,
|
|
||||||
actions
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate overall processing progress from individual file states
|
|
||||||
*/
|
|
||||||
function calculateProcessingProgress(states: Map<string, ProcessingState>): {
|
|
||||||
overall: number;
|
|
||||||
fileProgress: Map<string, number>;
|
|
||||||
estimatedTimeRemaining: number;
|
|
||||||
} {
|
|
||||||
if (states.size === 0) {
|
|
||||||
return {
|
|
||||||
overall: 100,
|
|
||||||
fileProgress: new Map(),
|
|
||||||
estimatedTimeRemaining: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileProgress = new Map<string, number>();
|
|
||||||
let totalProgress = 0;
|
|
||||||
let totalEstimatedTime = 0;
|
|
||||||
|
|
||||||
for (const [fileKey, state] of states) {
|
|
||||||
fileProgress.set(fileKey, state.progress);
|
|
||||||
totalProgress += state.progress;
|
|
||||||
totalEstimatedTime += state.estimatedTimeRemaining || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overall = totalProgress / states.size;
|
|
||||||
const estimatedTimeRemaining = totalEstimatedTime;
|
|
||||||
|
|
||||||
return {
|
|
||||||
overall,
|
|
||||||
fileProgress,
|
|
||||||
estimatedTimeRemaining
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for getting a single processed file with enhanced features
|
|
||||||
*/
|
|
||||||
export function useEnhancedProcessedFile(
|
|
||||||
file: File | null,
|
|
||||||
config?: Partial<ProcessingConfig>
|
|
||||||
): {
|
|
||||||
processedFile: ProcessedFile | null;
|
|
||||||
isProcessing: boolean;
|
|
||||||
processingState: ProcessingState | null;
|
|
||||||
error: string | null;
|
|
||||||
canRetry: boolean;
|
|
||||||
actions: {
|
|
||||||
cancel: () => void;
|
|
||||||
retry: () => void;
|
|
||||||
};
|
|
||||||
} {
|
|
||||||
const result = useEnhancedProcessedFiles(file ? [file] : [], config);
|
|
||||||
|
|
||||||
const processedFile = file ? result.processedFiles.get(file) || null : null;
|
|
||||||
// Note: This is async but we can't await in hook return - consider refactoring if needed
|
|
||||||
const fileKey = file ? '' : '';
|
|
||||||
const processingState = fileKey ? result.processingStates.get(fileKey) || null : null;
|
|
||||||
const isProcessing = !!processingState;
|
|
||||||
const error = processingState?.error?.message || null;
|
|
||||||
const canRetry = processingState?.error?.recoverable || false;
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
cancel: () => {
|
|
||||||
if (fileKey) {
|
|
||||||
result.actions.cancelProcessing(fileKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
retry: () => {
|
|
||||||
if (file) {
|
|
||||||
result.actions.retryProcessing(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
processedFile,
|
|
||||||
isProcessing,
|
|
||||||
processingState,
|
|
||||||
error,
|
|
||||||
canRetry,
|
|
||||||
actions
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,126 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
|
||||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
|
||||||
import { createQuickKey } from '../types/fileContext';
|
|
||||||
|
|
||||||
export function usePDFProcessor() {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const generatePageThumbnail = useCallback(async (
|
|
||||||
file: File,
|
|
||||||
pageNumber: number,
|
|
||||||
scale: number = 0.5
|
|
||||||
): Promise<string> => {
|
|
||||||
try {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
|
||||||
const page = await pdf.getPage(pageNumber);
|
|
||||||
|
|
||||||
const viewport = page.getViewport({ scale });
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('Could not get canvas context');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.render({ canvasContext: context, viewport }).promise;
|
|
||||||
const thumbnail = canvas.toDataURL();
|
|
||||||
|
|
||||||
// Clean up using worker manager
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
|
|
||||||
return thumbnail;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate thumbnail:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Internal function to generate thumbnail from already-opened PDF
|
|
||||||
const generateThumbnailFromPDF = useCallback(async (
|
|
||||||
pdf: any,
|
|
||||||
pageNumber: number,
|
|
||||||
scale: number = 0.5
|
|
||||||
): Promise<string> => {
|
|
||||||
const page = await pdf.getPage(pageNumber);
|
|
||||||
|
|
||||||
const viewport = page.getViewport({ scale });
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('Could not get canvas context');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.render({ canvasContext: context, viewport }).promise;
|
|
||||||
return canvas.toDataURL();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const processPDFFile = useCallback(async (file: File): Promise<PDFDocument> => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
|
||||||
const totalPages = pdf.numPages;
|
|
||||||
|
|
||||||
const pages: PDFPage[] = [];
|
|
||||||
|
|
||||||
// Create pages without thumbnails initially - load them lazily
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
|
||||||
pages.push({
|
|
||||||
id: `${createQuickKey(file)}-page-${i}`,
|
|
||||||
pageNumber: i,
|
|
||||||
originalPageNumber: i,
|
|
||||||
thumbnail: null, // Will be loaded lazily
|
|
||||||
rotation: 0,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate thumbnails for first 10 pages immediately using the same PDF instance
|
|
||||||
const priorityPages = Math.min(10, totalPages);
|
|
||||||
for (let i = 1; i <= priorityPages; i++) {
|
|
||||||
try {
|
|
||||||
const thumbnail = await generateThumbnailFromPDF(pdf, i);
|
|
||||||
pages[i - 1].thumbnail = thumbnail;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to generate thumbnail for page ${i}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up using worker manager
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
|
|
||||||
const document: PDFDocument = {
|
|
||||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
||||||
name: file.name,
|
|
||||||
file,
|
|
||||||
pages,
|
|
||||||
totalPages
|
|
||||||
};
|
|
||||||
|
|
||||||
return document;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to process PDF';
|
|
||||||
setError(errorMessage);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [generateThumbnailFromPDF]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
processPDFFile,
|
|
||||||
generatePageThumbnail,
|
|
||||||
loading,
|
|
||||||
error
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,125 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { ProcessedFile, ProcessingState } from '../types/processing';
|
|
||||||
import { pdfProcessingService } from '../services/pdfProcessingService';
|
|
||||||
|
|
||||||
interface UseProcessedFilesResult {
|
|
||||||
processedFiles: Map<File, ProcessedFile>;
|
|
||||||
processingStates: Map<string, ProcessingState>;
|
|
||||||
isProcessing: boolean;
|
|
||||||
hasProcessingErrors: boolean;
|
|
||||||
cacheStats: {
|
|
||||||
entries: number;
|
|
||||||
totalSizeBytes: number;
|
|
||||||
maxSizeBytes: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProcessedFiles(activeFiles: File[]): UseProcessedFilesResult {
|
|
||||||
const [processedFiles, setProcessedFiles] = useState<Map<File, ProcessedFile>>(new Map());
|
|
||||||
const [processingStates, setProcessingStates] = useState<Map<string, ProcessingState>>(new Map());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Subscribe to processing state changes
|
|
||||||
const unsubscribe = pdfProcessingService.onProcessingChange(setProcessingStates);
|
|
||||||
|
|
||||||
// Check/start processing for each active file
|
|
||||||
const checkProcessing = async () => {
|
|
||||||
const newProcessedFiles = new Map<File, ProcessedFile>();
|
|
||||||
|
|
||||||
for (const file of activeFiles) {
|
|
||||||
const processed = await pdfProcessingService.getProcessedFile(file);
|
|
||||||
if (processed) {
|
|
||||||
newProcessedFiles.set(file, processed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setProcessedFiles(newProcessedFiles);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkProcessing();
|
|
||||||
|
|
||||||
return unsubscribe;
|
|
||||||
}, [activeFiles]);
|
|
||||||
|
|
||||||
// Listen for processing completion and update processed files
|
|
||||||
useEffect(() => {
|
|
||||||
const updateProcessedFiles = async () => {
|
|
||||||
const updated = new Map<File, ProcessedFile>();
|
|
||||||
|
|
||||||
for (const file of activeFiles) {
|
|
||||||
const existing = processedFiles.get(file);
|
|
||||||
if (existing) {
|
|
||||||
updated.set(file, existing);
|
|
||||||
} else {
|
|
||||||
// Check if processing just completed
|
|
||||||
const processed = await pdfProcessingService.getProcessedFile(file);
|
|
||||||
if (processed) {
|
|
||||||
updated.set(file, processed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setProcessedFiles(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Small delay to allow processing state to settle
|
|
||||||
const timeoutId = setTimeout(updateProcessedFiles, 100);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}, [processingStates, activeFiles]);
|
|
||||||
|
|
||||||
// Cleanup when activeFiles changes
|
|
||||||
useEffect(() => {
|
|
||||||
const currentFiles = new Set(activeFiles);
|
|
||||||
const previousFiles = Array.from(processedFiles.keys());
|
|
||||||
const removedFiles = previousFiles.filter(file => !currentFiles.has(file));
|
|
||||||
|
|
||||||
if (removedFiles.length > 0) {
|
|
||||||
// Clean up processing service cache
|
|
||||||
pdfProcessingService.cleanup(removedFiles);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setProcessedFiles(prev => {
|
|
||||||
const updated = new Map();
|
|
||||||
for (const [file, processed] of prev) {
|
|
||||||
if (currentFiles.has(file)) {
|
|
||||||
updated.set(file, processed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [activeFiles]);
|
|
||||||
|
|
||||||
// Derived state
|
|
||||||
const isProcessing = processingStates.size > 0;
|
|
||||||
const hasProcessingErrors = Array.from(processingStates.values()).some(state => state.status === 'error');
|
|
||||||
const cacheStats = pdfProcessingService.getCacheStats();
|
|
||||||
|
|
||||||
return {
|
|
||||||
processedFiles,
|
|
||||||
processingStates,
|
|
||||||
isProcessing,
|
|
||||||
hasProcessingErrors,
|
|
||||||
cacheStats
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook for getting a single processed file
|
|
||||||
export function useProcessedFile(file: File | null): {
|
|
||||||
processedFile: ProcessedFile | null;
|
|
||||||
isProcessing: boolean;
|
|
||||||
processingState: ProcessingState | null;
|
|
||||||
} {
|
|
||||||
const result = useProcessedFiles(file ? [file] : []);
|
|
||||||
|
|
||||||
const processedFile = file ? result.processedFiles.get(file) || null : null;
|
|
||||||
const fileKey = file ? pdfProcessingService.generateFileKey(file) : '';
|
|
||||||
const processingState = fileKey ? result.processingStates.get(fileKey) || null : null;
|
|
||||||
const isProcessing = !!processingState;
|
|
||||||
|
|
||||||
return {
|
|
||||||
processedFile,
|
|
||||||
isProcessing,
|
|
||||||
processingState
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { useRightRail } from '../contexts/RightRailContext';
|
|
||||||
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
|
|
||||||
|
|
||||||
export interface RightRailButtonWithAction extends RightRailButtonConfig {
|
|
||||||
onClick: RightRailAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers one or more RightRail buttons and their actions.
|
|
||||||
* - Automatically registers on mount and unregisters on unmount
|
|
||||||
* - Updates registration when the input array reference changes
|
|
||||||
*/
|
|
||||||
export function useRightRailButtons(buttons: readonly RightRailButtonWithAction[]) {
|
|
||||||
const { registerButtons, unregisterButtons, setAction } = useRightRail();
|
|
||||||
|
|
||||||
// Memoize configs and ids to reduce churn
|
|
||||||
const configs: RightRailButtonConfig[] = useMemo(
|
|
||||||
() => buttons.map(({ onClick, ...cfg }) => cfg),
|
|
||||||
[buttons]
|
|
||||||
);
|
|
||||||
const ids: string[] = useMemo(() => buttons.map(b => b.id), [buttons]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!buttons || buttons.length === 0) return;
|
|
||||||
|
|
||||||
// DEV warnings for duplicate ids or missing handlers
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
const idSet = new Set<string>();
|
|
||||||
buttons.forEach(b => {
|
|
||||||
if (!b.onClick) console.warn('[RightRail] Missing onClick for id:', b.id);
|
|
||||||
if (idSet.has(b.id)) console.warn('[RightRail] Duplicate id in buttons array:', b.id);
|
|
||||||
idSet.add(b.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register visual button configs (idempotent merge by id)
|
|
||||||
registerButtons(configs);
|
|
||||||
|
|
||||||
// Bind/update actions independent of registration
|
|
||||||
buttons.forEach(({ id, onClick }) => setAction(id, onClick));
|
|
||||||
|
|
||||||
// Cleanup unregisters by ids present in this call
|
|
||||||
return () => unregisterButtons(ids);
|
|
||||||
}, [registerButtons, unregisterButtons, setAction, configs, ids, buttons]);
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* React hooks for tool parameter management (URL logic removed)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
type ToolParameterValues = Record<string, any>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register tool parameters and get current values
|
|
||||||
*/
|
|
||||||
export function useToolParameters(
|
|
||||||
_toolName: string,
|
|
||||||
_parameters: Record<string, any>
|
|
||||||
): [ToolParameterValues, (updates: Partial<ToolParameterValues>) => void] {
|
|
||||||
|
|
||||||
// Return empty values and noop updater
|
|
||||||
const currentValues = useMemo(() => ({}), []);
|
|
||||||
const updateParameters = useCallback(() => {}, []);
|
|
||||||
|
|
||||||
return [currentValues, updateParameters];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for managing a single tool parameter
|
|
||||||
*/
|
|
||||||
export function useToolParameter<T = any>(
|
|
||||||
toolName: string,
|
|
||||||
paramName: string,
|
|
||||||
definition: any
|
|
||||||
): [T, (value: T) => void] {
|
|
||||||
const [allParams, updateParams] = useToolParameters(toolName, { [paramName]: definition });
|
|
||||||
|
|
||||||
const value = allParams[paramName] as T;
|
|
||||||
|
|
||||||
const setValue = useCallback((newValue: T) => {
|
|
||||||
updateParams({ [paramName]: newValue });
|
|
||||||
}, [paramName, updateParams]);
|
|
||||||
|
|
||||||
return [value, setValue];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for getting/setting global parameters (zoom, page, etc.)
|
|
||||||
*/
|
|
||||||
export function useGlobalParameters() {
|
|
||||||
const currentValues = useMemo(() => ({}), []);
|
|
||||||
const updateParameters = useCallback(() => {}, []);
|
|
||||||
|
|
||||||
return [currentValues, updateParameters];
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
// Re-export react-i18next hook with our custom types
|
|
||||||
export { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
// You can add custom hooks here later if needed
|
|
||||||
// For example, a hook that returns commonly used translations
|
|
||||||
import { useTranslation as useI18nTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export const useCommonTranslations = () => {
|
|
||||||
const { t } = useI18nTranslation();
|
|
||||||
|
|
||||||
return {
|
|
||||||
submit: t('genericSubmit'),
|
|
||||||
selectPdf: t('pdfPrompt'),
|
|
||||||
selectPdfs: t('multiPdfPrompt'),
|
|
||||||
selectImages: t('imgPrompt'),
|
|
||||||
loading: t('loading', 'Loading...'), // fallback if not found
|
|
||||||
error: t('error._value', 'Error'),
|
|
||||||
success: t('success', 'Success'),
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,68 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
export interface Command {
|
|
||||||
execute(): void;
|
|
||||||
undo(): void;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandSequence {
|
|
||||||
commands: Command[];
|
|
||||||
execute(): void;
|
|
||||||
undo(): void;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUndoRedo() {
|
|
||||||
const [undoStack, setUndoStack] = useState<(Command | CommandSequence)[]>([]);
|
|
||||||
const [redoStack, setRedoStack] = useState<(Command | CommandSequence)[]>([]);
|
|
||||||
|
|
||||||
const executeCommand = useCallback((command: Command | CommandSequence) => {
|
|
||||||
command.execute();
|
|
||||||
setUndoStack(prev => [command, ...prev]);
|
|
||||||
setRedoStack([]); // Clear redo stack when new command is executed
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const undo = useCallback(() => {
|
|
||||||
if (undoStack.length === 0) return false;
|
|
||||||
|
|
||||||
const command = undoStack[0];
|
|
||||||
command.undo();
|
|
||||||
|
|
||||||
setUndoStack(prev => prev.slice(1));
|
|
||||||
setRedoStack(prev => [command, ...prev]);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}, [undoStack]);
|
|
||||||
|
|
||||||
const redo = useCallback(() => {
|
|
||||||
if (redoStack.length === 0) return false;
|
|
||||||
|
|
||||||
const command = redoStack[0];
|
|
||||||
command.execute();
|
|
||||||
|
|
||||||
setRedoStack(prev => prev.slice(1));
|
|
||||||
setUndoStack(prev => [command, ...prev]);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}, [redoStack]);
|
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
|
||||||
setUndoStack([]);
|
|
||||||
setRedoStack([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const canUndo = undoStack.length > 0;
|
|
||||||
const canRedo = redoStack.length > 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
executeCommand,
|
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
clear,
|
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
undoStack,
|
|
||||||
redoStack
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,553 +0,0 @@
|
|||||||
import { ProcessedFile, ProcessingState, PDFPage, ProcessingConfig, ProcessingMetrics } from '../types/processing';
|
|
||||||
import { ProcessingCache } from './processingCache';
|
|
||||||
import { FileHasher } from '../utils/fileHash';
|
|
||||||
import { FileAnalyzer } from './fileAnalyzer';
|
|
||||||
import { ProcessingErrorHandler } from './processingErrorHandler';
|
|
||||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
|
||||||
import { createQuickKey } from '../types/fileContext';
|
|
||||||
|
|
||||||
export class EnhancedPDFProcessingService {
|
|
||||||
private static instance: EnhancedPDFProcessingService;
|
|
||||||
private cache = new ProcessingCache();
|
|
||||||
private processing = new Map<string, ProcessingState>();
|
|
||||||
private processingListeners = new Set<(states: Map<string, ProcessingState>) => void>();
|
|
||||||
private metrics: ProcessingMetrics = {
|
|
||||||
totalFiles: 0,
|
|
||||||
completedFiles: 0,
|
|
||||||
failedFiles: 0,
|
|
||||||
averageProcessingTime: 0,
|
|
||||||
cacheHitRate: 0,
|
|
||||||
memoryUsage: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
private defaultConfig: ProcessingConfig = {
|
|
||||||
strategy: 'immediate_full',
|
|
||||||
chunkSize: 20,
|
|
||||||
thumbnailQuality: 'medium',
|
|
||||||
priorityPageCount: 10,
|
|
||||||
useWebWorker: false,
|
|
||||||
maxRetries: 3
|
|
||||||
};
|
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
static getInstance(): EnhancedPDFProcessingService {
|
|
||||||
if (!EnhancedPDFProcessingService.instance) {
|
|
||||||
EnhancedPDFProcessingService.instance = new EnhancedPDFProcessingService();
|
|
||||||
}
|
|
||||||
return EnhancedPDFProcessingService.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a file with intelligent strategy selection
|
|
||||||
*/
|
|
||||||
async processFile(file: File, customConfig?: Partial<ProcessingConfig>): Promise<ProcessedFile | null> {
|
|
||||||
const fileKey = await this.generateFileKey(file);
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = this.cache.get(fileKey);
|
|
||||||
if (cached) {
|
|
||||||
this.updateMetrics('cacheHit');
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already processing
|
|
||||||
if (this.processing.has(fileKey)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analyze file to determine optimal strategy
|
|
||||||
const analysis = await FileAnalyzer.analyzeFile(file);
|
|
||||||
if (analysis.isCorrupted) {
|
|
||||||
throw new Error(`File ${file.name} appears to be corrupted`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create processing config
|
|
||||||
const config: ProcessingConfig = {
|
|
||||||
...this.defaultConfig,
|
|
||||||
strategy: analysis.recommendedStrategy,
|
|
||||||
...customConfig
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start processing
|
|
||||||
this.startProcessing(file, fileKey, config, analysis.estimatedProcessingTime);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start processing a file with the specified configuration
|
|
||||||
*/
|
|
||||||
private async startProcessing(
|
|
||||||
file: File,
|
|
||||||
fileKey: string,
|
|
||||||
config: ProcessingConfig,
|
|
||||||
estimatedTime: number
|
|
||||||
): Promise<void> {
|
|
||||||
// Create cancellation token
|
|
||||||
const cancellationToken = new AbortController();
|
|
||||||
|
|
||||||
// Set initial state
|
|
||||||
const state: ProcessingState = {
|
|
||||||
fileKey,
|
|
||||||
fileName: file.name,
|
|
||||||
status: 'processing',
|
|
||||||
progress: 0,
|
|
||||||
strategy: config.strategy,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
estimatedTimeRemaining: estimatedTime,
|
|
||||||
cancellationToken
|
|
||||||
};
|
|
||||||
|
|
||||||
this.processing.set(fileKey, state);
|
|
||||||
this.notifyListeners();
|
|
||||||
this.updateMetrics('started');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Execute processing with retry logic
|
|
||||||
const processedFile = await ProcessingErrorHandler.executeWithRetry(
|
|
||||||
() => this.executeProcessingStrategy(file, config, state),
|
|
||||||
(error) => {
|
|
||||||
state.error = error;
|
|
||||||
this.notifyListeners();
|
|
||||||
},
|
|
||||||
config.maxRetries
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
this.cache.set(fileKey, processedFile);
|
|
||||||
|
|
||||||
// Update state to completed
|
|
||||||
state.status = 'completed';
|
|
||||||
state.progress = 100;
|
|
||||||
state.completedAt = Date.now();
|
|
||||||
this.notifyListeners();
|
|
||||||
this.updateMetrics('completed', Date.now() - state.startedAt);
|
|
||||||
|
|
||||||
// Remove from processing map after brief delay
|
|
||||||
setTimeout(() => {
|
|
||||||
this.processing.delete(fileKey);
|
|
||||||
this.notifyListeners();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Processing failed for', file.name, ':', error);
|
|
||||||
|
|
||||||
const processingError = ProcessingErrorHandler.createProcessingError(error);
|
|
||||||
state.status = 'error';
|
|
||||||
state.error = processingError;
|
|
||||||
this.notifyListeners();
|
|
||||||
this.updateMetrics('failed');
|
|
||||||
|
|
||||||
// Remove failed processing after delay
|
|
||||||
setTimeout(() => {
|
|
||||||
this.processing.delete(fileKey);
|
|
||||||
this.notifyListeners();
|
|
||||||
}, 10000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the actual processing based on strategy
|
|
||||||
*/
|
|
||||||
private async executeProcessingStrategy(
|
|
||||||
file: File,
|
|
||||||
config: ProcessingConfig,
|
|
||||||
state: ProcessingState
|
|
||||||
): Promise<ProcessedFile> {
|
|
||||||
switch (config.strategy) {
|
|
||||||
case 'immediate_full':
|
|
||||||
return this.processImmediateFull(file, config, state);
|
|
||||||
|
|
||||||
case 'priority_pages':
|
|
||||||
return this.processPriorityPages(file, config, state);
|
|
||||||
|
|
||||||
case 'progressive_chunked':
|
|
||||||
return this.processProgressiveChunked(file, config, state);
|
|
||||||
|
|
||||||
case 'metadata_only':
|
|
||||||
return this.processMetadataOnly(file, config, state);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return this.processImmediateFull(file, config, state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process all pages immediately (for small files)
|
|
||||||
*/
|
|
||||||
private async processImmediateFull(
|
|
||||||
file: File,
|
|
||||||
config: ProcessingConfig,
|
|
||||||
state: ProcessingState
|
|
||||||
): Promise<ProcessedFile> {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const totalPages = pdf.numPages;
|
|
||||||
|
|
||||||
state.progress = 10;
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
const pages: PDFPage[] = [];
|
|
||||||
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
|
||||||
// Check for cancellation
|
|
||||||
if (state.cancellationToken?.signal.aborted) {
|
|
||||||
throw new Error('Processing cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await pdf.getPage(i);
|
|
||||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
|
||||||
|
|
||||||
pages.push({
|
|
||||||
id: `${createQuickKey(file)}-page-${i}`,
|
|
||||||
pageNumber: i,
|
|
||||||
thumbnail,
|
|
||||||
rotation: 0,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update progress
|
|
||||||
state.progress = 10 + (i / totalPages) * 85;
|
|
||||||
state.currentPage = i;
|
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.createProcessedFile(file, pages, totalPages);
|
|
||||||
} finally {
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
state.progress = 100;
|
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process priority pages first, then queue the rest
|
|
||||||
*/
|
|
||||||
private async processPriorityPages(
|
|
||||||
file: File,
|
|
||||||
config: ProcessingConfig,
|
|
||||||
state: ProcessingState
|
|
||||||
): Promise<ProcessedFile> {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
|
||||||
const totalPages = pdf.numPages;
|
|
||||||
|
|
||||||
state.progress = 10;
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
const pages: PDFPage[] = [];
|
|
||||||
const priorityCount = Math.min(config.priorityPageCount, totalPages);
|
|
||||||
|
|
||||||
// Process priority pages first
|
|
||||||
for (let i = 1; i <= priorityCount; i++) {
|
|
||||||
if (state.cancellationToken?.signal.aborted) {
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
throw new Error('Processing cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await pdf.getPage(i);
|
|
||||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
|
||||||
|
|
||||||
pages.push({
|
|
||||||
id: `${createQuickKey(file)}-page-${i}`,
|
|
||||||
pageNumber: i,
|
|
||||||
thumbnail,
|
|
||||||
rotation: 0,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
|
|
||||||
state.progress = 10 + (i / priorityCount) * 60;
|
|
||||||
state.currentPage = i;
|
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create placeholder pages for remaining pages
|
|
||||||
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
|
||||||
pages.push({
|
|
||||||
id: `${createQuickKey(file)}-page-${i}`,
|
|
||||||
pageNumber: i,
|
|
||||||
thumbnail: null, // Will be loaded lazily
|
|
||||||
rotation: 0,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
state.progress = 100;
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
return this.createProcessedFile(file, pages, totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process in chunks with breaks between chunks
|
|
||||||
*/
|
|
||||||
private async processProgressiveChunked(
|
|
||||||
file: File,
|
|
||||||
config: ProcessingConfig,
|
|
||||||
state: ProcessingState
|
|
||||||
): Promise<ProcessedFile> {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
|
||||||
const totalPages = pdf.numPages;
|
|
||||||
|
|
||||||
state.progress = 10;
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
const pages: PDFPage[] = [];
|
|
||||||
const chunkSize = config.chunkSize;
|
|
||||||
let processedPages = 0;
|
|
||||||
|
|
||||||
// Process first chunk immediately
|
|
||||||
const firstChunkEnd = Math.min(chunkSize, totalPages);
|
|
||||||
|
|
||||||
for (let i = 1; i <= firstChunkEnd; i++) {
|
|
||||||
if (state.cancellationToken?.signal.aborted) {
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
throw new Error('Processing cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await pdf.getPage(i);
|
|
||||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
|
||||||
|
|
||||||
pages.push({
|
|
||||||
id: `${createQuickKey(file)}-page-${i}`,
|
|
||||||
pageNumber: i,
|
|
||||||
thumbnail,
|
|
||||||
rotation: 0,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
|
|
||||||
processedPages++;
|
|
||||||
state.progress = 10 + (processedPages / totalPages) * 70;
|
|
||||||
state.currentPage = i;
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
// Small delay to prevent UI blocking
|
|
||||||
if (i % 5 === 0) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create placeholders for remaining pages
|
|
||||||
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
|
||||||
pages.push({
|
|
||||||
id: `${createQuickKey(file)}-page-${i}`,
|
|
||||||
pageNumber: i,
|
|
||||||
thumbnail: null,
|
|
||||||
rotation: 0,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
state.progress = 100;
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
return this.createProcessedFile(file, pages, totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process metadata only (for very large files)
|
|
||||||
*/
|
|
||||||
private async processMetadataOnly(
|
|
||||||
file: File,
|
|
||||||
_config: ProcessingConfig,
|
|
||||||
state: ProcessingState
|
|
||||||
): Promise<ProcessedFile> {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
|
||||||
const totalPages = pdf.numPages;
|
|
||||||
|
|
||||||
state.progress = 50;
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
// Create placeholder pages without thumbnails
|
|
||||||
const pages: PDFPage[] = [];
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
|
||||||
pages.push({
|
|
||||||
id: `${createQuickKey(file)}-page-${i}`,
|
|
||||||
pageNumber: i,
|
|
||||||
thumbnail: null,
|
|
||||||
rotation: 0,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
state.progress = 100;
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
return this.createProcessedFile(file, pages, totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a page thumbnail with specified quality
|
|
||||||
*/
|
|
||||||
private async renderPageThumbnail(page: any, quality: 'low' | 'medium' | 'high'): Promise<string> {
|
|
||||||
const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor
|
|
||||||
const scale = scales[quality];
|
|
||||||
|
|
||||||
const viewport = page.getViewport({ scale });
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('Could not get canvas context');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.render({ canvasContext: context, viewport }).promise;
|
|
||||||
return canvas.toDataURL('image/jpeg', 0.8); // Use JPEG for better compression
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a ProcessedFile object
|
|
||||||
*/
|
|
||||||
private createProcessedFile(file: File, pages: PDFPage[], totalPages: number): ProcessedFile {
|
|
||||||
return {
|
|
||||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
||||||
pages,
|
|
||||||
totalPages,
|
|
||||||
metadata: {
|
|
||||||
title: file.name,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
modifiedAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a unique, collision-resistant cache key
|
|
||||||
*/
|
|
||||||
private async generateFileKey(file: File): Promise<string> {
|
|
||||||
return await FileHasher.generateHybridHash(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel processing for a specific file
|
|
||||||
*/
|
|
||||||
cancelProcessing(fileKey: string): void {
|
|
||||||
const state = this.processing.get(fileKey);
|
|
||||||
if (state && state.cancellationToken) {
|
|
||||||
state.cancellationToken.abort();
|
|
||||||
state.status = 'cancelled';
|
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update processing metrics
|
|
||||||
*/
|
|
||||||
private updateMetrics(event: 'started' | 'completed' | 'failed' | 'cacheHit', processingTime?: number): void {
|
|
||||||
switch (event) {
|
|
||||||
case 'started':
|
|
||||||
this.metrics.totalFiles++;
|
|
||||||
break;
|
|
||||||
case 'completed':
|
|
||||||
this.metrics.completedFiles++;
|
|
||||||
if (processingTime) {
|
|
||||||
// Update rolling average
|
|
||||||
const totalProcessingTime = this.metrics.averageProcessingTime * (this.metrics.completedFiles - 1) + processingTime;
|
|
||||||
this.metrics.averageProcessingTime = totalProcessingTime / this.metrics.completedFiles;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'failed':
|
|
||||||
this.metrics.failedFiles++;
|
|
||||||
break;
|
|
||||||
case 'cacheHit': {
|
|
||||||
// Update cache hit rate
|
|
||||||
const totalAttempts = this.metrics.totalFiles + 1;
|
|
||||||
this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get processing metrics
|
|
||||||
*/
|
|
||||||
getMetrics(): ProcessingMetrics {
|
|
||||||
return { ...this.metrics };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* State subscription for components
|
|
||||||
*/
|
|
||||||
onProcessingChange(callback: (states: Map<string, ProcessingState>) => void): () => void {
|
|
||||||
this.processingListeners.add(callback);
|
|
||||||
return () => this.processingListeners.delete(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
getProcessingStates(): Map<string, ProcessingState> {
|
|
||||||
return new Map(this.processing);
|
|
||||||
}
|
|
||||||
|
|
||||||
private notifyListeners(): void {
|
|
||||||
this.processingListeners.forEach(callback => callback(this.processing));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup method for removed files
|
|
||||||
*/
|
|
||||||
cleanup(removedFiles: File[]): void {
|
|
||||||
removedFiles.forEach(async (file) => {
|
|
||||||
const key = await this.generateFileKey(file);
|
|
||||||
this.cache.delete(key);
|
|
||||||
this.cancelProcessing(key);
|
|
||||||
this.processing.delete(key);
|
|
||||||
});
|
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all processing for view switches
|
|
||||||
*/
|
|
||||||
clearAllProcessing(): void {
|
|
||||||
// Cancel all ongoing processing
|
|
||||||
this.processing.forEach((state) => {
|
|
||||||
if (state.cancellationToken) {
|
|
||||||
state.cancellationToken.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear processing states
|
|
||||||
this.processing.clear();
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
// Force memory cleanup hint
|
|
||||||
setTimeout(() => window.gc?.(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache statistics
|
|
||||||
*/
|
|
||||||
getCacheStats() {
|
|
||||||
return this.cache.getStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all cache and processing
|
|
||||||
*/
|
|
||||||
clearAll(): void {
|
|
||||||
this.cache.clear();
|
|
||||||
this.processing.clear();
|
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emergency cleanup - destroy all PDF workers
|
|
||||||
*/
|
|
||||||
emergencyCleanup(): void {
|
|
||||||
this.clearAllProcessing();
|
|
||||||
this.clearAll();
|
|
||||||
pdfWorkerManager.destroyAllDocuments();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
|
|
@ -1,241 +0,0 @@
|
|||||||
import { FileAnalysis, ProcessingStrategy } from '../types/processing';
|
|
||||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
|
||||||
|
|
||||||
export class FileAnalyzer {
|
|
||||||
private static readonly SIZE_THRESHOLDS = {
|
|
||||||
SMALL: 10 * 1024 * 1024, // 10MB
|
|
||||||
MEDIUM: 50 * 1024 * 1024, // 50MB
|
|
||||||
LARGE: 200 * 1024 * 1024, // 200MB
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly PAGE_THRESHOLDS = {
|
|
||||||
FEW: 10, // < 10 pages - immediate full processing
|
|
||||||
MANY: 50, // < 50 pages - priority pages
|
|
||||||
MASSIVE: 100, // < 100 pages - progressive chunked
|
|
||||||
// >100 pages = metadata only
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze a file to determine optimal processing strategy
|
|
||||||
*/
|
|
||||||
static async analyzeFile(file: File): Promise<FileAnalysis> {
|
|
||||||
const analysis: FileAnalysis = {
|
|
||||||
fileSize: file.size,
|
|
||||||
isEncrypted: false,
|
|
||||||
isCorrupted: false,
|
|
||||||
recommendedStrategy: 'metadata_only',
|
|
||||||
estimatedProcessingTime: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Quick validation and page count estimation
|
|
||||||
const quickAnalysis = await this.quickPDFAnalysis(file);
|
|
||||||
analysis.estimatedPageCount = quickAnalysis.pageCount;
|
|
||||||
analysis.isEncrypted = quickAnalysis.isEncrypted;
|
|
||||||
analysis.isCorrupted = quickAnalysis.isCorrupted;
|
|
||||||
|
|
||||||
// Determine strategy based on file characteristics
|
|
||||||
analysis.recommendedStrategy = this.determineStrategy(file.size, quickAnalysis.pageCount);
|
|
||||||
|
|
||||||
// Estimate processing time
|
|
||||||
analysis.estimatedProcessingTime = this.estimateProcessingTime(
|
|
||||||
file.size,
|
|
||||||
quickAnalysis.pageCount,
|
|
||||||
analysis.recommendedStrategy
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('File analysis failed:', error);
|
|
||||||
analysis.isCorrupted = true;
|
|
||||||
analysis.recommendedStrategy = 'metadata_only';
|
|
||||||
}
|
|
||||||
|
|
||||||
return analysis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick PDF analysis without full processing
|
|
||||||
*/
|
|
||||||
private static async quickPDFAnalysis(file: File): Promise<{
|
|
||||||
pageCount: number;
|
|
||||||
isEncrypted: boolean;
|
|
||||||
isCorrupted: boolean;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
// For small files, read the whole file
|
|
||||||
// For large files, try the whole file first (PDF.js needs the complete structure)
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
|
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
|
||||||
stopAtErrors: false, // Don't stop at minor errors
|
|
||||||
verbosity: 0 // Suppress PDF.js warnings
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageCount = pdf.numPages;
|
|
||||||
const isEncrypted = (pdf as any).isEncrypted;
|
|
||||||
|
|
||||||
// Clean up using worker manager
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageCount,
|
|
||||||
isEncrypted,
|
|
||||||
isCorrupted: false
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Try to determine if it's corruption vs encryption
|
|
||||||
const errorMessage = error instanceof Error ? error.message.toLowerCase() : '';
|
|
||||||
const isEncrypted = errorMessage.includes('password') || errorMessage.includes('encrypted');
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageCount: 0,
|
|
||||||
isEncrypted,
|
|
||||||
isCorrupted: !isEncrypted // If not encrypted, probably corrupted
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the best processing strategy based on file characteristics
|
|
||||||
*/
|
|
||||||
private static determineStrategy(fileSize: number, pageCount?: number): ProcessingStrategy {
|
|
||||||
// Handle corrupted or encrypted files
|
|
||||||
if (!pageCount || pageCount === 0) {
|
|
||||||
return 'metadata_only';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small files with few pages - process everything immediately
|
|
||||||
if (fileSize <= this.SIZE_THRESHOLDS.SMALL && pageCount <= this.PAGE_THRESHOLDS.FEW) {
|
|
||||||
return 'immediate_full';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Medium files or many pages - priority pages first, then progressive
|
|
||||||
if (fileSize <= this.SIZE_THRESHOLDS.MEDIUM && pageCount <= this.PAGE_THRESHOLDS.MANY) {
|
|
||||||
return 'priority_pages';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Large files or massive page counts - chunked processing
|
|
||||||
if (fileSize <= this.SIZE_THRESHOLDS.LARGE && pageCount <= this.PAGE_THRESHOLDS.MASSIVE) {
|
|
||||||
return 'progressive_chunked';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Very large files - metadata only
|
|
||||||
return 'metadata_only';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate processing time based on file characteristics and strategy
|
|
||||||
*/
|
|
||||||
private static estimateProcessingTime(
|
|
||||||
_fileSize: number,
|
|
||||||
pageCount: number = 0,
|
|
||||||
strategy: ProcessingStrategy
|
|
||||||
): number {
|
|
||||||
const baseTimes = {
|
|
||||||
immediate_full: 200, // 200ms per page
|
|
||||||
priority_pages: 150, // 150ms per page (optimized)
|
|
||||||
progressive_chunked: 100, // 100ms per page (chunked)
|
|
||||||
metadata_only: 50 // 50ms total
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseTime = baseTimes[strategy];
|
|
||||||
|
|
||||||
switch (strategy) {
|
|
||||||
case 'metadata_only':
|
|
||||||
return baseTime;
|
|
||||||
|
|
||||||
case 'immediate_full':
|
|
||||||
return pageCount * baseTime;
|
|
||||||
|
|
||||||
case 'priority_pages': {
|
|
||||||
// Estimate time for priority pages (first 10)
|
|
||||||
const priorityPages = Math.min(pageCount, 10);
|
|
||||||
return priorityPages * baseTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'progressive_chunked': {
|
|
||||||
// Estimate time for first chunk (20 pages)
|
|
||||||
const firstChunk = Math.min(pageCount, 20);
|
|
||||||
return firstChunk * baseTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return pageCount * baseTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get processing recommendations for a set of files
|
|
||||||
*/
|
|
||||||
static async analyzeMultipleFiles(files: File[]): Promise<{
|
|
||||||
analyses: Map<File, FileAnalysis>;
|
|
||||||
recommendations: {
|
|
||||||
totalEstimatedTime: number;
|
|
||||||
suggestedBatchSize: number;
|
|
||||||
shouldUseWebWorker: boolean;
|
|
||||||
memoryWarning: boolean;
|
|
||||||
};
|
|
||||||
}> {
|
|
||||||
const analyses = new Map<File, FileAnalysis>();
|
|
||||||
let totalEstimatedTime = 0;
|
|
||||||
let totalSize = 0;
|
|
||||||
let totalPages = 0;
|
|
||||||
|
|
||||||
// Analyze each file
|
|
||||||
for (const file of files) {
|
|
||||||
const analysis = await this.analyzeFile(file);
|
|
||||||
analyses.set(file, analysis);
|
|
||||||
totalEstimatedTime += analysis.estimatedProcessingTime;
|
|
||||||
totalSize += file.size;
|
|
||||||
totalPages += analysis.estimatedPageCount || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate recommendations
|
|
||||||
const recommendations = {
|
|
||||||
totalEstimatedTime,
|
|
||||||
suggestedBatchSize: this.calculateBatchSize(files.length, totalSize),
|
|
||||||
shouldUseWebWorker: totalPages > 100 || totalSize > this.SIZE_THRESHOLDS.MEDIUM,
|
|
||||||
memoryWarning: totalSize > this.SIZE_THRESHOLDS.LARGE || totalPages > this.PAGE_THRESHOLDS.MASSIVE
|
|
||||||
};
|
|
||||||
|
|
||||||
return { analyses, recommendations };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate optimal batch size for processing multiple files
|
|
||||||
*/
|
|
||||||
private static calculateBatchSize(fileCount: number, totalSize: number): number {
|
|
||||||
// Process small batches for large total sizes
|
|
||||||
if (totalSize > this.SIZE_THRESHOLDS.LARGE) {
|
|
||||||
return Math.max(1, Math.floor(fileCount / 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalSize > this.SIZE_THRESHOLDS.MEDIUM) {
|
|
||||||
return Math.max(2, Math.floor(fileCount / 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process all at once for smaller total sizes
|
|
||||||
return fileCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file appears to be a valid PDF
|
|
||||||
*/
|
|
||||||
static async isValidPDF(file: File): Promise<boolean> {
|
|
||||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Read first few bytes to check PDF header
|
|
||||||
const header = file.slice(0, 8);
|
|
||||||
const headerBytes = new Uint8Array(await header.arrayBuffer());
|
|
||||||
const headerString = String.fromCharCode(...headerBytes);
|
|
||||||
|
|
||||||
return headerString.startsWith('%PDF-');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* Centralized file processing service
|
|
||||||
* Handles metadata discovery, page counting, and thumbnail generation
|
|
||||||
* Called when files are added to FileContext, before any view sees them
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
|
||||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
|
||||||
import { FileId } from '../types/file';
|
|
||||||
|
|
||||||
export interface ProcessedFileMetadata {
|
|
||||||
totalPages: number;
|
|
||||||
pages: Array<{
|
|
||||||
pageNumber: number;
|
|
||||||
thumbnail?: string;
|
|
||||||
rotation: number;
|
|
||||||
splitBefore: boolean;
|
|
||||||
}>;
|
|
||||||
thumbnailUrl?: string; // Page 1 thumbnail for FileEditor
|
|
||||||
lastProcessed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileProcessingResult {
|
|
||||||
success: boolean;
|
|
||||||
metadata?: ProcessedFileMetadata;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProcessingOperation {
|
|
||||||
promise: Promise<FileProcessingResult>;
|
|
||||||
abortController: AbortController;
|
|
||||||
}
|
|
||||||
|
|
||||||
class FileProcessingService {
|
|
||||||
private processingCache = new Map<string, ProcessingOperation>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a file to extract metadata, page count, and generate thumbnails
|
|
||||||
* This is the single source of truth for file processing
|
|
||||||
*/
|
|
||||||
async processFile(file: File, fileId: FileId): Promise<FileProcessingResult> {
|
|
||||||
// Check if we're already processing this file
|
|
||||||
const existingOperation = this.processingCache.get(fileId);
|
|
||||||
if (existingOperation) {
|
|
||||||
console.log(`📁 FileProcessingService: Using cached processing for ${file.name}`);
|
|
||||||
return existingOperation.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create abort controller for this operation
|
|
||||||
const abortController = new AbortController();
|
|
||||||
|
|
||||||
// Create processing promise
|
|
||||||
const processingPromise = this.performProcessing(file, fileId, abortController);
|
|
||||||
|
|
||||||
// Store operation with abort controller
|
|
||||||
const operation: ProcessingOperation = {
|
|
||||||
promise: processingPromise,
|
|
||||||
abortController
|
|
||||||
};
|
|
||||||
this.processingCache.set(fileId, operation);
|
|
||||||
|
|
||||||
// Clean up cache after completion
|
|
||||||
processingPromise.finally(() => {
|
|
||||||
this.processingCache.delete(fileId);
|
|
||||||
});
|
|
||||||
|
|
||||||
return processingPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async performProcessing(file: File, fileId: FileId, abortController: AbortController): Promise<FileProcessingResult> {
|
|
||||||
console.log(`📁 FileProcessingService: Starting processing for ${file.name} (${fileId})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check for cancellation at start
|
|
||||||
if (abortController.signal.aborted) {
|
|
||||||
throw new Error('Processing cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalPages = 1;
|
|
||||||
let thumbnailUrl: string | undefined;
|
|
||||||
|
|
||||||
// Handle PDF files
|
|
||||||
if (file.type === 'application/pdf') {
|
|
||||||
// Read arrayBuffer once and reuse for both PDF.js and fallback
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
|
|
||||||
// Check for cancellation after async operation
|
|
||||||
if (abortController.signal.aborted) {
|
|
||||||
throw new Error('Processing cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discover page count using PDF.js (most accurate)
|
|
||||||
try {
|
|
||||||
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
|
||||||
disableAutoFetch: true,
|
|
||||||
disableStream: true
|
|
||||||
});
|
|
||||||
|
|
||||||
totalPages = pdfDoc.numPages;
|
|
||||||
console.log(`📁 FileProcessingService: PDF.js discovered ${totalPages} pages for ${file.name}`);
|
|
||||||
|
|
||||||
// Clean up immediately
|
|
||||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
|
||||||
|
|
||||||
// Check for cancellation after PDF.js processing
|
|
||||||
if (abortController.signal.aborted) {
|
|
||||||
throw new Error('Processing cancelled');
|
|
||||||
}
|
|
||||||
} catch (pdfError) {
|
|
||||||
console.warn(`📁 FileProcessingService: PDF.js failed for ${file.name}, setting pages to 0:`, pdfError);
|
|
||||||
totalPages = 0; // Unknown page count - UI will hide page count display
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate page 1 thumbnail
|
|
||||||
try {
|
|
||||||
thumbnailUrl = await generateThumbnailForFile(file);
|
|
||||||
console.log(`📁 FileProcessingService: Generated thumbnail for ${file.name}`);
|
|
||||||
|
|
||||||
// Check for cancellation after thumbnail generation
|
|
||||||
if (abortController.signal.aborted) {
|
|
||||||
throw new Error('Processing cancelled');
|
|
||||||
}
|
|
||||||
} catch (thumbError) {
|
|
||||||
console.warn(`📁 FileProcessingService: Thumbnail generation failed for ${file.name}:`, thumbError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create page structure
|
|
||||||
const pages = Array.from({ length: totalPages }, (_, index) => ({
|
|
||||||
pageNumber: index + 1,
|
|
||||||
thumbnail: index === 0 ? thumbnailUrl : undefined, // Only page 1 gets thumbnail initially
|
|
||||||
rotation: 0,
|
|
||||||
splitBefore: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
const metadata: ProcessedFileMetadata = {
|
|
||||||
totalPages,
|
|
||||||
pages,
|
|
||||||
thumbnailUrl, // For FileEditor display
|
|
||||||
lastProcessed: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`📁 FileProcessingService: Processing complete for ${file.name} - ${totalPages} pages`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
metadata
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`📁 FileProcessingService: Processing failed for ${file.name}:`, error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown processing error'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all processing caches
|
|
||||||
*/
|
|
||||||
clearCache(): void {
|
|
||||||
this.processingCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file is currently being processed
|
|
||||||
*/
|
|
||||||
isProcessing(fileId: FileId): boolean {
|
|
||||||
return this.processingCache.has(fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel processing for a specific file
|
|
||||||
*/
|
|
||||||
cancelProcessing(fileId: FileId): boolean {
|
|
||||||
const operation = this.processingCache.get(fileId);
|
|
||||||
if (operation) {
|
|
||||||
operation.abortController.abort();
|
|
||||||
console.log(`📁 FileProcessingService: Cancelled processing for ${fileId}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel all ongoing processing operations
|
|
||||||
*/
|
|
||||||
cancelAllProcessing(): void {
|
|
||||||
this.processingCache.forEach((operation, fileId) => {
|
|
||||||
operation.abortController.abort();
|
|
||||||
console.log(`📁 FileProcessingService: Cancelled processing for ${fileId}`);
|
|
||||||
});
|
|
||||||
console.log(`📁 FileProcessingService: Cancelled ${this.processingCache.size} processing operations`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emergency cleanup - cancel all processing and destroy workers
|
|
||||||
*/
|
|
||||||
emergencyCleanup(): void {
|
|
||||||
this.cancelAllProcessing();
|
|
||||||
this.clearCache();
|
|
||||||
pdfWorkerManager.destroyAllDocuments();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const fileProcessingService = new FileProcessingService();
|
|
@ -1,187 +0,0 @@
|
|||||||
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
|
||||||
import { ProcessingCache } from './processingCache';
|
|
||||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
|
||||||
import { createQuickKey } from '../types/fileContext';
|
|
||||||
|
|
||||||
export class PDFProcessingService {
|
|
||||||
private static instance: PDFProcessingService;
|
|
||||||
private cache = new ProcessingCache();
|
|
||||||
private processing = new Map<string, ProcessingState>();
|
|
||||||
private processingListeners = new Set<(states: Map<string, ProcessingState>) => void>();
|
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
static getInstance(): PDFProcessingService {
|
|
||||||
if (!PDFProcessingService.instance) {
|
|
||||||
PDFProcessingService.instance = new PDFProcessingService();
|
|
||||||
}
|
|
||||||
return PDFProcessingService.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProcessedFile(file: File): Promise<ProcessedFile | null> {
|
|
||||||
const fileKey = this.generateFileKey(file);
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = this.cache.get(fileKey);
|
|
||||||
if (cached) {
|
|
||||||
console.log('Cache hit for:', file.name);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already processing
|
|
||||||
if (this.processing.has(fileKey)) {
|
|
||||||
console.log('Already processing:', file.name);
|
|
||||||
return null; // Will be available when processing completes
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start processing
|
|
||||||
this.startProcessing(file, fileKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async startProcessing(file: File, fileKey: string): Promise<void> {
|
|
||||||
// Set initial state
|
|
||||||
const state: ProcessingState = {
|
|
||||||
fileKey,
|
|
||||||
fileName: file.name,
|
|
||||||
status: 'processing',
|
|
||||||
progress: 0,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
strategy: 'immediate_full'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.processing.set(fileKey, state);
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Process the file with progress updates
|
|
||||||
const processedFile = await this.processFileWithProgress(file, (progress) => {
|
|
||||||
state.progress = progress;
|
|
||||||
this.notifyListeners();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
this.cache.set(fileKey, processedFile);
|
|
||||||
|
|
||||||
// Update state to completed
|
|
||||||
state.status = 'completed';
|
|
||||||
state.progress = 100;
|
|
||||||
state.completedAt = Date.now();
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
// Remove from processing map after brief delay
|
|
||||||
setTimeout(() => {
|
|
||||||
this.processing.delete(fileKey);
|
|
||||||
this.notifyListeners();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Processing failed for', file.name, ':', error);
|
|
||||||
state.status = 'error';
|
|
||||||
state.error = (error instanceof Error ? error.message : 'Unknown error') as any;
|
|
||||||
this.notifyListeners();
|
|
||||||
|
|
||||||
// Remove failed processing after delay
|
|
||||||
setTimeout(() => {
|
|
||||||
this.processing.delete(fileKey);
|
|
||||||
this.notifyListeners();
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processFileWithProgress(
|
|
||||||
file: File,
|
|
||||||
onProgress: (progress: number) => void
|
|
||||||
): Promise<ProcessedFile> {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
|
||||||
const totalPages = pdf.numPages;
|
|
||||||
|
|
||||||
onProgress(10); // PDF loaded
|
|
||||||
|
|
||||||
const pages: PDFPage[] = [];
|
|
||||||
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
|
||||||
const page = await pdf.getPage(i);
|
|
||||||
const viewport = page.getViewport({ scale: 0.5 });
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (context) {
|
|
||||||
await page.render({ canvasContext: context, viewport }).promise;
|
|
||||||
const thumbnail = canvas.toDataURL();
|
|
||||||
|
|
||||||
pages.push({
|
|
||||||
id: `${createQuickKey(file)}-page-${i}`,
|
|
||||||
pageNumber: i,
|
|
||||||
thumbnail,
|
|
||||||
rotation: 0,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update progress
|
|
||||||
const progress = 10 + (i / totalPages) * 85; // 10-95%
|
|
||||||
onProgress(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
|
||||||
onProgress(100);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
||||||
pages,
|
|
||||||
totalPages,
|
|
||||||
metadata: {
|
|
||||||
title: file.name,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
modifiedAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// State subscription for components
|
|
||||||
onProcessingChange(callback: (states: Map<string, ProcessingState>) => void): () => void {
|
|
||||||
this.processingListeners.add(callback);
|
|
||||||
return () => this.processingListeners.delete(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
getProcessingStates(): Map<string, ProcessingState> {
|
|
||||||
return new Map(this.processing);
|
|
||||||
}
|
|
||||||
|
|
||||||
private notifyListeners(): void {
|
|
||||||
this.processingListeners.forEach(callback => callback(this.processing));
|
|
||||||
}
|
|
||||||
|
|
||||||
generateFileKey(file: File): string {
|
|
||||||
return `${file.name}-${file.size}-${file.lastModified}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup method for activeFiles changes
|
|
||||||
cleanup(removedFiles: File[]): void {
|
|
||||||
removedFiles.forEach(file => {
|
|
||||||
const key = this.generateFileKey(file);
|
|
||||||
this.cache.delete(key);
|
|
||||||
this.processing.delete(key);
|
|
||||||
});
|
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get cache stats (for debugging)
|
|
||||||
getCacheStats() {
|
|
||||||
return this.cache.getStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all cache and processing
|
|
||||||
clearAll(): void {
|
|
||||||
this.cache.clear();
|
|
||||||
this.processing.clear();
|
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const pdfProcessingService = PDFProcessingService.getInstance();
|
|
@ -1,138 +0,0 @@
|
|||||||
import { ProcessedFile, CacheConfig, CacheEntry, CacheStats } from '../types/processing';
|
|
||||||
|
|
||||||
export class ProcessingCache {
|
|
||||||
private cache = new Map<string, CacheEntry>();
|
|
||||||
private totalSize = 0;
|
|
||||||
|
|
||||||
constructor(private config: CacheConfig = {
|
|
||||||
maxFiles: 20,
|
|
||||||
maxSizeBytes: 2 * 1024 * 1024 * 1024, // 2GB
|
|
||||||
ttlMs: 30 * 60 * 1000 // 30 minutes
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
set(key: string, data: ProcessedFile): void {
|
|
||||||
// Remove expired entries first
|
|
||||||
this.cleanup();
|
|
||||||
|
|
||||||
// Calculate entry size (rough estimate)
|
|
||||||
const size = this.calculateSize(data);
|
|
||||||
|
|
||||||
// Make room if needed
|
|
||||||
this.makeRoom(size);
|
|
||||||
|
|
||||||
this.cache.set(key, {
|
|
||||||
data,
|
|
||||||
size,
|
|
||||||
lastAccessed: Date.now(),
|
|
||||||
createdAt: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
this.totalSize += size;
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key: string): ProcessedFile | null {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (!entry) return null;
|
|
||||||
|
|
||||||
// Check TTL
|
|
||||||
if (Date.now() - entry.createdAt > this.config.ttlMs) {
|
|
||||||
this.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last accessed
|
|
||||||
entry.lastAccessed = Date.now();
|
|
||||||
return entry.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
has(key: string): boolean {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (!entry) return false;
|
|
||||||
|
|
||||||
// Check TTL
|
|
||||||
if (Date.now() - entry.createdAt > this.config.ttlMs) {
|
|
||||||
this.delete(key);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private makeRoom(neededSize: number): void {
|
|
||||||
// Remove oldest entries until we have space
|
|
||||||
while (
|
|
||||||
this.cache.size >= this.config.maxFiles ||
|
|
||||||
this.totalSize + neededSize > this.config.maxSizeBytes
|
|
||||||
) {
|
|
||||||
const oldestKey = this.findOldestEntry();
|
|
||||||
if (oldestKey) {
|
|
||||||
this.delete(oldestKey);
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private findOldestEntry(): string | null {
|
|
||||||
let oldest: { key: string; lastAccessed: number } | null = null;
|
|
||||||
|
|
||||||
for (const [key, entry] of this.cache) {
|
|
||||||
if (!oldest || entry.lastAccessed < oldest.lastAccessed) {
|
|
||||||
oldest = { key, lastAccessed: entry.lastAccessed };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return oldest?.key || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanup(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, entry] of this.cache) {
|
|
||||||
if (now - entry.createdAt > this.config.ttlMs) {
|
|
||||||
this.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateSize(data: ProcessedFile): number {
|
|
||||||
// Rough size estimation
|
|
||||||
let size = 0;
|
|
||||||
|
|
||||||
// Estimate size of thumbnails (main memory consumer)
|
|
||||||
data.pages.forEach(page => {
|
|
||||||
if (page.thumbnail) {
|
|
||||||
// Base64 thumbnail is roughly 50KB each
|
|
||||||
size += 50 * 1024;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add some overhead for other data
|
|
||||||
size += 10 * 1024; // 10KB overhead
|
|
||||||
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(key: string): void {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (entry) {
|
|
||||||
this.totalSize -= entry.size;
|
|
||||||
this.cache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.cache.clear();
|
|
||||||
this.totalSize = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getStats(): CacheStats {
|
|
||||||
return {
|
|
||||||
entries: this.cache.size,
|
|
||||||
totalSizeBytes: this.totalSize,
|
|
||||||
maxSizeBytes: this.config.maxSizeBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all cached keys (for debugging and cleanup)
|
|
||||||
getKeys(): string[] {
|
|
||||||
return Array.from(this.cache.keys());
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,282 +0,0 @@
|
|||||||
import { ProcessingError } from '../types/processing';
|
|
||||||
|
|
||||||
export class ProcessingErrorHandler {
|
|
||||||
private static readonly DEFAULT_MAX_RETRIES = 3;
|
|
||||||
private static readonly RETRY_DELAYS = [1000, 2000, 4000]; // Progressive backoff in ms
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a ProcessingError from an unknown error
|
|
||||||
*/
|
|
||||||
static createProcessingError(
|
|
||||||
error: unknown,
|
|
||||||
retryCount: number = 0,
|
|
||||||
maxRetries: number = this.DEFAULT_MAX_RETRIES
|
|
||||||
): ProcessingError {
|
|
||||||
const originalError = error instanceof Error ? error : new Error(String(error));
|
|
||||||
const message = originalError.message;
|
|
||||||
|
|
||||||
// Determine error type based on error message and properties
|
|
||||||
const errorType = this.determineErrorType(originalError, message);
|
|
||||||
|
|
||||||
// Determine if error is recoverable
|
|
||||||
const recoverable = this.isRecoverable(errorType, retryCount, maxRetries);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: errorType,
|
|
||||||
message: this.formatErrorMessage(errorType, message),
|
|
||||||
recoverable,
|
|
||||||
retryCount,
|
|
||||||
maxRetries,
|
|
||||||
originalError
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the type of error based on error characteristics
|
|
||||||
*/
|
|
||||||
private static determineErrorType(error: Error, message: string): ProcessingError['type'] {
|
|
||||||
const lowerMessage = message.toLowerCase();
|
|
||||||
|
|
||||||
// Network-related errors
|
|
||||||
if (lowerMessage.includes('network') ||
|
|
||||||
lowerMessage.includes('fetch') ||
|
|
||||||
lowerMessage.includes('connection')) {
|
|
||||||
return 'network';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory-related errors
|
|
||||||
if (lowerMessage.includes('memory') ||
|
|
||||||
lowerMessage.includes('quota') ||
|
|
||||||
lowerMessage.includes('allocation') ||
|
|
||||||
error.name === 'QuotaExceededError') {
|
|
||||||
return 'memory';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout errors
|
|
||||||
if (lowerMessage.includes('timeout') ||
|
|
||||||
lowerMessage.includes('aborted') ||
|
|
||||||
error.name === 'AbortError') {
|
|
||||||
return 'timeout';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancellation
|
|
||||||
if (lowerMessage.includes('cancel') ||
|
|
||||||
lowerMessage.includes('abort') ||
|
|
||||||
error.name === 'AbortError') {
|
|
||||||
return 'cancelled';
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF corruption/parsing errors
|
|
||||||
if (lowerMessage.includes('pdf') ||
|
|
||||||
lowerMessage.includes('parse') ||
|
|
||||||
lowerMessage.includes('invalid') ||
|
|
||||||
lowerMessage.includes('corrupt') ||
|
|
||||||
lowerMessage.includes('malformed')) {
|
|
||||||
return 'corruption';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to parsing error
|
|
||||||
return 'parsing';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if an error is recoverable based on type and retry count
|
|
||||||
*/
|
|
||||||
private static isRecoverable(
|
|
||||||
errorType: ProcessingError['type'],
|
|
||||||
retryCount: number,
|
|
||||||
maxRetries: number
|
|
||||||
): boolean {
|
|
||||||
// Never recoverable
|
|
||||||
if (errorType === 'cancelled' || errorType === 'corruption') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recoverable if we haven't exceeded retry count
|
|
||||||
if (retryCount >= maxRetries) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory errors are usually not recoverable
|
|
||||||
if (errorType === 'memory') {
|
|
||||||
return retryCount < 1; // Only one retry for memory errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network and timeout errors are usually recoverable
|
|
||||||
return errorType === 'network' || errorType === 'timeout' || errorType === 'parsing';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format error message for user display
|
|
||||||
*/
|
|
||||||
private static formatErrorMessage(errorType: ProcessingError['type'], originalMessage: string): string {
|
|
||||||
switch (errorType) {
|
|
||||||
case 'network':
|
|
||||||
return 'Network connection failed. Please check your internet connection and try again.';
|
|
||||||
|
|
||||||
case 'memory':
|
|
||||||
return 'Insufficient memory to process this file. Try closing other applications or processing a smaller file.';
|
|
||||||
|
|
||||||
case 'timeout':
|
|
||||||
return 'Processing timed out. This file may be too large or complex to process.';
|
|
||||||
|
|
||||||
case 'cancelled':
|
|
||||||
return 'Processing was cancelled by user.';
|
|
||||||
|
|
||||||
case 'corruption':
|
|
||||||
return 'This PDF file appears to be corrupted or encrypted. Please try a different file.';
|
|
||||||
|
|
||||||
case 'parsing':
|
|
||||||
return `Failed to process PDF: ${originalMessage}`;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return `Processing failed: ${originalMessage}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute an operation with automatic retry logic
|
|
||||||
*/
|
|
||||||
static async executeWithRetry<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
onError?: (error: ProcessingError) => void,
|
|
||||||
maxRetries: number = this.DEFAULT_MAX_RETRIES
|
|
||||||
): Promise<T> {
|
|
||||||
let lastError: ProcessingError | null = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
lastError = this.createProcessingError(error, attempt, maxRetries);
|
|
||||||
|
|
||||||
// Notify error handler
|
|
||||||
if (onError) {
|
|
||||||
onError(lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't retry if not recoverable
|
|
||||||
if (!lastError.recoverable) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't retry on last attempt
|
|
||||||
if (attempt === maxRetries) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before retry with progressive backoff
|
|
||||||
const delay = this.RETRY_DELAYS[Math.min(attempt, this.RETRY_DELAYS.length - 1)];
|
|
||||||
await this.delay(delay);
|
|
||||||
|
|
||||||
console.log(`Retrying operation (attempt ${attempt + 2}/${maxRetries + 1}) after ${delay}ms delay`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All retries exhausted
|
|
||||||
throw lastError || new Error('Operation failed after all retries');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a timeout wrapper for operations
|
|
||||||
*/
|
|
||||||
static withTimeout<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
timeoutMs: number,
|
|
||||||
timeoutMessage: string = 'Operation timed out'
|
|
||||||
): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
reject(new Error(timeoutMessage));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
operation()
|
|
||||||
.then(result => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
resolve(result);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an AbortController that times out after specified duration
|
|
||||||
*/
|
|
||||||
static createTimeoutController(timeoutMs: number): AbortController {
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
controller.abort();
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
return controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error indicates the operation should be retried
|
|
||||||
*/
|
|
||||||
static shouldRetry(error: ProcessingError): boolean {
|
|
||||||
return error.recoverable && error.retryCount < error.maxRetries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user-friendly suggestions based on error type
|
|
||||||
*/
|
|
||||||
static getErrorSuggestions(error: ProcessingError): string[] {
|
|
||||||
switch (error.type) {
|
|
||||||
case 'network':
|
|
||||||
return [
|
|
||||||
'Check your internet connection',
|
|
||||||
'Try refreshing the page',
|
|
||||||
'Try again in a few moments'
|
|
||||||
];
|
|
||||||
|
|
||||||
case 'memory':
|
|
||||||
return [
|
|
||||||
'Close other browser tabs or applications',
|
|
||||||
'Try processing a smaller file',
|
|
||||||
'Restart your browser',
|
|
||||||
'Use a device with more memory'
|
|
||||||
];
|
|
||||||
|
|
||||||
case 'timeout':
|
|
||||||
return [
|
|
||||||
'Try processing a smaller file',
|
|
||||||
'Break large files into smaller sections',
|
|
||||||
'Check your internet connection speed'
|
|
||||||
];
|
|
||||||
|
|
||||||
case 'corruption':
|
|
||||||
return [
|
|
||||||
'Verify the PDF file opens in other applications',
|
|
||||||
'Try re-downloading the file',
|
|
||||||
'Try a different PDF file',
|
|
||||||
'Contact the file creator if it appears corrupted'
|
|
||||||
];
|
|
||||||
|
|
||||||
case 'parsing':
|
|
||||||
return [
|
|
||||||
'Verify this is a valid PDF file',
|
|
||||||
'Try a different PDF file',
|
|
||||||
'Contact support if the problem persists'
|
|
||||||
];
|
|
||||||
|
|
||||||
default:
|
|
||||||
return [
|
|
||||||
'Try refreshing the page',
|
|
||||||
'Try again in a few moments',
|
|
||||||
'Contact support if the problem persists'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function for delays
|
|
||||||
*/
|
|
||||||
private static delay(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
|
||||||
// allows you to do things like:
|
|
||||||
// expect(element).toHaveTextContent(/react/i)
|
|
||||||
// learn more: https://github.com/testing-library/jest-dom
|
|
||||||
import '@testing-library/jest-dom';
|
|
@ -1,23 +0,0 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { BaseToolProps } from "../types/tool";
|
|
||||||
|
|
||||||
const SwaggerUI: React.FC<BaseToolProps> = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
// Redirect to Swagger UI
|
|
||||||
window.open("/swagger-ui/5.21.0/index.html", "_blank");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: "center", padding: "2rem" }}>
|
|
||||||
<p>Opening Swagger UI in a new tab...</p>
|
|
||||||
<p>
|
|
||||||
If it didn't open automatically,{" "}
|
|
||||||
<a href="/swagger-ui/5.21.0/index.html" target="_blank" rel="noopener noreferrer">
|
|
||||||
click here
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SwaggerUI;
|
|
@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Navigation action interfaces to break circular dependencies
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { WorkbenchType } from './workbench';
|
|
||||||
import { ToolId } from './toolId';
|
|
||||||
|
|
||||||
export interface NavigationActions {
|
|
||||||
setWorkbench: (workbench: WorkbenchType) => void;
|
|
||||||
setSelectedTool: (toolId: ToolId | null) => void;
|
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
|
||||||
showNavigationWarning: (show: boolean) => void;
|
|
||||||
requestNavigation: (navigationFn: () => void) => void;
|
|
||||||
confirmNavigation: () => void;
|
|
||||||
cancelNavigation: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavigationState {
|
|
||||||
workbench: WorkbenchType;
|
|
||||||
selectedTool: ToolId | null;
|
|
||||||
hasUnsavedChanges: boolean;
|
|
||||||
pendingNavigation: (() => void) | null;
|
|
||||||
showNavigationWarning: boolean;
|
|
||||||
}
|
|
@ -1,90 +0,0 @@
|
|||||||
export interface ProcessingError {
|
|
||||||
type: 'network' | 'parsing' | 'memory' | 'corruption' | 'timeout' | 'cancelled';
|
|
||||||
message: string;
|
|
||||||
recoverable: boolean;
|
|
||||||
retryCount: number;
|
|
||||||
maxRetries: number;
|
|
||||||
originalError?: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessingState {
|
|
||||||
fileKey: string;
|
|
||||||
fileName: string;
|
|
||||||
status: 'pending' | 'processing' | 'completed' | 'error' | 'cancelled';
|
|
||||||
progress: number; // 0-100
|
|
||||||
strategy: ProcessingStrategy;
|
|
||||||
error?: ProcessingError;
|
|
||||||
startedAt: number;
|
|
||||||
completedAt?: number;
|
|
||||||
estimatedTimeRemaining?: number;
|
|
||||||
currentPage?: number;
|
|
||||||
cancellationToken?: AbortController;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessedFile {
|
|
||||||
id: string;
|
|
||||||
pages: PDFPage[];
|
|
||||||
totalPages: number;
|
|
||||||
metadata: {
|
|
||||||
title: string;
|
|
||||||
createdAt: string;
|
|
||||||
modifiedAt: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PDFPage {
|
|
||||||
id: string;
|
|
||||||
pageNumber: number;
|
|
||||||
thumbnail: string | null;
|
|
||||||
rotation: number;
|
|
||||||
selected: boolean;
|
|
||||||
splitBefore?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CacheConfig {
|
|
||||||
maxFiles: number;
|
|
||||||
maxSizeBytes: number;
|
|
||||||
ttlMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CacheEntry {
|
|
||||||
data: ProcessedFile;
|
|
||||||
size: number;
|
|
||||||
lastAccessed: number;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CacheStats {
|
|
||||||
entries: number;
|
|
||||||
totalSizeBytes: number;
|
|
||||||
maxSizeBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProcessingStrategy = 'immediate_full' | 'progressive_chunked' | 'metadata_only' | 'priority_pages';
|
|
||||||
|
|
||||||
export interface ProcessingConfig {
|
|
||||||
strategy: ProcessingStrategy;
|
|
||||||
chunkSize: number; // Pages per chunk
|
|
||||||
thumbnailQuality: 'low' | 'medium' | 'high';
|
|
||||||
priorityPageCount: number; // Number of priority pages to process first
|
|
||||||
useWebWorker: boolean;
|
|
||||||
maxRetries: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileAnalysis {
|
|
||||||
fileSize: number;
|
|
||||||
estimatedPageCount?: number;
|
|
||||||
isEncrypted: boolean;
|
|
||||||
isCorrupted: boolean;
|
|
||||||
recommendedStrategy: ProcessingStrategy;
|
|
||||||
estimatedProcessingTime: number; // milliseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessingMetrics {
|
|
||||||
totalFiles: number;
|
|
||||||
completedFiles: number;
|
|
||||||
failedFiles: number;
|
|
||||||
averageProcessingTime: number;
|
|
||||||
cacheHitRate: number;
|
|
||||||
memoryUsage: number;
|
|
||||||
}
|
|
@ -1,127 +0,0 @@
|
|||||||
/**
|
|
||||||
* File hashing utilities for cache key generation
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class FileHasher {
|
|
||||||
private static readonly CHUNK_SIZE = 64 * 1024; // 64KB chunks for hashing
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a content-based hash for a file
|
|
||||||
* Uses first + last + middle chunks to create a reasonably unique hash
|
|
||||||
* without reading the entire file (which would be expensive for large files)
|
|
||||||
*/
|
|
||||||
static async generateContentHash(file: File): Promise<string> {
|
|
||||||
const chunks = await this.getFileChunks(file);
|
|
||||||
const combined = await this.combineChunks(chunks);
|
|
||||||
return await this.hashArrayBuffer(combined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a fast hash based on file metadata
|
|
||||||
* Faster but less collision-resistant than content hash
|
|
||||||
*/
|
|
||||||
static generateMetadataHash(file: File): string {
|
|
||||||
const data = `${file.name}-${file.size}-${file.lastModified}-${file.type}`;
|
|
||||||
return this.simpleHash(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a hybrid hash that balances speed and uniqueness
|
|
||||||
* Uses metadata + small content sample
|
|
||||||
*/
|
|
||||||
static async generateHybridHash(file: File): Promise<string> {
|
|
||||||
const metadataHash = this.generateMetadataHash(file);
|
|
||||||
|
|
||||||
// For small files, use full content hash
|
|
||||||
if (file.size <= 1024 * 1024) { // 1MB
|
|
||||||
const contentHash = await this.generateContentHash(file);
|
|
||||||
return `${metadataHash}-${contentHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For large files, use first chunk only
|
|
||||||
const firstChunk = file.slice(0, this.CHUNK_SIZE);
|
|
||||||
const firstChunkBuffer = await firstChunk.arrayBuffer();
|
|
||||||
const firstChunkHash = await this.hashArrayBuffer(firstChunkBuffer);
|
|
||||||
|
|
||||||
return `${metadataHash}-${firstChunkHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async getFileChunks(file: File): Promise<ArrayBuffer[]> {
|
|
||||||
const chunks: ArrayBuffer[] = [];
|
|
||||||
|
|
||||||
// First chunk
|
|
||||||
if (file.size > 0) {
|
|
||||||
const firstChunk = file.slice(0, Math.min(this.CHUNK_SIZE, file.size));
|
|
||||||
chunks.push(await firstChunk.arrayBuffer());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Middle chunk (if file is large enough)
|
|
||||||
if (file.size > this.CHUNK_SIZE * 2) {
|
|
||||||
const middleStart = Math.floor(file.size / 2) - Math.floor(this.CHUNK_SIZE / 2);
|
|
||||||
const middleEnd = middleStart + this.CHUNK_SIZE;
|
|
||||||
const middleChunk = file.slice(middleStart, middleEnd);
|
|
||||||
chunks.push(await middleChunk.arrayBuffer());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last chunk (if file is large enough and different from first)
|
|
||||||
if (file.size > this.CHUNK_SIZE) {
|
|
||||||
const lastStart = Math.max(file.size - this.CHUNK_SIZE, this.CHUNK_SIZE);
|
|
||||||
const lastChunk = file.slice(lastStart);
|
|
||||||
chunks.push(await lastChunk.arrayBuffer());
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async combineChunks(chunks: ArrayBuffer[]): Promise<ArrayBuffer> {
|
|
||||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
||||||
const combined = new Uint8Array(totalLength);
|
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
combined.set(new Uint8Array(chunk), offset);
|
|
||||||
offset += chunk.byteLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
return combined.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async hashArrayBuffer(buffer: ArrayBuffer): Promise<string> {
|
|
||||||
// Use Web Crypto API for proper hashing
|
|
||||||
if (crypto.subtle) {
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
||||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for environments without crypto.subtle
|
|
||||||
return this.simpleHash(Array.from(new Uint8Array(buffer)).join(''));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static simpleHash(str: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
if (str.length === 0) return hash.toString();
|
|
||||||
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
const char = str.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash) + char;
|
|
||||||
hash = hash & hash; // Convert to 32-bit integer
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.abs(hash).toString(16);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a file matches its expected hash
|
|
||||||
* Useful for detecting file corruption or changes
|
|
||||||
*/
|
|
||||||
static async validateFileHash(file: File, expectedHash: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const actualHash = await this.generateHybridHash(file);
|
|
||||||
return actualHash === expectedHash;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Hash validation failed:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,70 +0,0 @@
|
|||||||
import { StorageStats } from "../services/fileStorage";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage operation types for incremental updates
|
|
||||||
*/
|
|
||||||
export type StorageOperation = 'add' | 'remove' | 'clear';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update storage stats incrementally based on operation
|
|
||||||
*/
|
|
||||||
export function updateStorageStatsIncremental(
|
|
||||||
currentStats: StorageStats,
|
|
||||||
operation: StorageOperation,
|
|
||||||
files: File[] = []
|
|
||||||
): StorageStats {
|
|
||||||
const filesSizeTotal = files.reduce((total, file) => total + file.size, 0);
|
|
||||||
|
|
||||||
switch (operation) {
|
|
||||||
case 'add':
|
|
||||||
return {
|
|
||||||
...currentStats,
|
|
||||||
used: currentStats.used + filesSizeTotal,
|
|
||||||
available: currentStats.available - filesSizeTotal,
|
|
||||||
fileCount: currentStats.fileCount + files.length
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'remove':
|
|
||||||
return {
|
|
||||||
...currentStats,
|
|
||||||
used: Math.max(0, currentStats.used - filesSizeTotal),
|
|
||||||
available: currentStats.available + filesSizeTotal,
|
|
||||||
fileCount: Math.max(0, currentStats.fileCount - files.length)
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'clear':
|
|
||||||
return {
|
|
||||||
...currentStats,
|
|
||||||
used: 0,
|
|
||||||
available: currentStats.quota || currentStats.available,
|
|
||||||
fileCount: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return currentStats;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check storage usage and return warning message if needed
|
|
||||||
*/
|
|
||||||
export function checkStorageWarnings(stats: StorageStats): string | null {
|
|
||||||
if (!stats.quota || stats.used === 0) return null;
|
|
||||||
|
|
||||||
const usagePercent = (stats.used / stats.quota) * 100;
|
|
||||||
|
|
||||||
if (usagePercent > 90) {
|
|
||||||
return 'Warning: Storage is nearly full (>90%). Browser may start clearing data.';
|
|
||||||
} else if (usagePercent > 80) {
|
|
||||||
return 'Storage is getting full (>80%). Consider removing old files.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate storage usage percentage
|
|
||||||
*/
|
|
||||||
export function getStorageUsagePercent(stats: StorageStats): number {
|
|
||||||
return stats.quota ? (stats.used / stats.quota) * 100 : 0;
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
import { FileId } from '../types/file';
|
|
||||||
import { FileOperation } from '../types/fileContext';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates operation tracking data for FileContext integration
|
|
||||||
*/
|
|
||||||
export const createOperation = <TParams = void>(
|
|
||||||
operationType: string,
|
|
||||||
params: TParams,
|
|
||||||
selectedFiles: File[]
|
|
||||||
): { operation: FileOperation; operationId: string; fileId: FileId } => {
|
|
||||||
const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const fileId = selectedFiles.map(f => f.name).join(',') as FileId;
|
|
||||||
|
|
||||||
const operation: FileOperation = {
|
|
||||||
id: operationId,
|
|
||||||
type: operationType,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
|
||||||
status: 'pending',
|
|
||||||
metadata: {
|
|
||||||
originalFileName: selectedFiles[0]?.name,
|
|
||||||
parameters: params,
|
|
||||||
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
|
||||||
}
|
|
||||||
} as any /* FIX ME*/;
|
|
||||||
|
|
||||||
return { operation, operationId, fileId };
|
|
||||||
};
|
|
Loading…
x
Reference in New Issue
Block a user