mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 01:19:24 +00:00
Merge
This commit is contained in:
parent
dad9f20879
commit
564b14e3e2
@ -13,9 +13,11 @@
|
|||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(node:*)",
|
"Bash(node:*)",
|
||||||
"Bash(npm run dev:*)",
|
"Bash(npm run dev:*)",
|
||||||
"Bash(sed:*)"
|
"Bash(sed:*)",
|
||||||
|
"Bash(npm run typecheck:*)",
|
||||||
|
"Bash(git checkout:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"defaultMode": "acceptEdits"
|
"defaultMode": "acceptEdits"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -41,6 +41,7 @@
|
|||||||
"prebuild": "npm run generate-icons",
|
"prebuild": "npm run generate-icons",
|
||||||
"build": "npx tsc --noEmit && vite build",
|
"build": "npx tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"generate-licenses": "node scripts/generate-licenses.js",
|
"generate-licenses": "node scripts/generate-licenses.js",
|
||||||
"generate-icons": "node scripts/generate-icons.js",
|
"generate-icons": "node scripts/generate-icons.js",
|
||||||
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
||||||
|
@ -41,6 +41,9 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saveToBrowser": "Save to Browser",
|
"saveToBrowser": "Save to Browser",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||||
|
"undo": "Undo",
|
||||||
|
"moreOptions": "More Options",
|
||||||
"editYourNewFiles": "Edit your new file(s)",
|
"editYourNewFiles": "Edit your new file(s)",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"fileSelected": "Selected: {{filename}}",
|
"fileSelected": "Selected: {{filename}}",
|
||||||
|
@ -350,9 +350,9 @@ const FileEditor = ({
|
|||||||
if (record) {
|
if (record) {
|
||||||
// Set the file as selected in context and switch to viewer for preview
|
// Set the file as selected in context and switch to viewer for preview
|
||||||
setSelectedFiles([fileId as FileId]);
|
setSelectedFiles([fileId as FileId]);
|
||||||
navActions.setMode('viewer');
|
navActions.setWorkbench('viewer');
|
||||||
}
|
}
|
||||||
}, [activeFileRecords, setSelectedFiles, navActions.setMode]);
|
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
|
||||||
|
|
||||||
const handleMergeFromHere = useCallback((fileId: string) => {
|
const handleMergeFromHere = useCallback((fileId: string) => {
|
||||||
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
|
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
|
||||||
|
@ -1,27 +1,48 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Button, Stack, Text } from '@mantine/core';
|
import { Button, Group, Stack } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
import ErrorNotification from './ErrorNotification';
|
import UndoIcon from "@mui/icons-material/Undo";
|
||||||
import ResultsPreview from './ResultsPreview';
|
import ErrorNotification from "./ErrorNotification";
|
||||||
import { SuggestedToolsSection } from './SuggestedToolsSection';
|
import ResultsPreview from "./ResultsPreview";
|
||||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
import { SuggestedToolsSection } from "./SuggestedToolsSection";
|
||||||
|
import { ToolOperationHook } from "../../../hooks/tools/shared/useToolOperation";
|
||||||
|
import { Tooltip } from "../../shared/Tooltip";
|
||||||
|
|
||||||
export interface ReviewToolStepProps<TParams = unknown> {
|
export interface ReviewToolStepProps<TParams = unknown> {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
operation: ToolOperationHook<TParams>;
|
operation: ToolOperationHook<TParams>;
|
||||||
title?: string;
|
title?: string;
|
||||||
onFileClick?: (file: File) => void;
|
onFileClick?: (file: File) => void;
|
||||||
|
onUndo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { operation: ToolOperationHook<TParams>; onFileClick?: (file: File) => void }) {
|
function ReviewStepContent<TParams = unknown>({
|
||||||
|
operation,
|
||||||
|
onFileClick,
|
||||||
|
onUndo,
|
||||||
|
}: {
|
||||||
|
operation: ToolOperationHook<TParams>;
|
||||||
|
onFileClick?: (file: File) => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const stepRef = useRef<HTMLDivElement>(null);
|
const stepRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const previewFiles = operation.files?.map((file, index) => ({
|
const handleUndo = async () => {
|
||||||
file,
|
try {
|
||||||
thumbnail: operation.thumbnails[index]
|
onUndo();
|
||||||
})) || [];
|
} catch (error) {
|
||||||
|
// Error is already handled by useToolOperation, just reset loading state
|
||||||
|
console.error("Undo operation failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewFiles =
|
||||||
|
operation.files?.map((file, index) => ({
|
||||||
|
file,
|
||||||
|
thumbnail: operation.thumbnails[index],
|
||||||
|
})) || [];
|
||||||
|
|
||||||
// Auto-scroll to bottom when content appears
|
// Auto-scroll to bottom when content appears
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -31,7 +52,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollableContainer.scrollTo({
|
scrollableContainer.scrollTo({
|
||||||
top: scrollableContainer.scrollHeight,
|
top: scrollableContainer.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, 100); // Small delay to ensure content is rendered
|
}, 100); // Small delay to ensure content is rendered
|
||||||
}
|
}
|
||||||
@ -40,10 +61,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm" ref={stepRef}>
|
<Stack gap="sm" ref={stepRef}>
|
||||||
<ErrorNotification
|
<ErrorNotification error={operation.errorMessage} onClose={operation.clearError} />
|
||||||
error={operation.errorMessage}
|
|
||||||
onClose={operation.clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{previewFiles.length > 0 && (
|
{previewFiles.length > 0 && (
|
||||||
<ResultsPreview
|
<ResultsPreview
|
||||||
@ -53,7 +71,18 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{operation.downloadUrl && (
|
<Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
|
||||||
|
<Button
|
||||||
|
leftSection={<UndoIcon />}
|
||||||
|
variant="outline"
|
||||||
|
color="var(--mantine-color-gray-6)"
|
||||||
|
onClick={handleUndo}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t("undo", "Undo")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
{operation.downloadUrl && (
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
component="a"
|
||||||
href={operation.downloadUrl}
|
href={operation.downloadUrl}
|
||||||
@ -78,14 +107,13 @@ export function createReviewToolStep<TParams = unknown>(
|
|||||||
): React.ReactElement {
|
): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return createStep(t("review", "Review"), {
|
return createStep(
|
||||||
isVisible: props.isVisible,
|
t("review", "Review"),
|
||||||
_excludeFromCount: true,
|
{
|
||||||
_noPadding: true
|
isVisible: props.isVisible,
|
||||||
}, (
|
_excludeFromCount: true,
|
||||||
<ReviewStepContent
|
_noPadding: true,
|
||||||
operation={props.operation}
|
},
|
||||||
onFileClick={props.onFileClick}
|
<ReviewStepContent operation={props.operation} onFileClick={props.onFileClick} onUndo={props.onUndo} />
|
||||||
/>
|
);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
@ -44,6 +44,7 @@ export interface ReviewStepConfig {
|
|||||||
operation: ToolOperationHook<any>;
|
operation: ToolOperationHook<any>;
|
||||||
title: string;
|
title: string;
|
||||||
onFileClick?: (file: File) => void;
|
onFileClick?: (file: File) => void;
|
||||||
|
onUndo: () => void;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +107,8 @@ export function createToolFlow(config: ToolFlowConfig) {
|
|||||||
isVisible: config.review.isVisible,
|
isVisible: config.review.isVisible,
|
||||||
operation: config.review.operation,
|
operation: config.review.operation,
|
||||||
title: config.review.title,
|
title: config.review.title,
|
||||||
onFileClick: config.review.onFileClick
|
onFileClick: config.review.onFileClick,
|
||||||
|
onUndo: config.review.onUndo
|
||||||
})}
|
})}
|
||||||
</ToolStepProvider>
|
</ToolStepProvider>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* FileContext - Manages PDF files for Stirling PDF multi-tool workflow
|
* FileContext - Manages PDF files for Stirling PDF multi-tool workflow
|
||||||
*
|
*
|
||||||
* Handles file state, memory management, and resource cleanup for large PDFs (up to 100GB+).
|
* Handles file state, memory management, and resource cleanup for large PDFs (up to 100GB+).
|
||||||
* Users upload PDFs once and chain tools (split → merge → compress → view) without reloading.
|
* Users upload PDFs once and chain tools (split → merge → compress → view) without reloading.
|
||||||
*
|
*
|
||||||
* Key hooks:
|
* Key hooks:
|
||||||
* - useFileState() - access file state and UI state
|
* - useFileState() - access file state and UI state
|
||||||
* - useFileActions() - file operations (add/remove/update)
|
* - useFileActions() - file operations (add/remove/update)
|
||||||
* - useFileSelection() - for file selection state and actions
|
* - useFileSelection() - for file selection state and actions
|
||||||
*
|
*
|
||||||
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
|
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ import {
|
|||||||
// Import modular components
|
// Import modular components
|
||||||
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
||||||
import { createFileSelectors } from './file/fileSelectors';
|
import { createFileSelectors } from './file/fileSelectors';
|
||||||
import { addFiles, consumeFiles, createFileActions } from './file/fileActions';
|
import { addFiles, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
|
||||||
import { FileLifecycleManager } from './file/lifecycle';
|
import { FileLifecycleManager } from './file/lifecycle';
|
||||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||||
@ -40,16 +40,16 @@ const DEBUG = process.env.NODE_ENV === 'development';
|
|||||||
function FileContextInner({
|
function FileContextInner({
|
||||||
children,
|
children,
|
||||||
enableUrlSync = true,
|
enableUrlSync = true,
|
||||||
enablePersistence = true
|
enablePersistence = true
|
||||||
}: FileContextProviderProps) {
|
}: FileContextProviderProps) {
|
||||||
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
||||||
|
|
||||||
// IndexedDB context for persistence
|
// IndexedDB context for persistence
|
||||||
const indexedDB = enablePersistence ? useIndexedDB() : null;
|
const indexedDB = enablePersistence ? useIndexedDB() : null;
|
||||||
|
|
||||||
// File ref map - stores File objects outside React state
|
// File ref map - stores File objects outside React state
|
||||||
const filesRef = useRef<Map<FileId, File>>(new Map());
|
const filesRef = useRef<Map<FileId, File>>(new Map());
|
||||||
|
|
||||||
// Stable state reference for selectors
|
// Stable state reference for selectors
|
||||||
const stateRef = useRef(state);
|
const stateRef = useRef(state);
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
@ -62,8 +62,8 @@ function FileContextInner({
|
|||||||
const lifecycleManager = lifecycleManagerRef.current;
|
const lifecycleManager = lifecycleManagerRef.current;
|
||||||
|
|
||||||
// Create stable selectors (memoized once to avoid re-renders)
|
// Create stable selectors (memoized once to avoid re-renders)
|
||||||
const selectors = useMemo<FileContextSelectors>(() =>
|
const selectors = useMemo<FileContextSelectors>(() =>
|
||||||
createFileSelectors(stateRef, filesRef),
|
createFileSelectors(stateRef, filesRef),
|
||||||
[] // Empty deps - selectors are stable
|
[] // Empty deps - selectors are stable
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -74,10 +74,21 @@ function FileContextInner({
|
|||||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const selectFiles = useCallback((addedFilesWithIds: Array<{ file: File; id: FileId; thumbnail?: string }>) => {
|
||||||
|
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||||
|
const newFileIds = addedFilesWithIds.map(({ id }) => id);
|
||||||
|
dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds: [...currentSelection, ...newFileIds] } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// File operations using unified addFiles helper with persistence
|
// File operations using unified addFiles helper with persistence
|
||||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise<FileWithId[]> => {
|
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<FileWithId[]> => {
|
||||||
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||||
|
|
||||||
|
// Auto-select the newly added files if requested
|
||||||
|
if (options?.selectFiles && addedFilesWithIds.length > 0) {
|
||||||
|
selectFiles(addedFilesWithIds);
|
||||||
|
}
|
||||||
|
|
||||||
// Persist to IndexedDB if enabled
|
// Persist to IndexedDB if enabled
|
||||||
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
|
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
|
||||||
await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => {
|
await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => {
|
||||||
@ -88,7 +99,7 @@ function FileContextInner({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return addedFilesWithIds.map(({ file, id }) => createFileWithId(file, id));
|
return addedFilesWithIds.map(({ file, id }) => createFileWithId(file, id));
|
||||||
}, [indexedDB, enablePersistence]);
|
}, [indexedDB, enablePersistence]);
|
||||||
|
|
||||||
@ -97,8 +108,14 @@ function FileContextInner({
|
|||||||
return result.map(({ file, id }) => createFileWithId(file, id));
|
return result.map(({ file, id }) => createFileWithId(file, id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise<FileWithId[]> => {
|
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<FileWithId[]> => {
|
||||||
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||||
|
|
||||||
|
// Auto-select the newly added files if requested
|
||||||
|
if (options?.selectFiles && result.length > 0) {
|
||||||
|
selectFiles(result);
|
||||||
|
}
|
||||||
|
|
||||||
return result.map(({ file, id }) => createFileWithId(file, id));
|
return result.map(({ file, id }) => createFileWithId(file, id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -106,10 +123,13 @@ function FileContextInner({
|
|||||||
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
||||||
|
|
||||||
// Helper functions for pinned files
|
// Helper functions for pinned files
|
||||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileWithId[]> => {
|
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
||||||
const result = await consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch);
|
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB);
|
||||||
return result.map(({ file, id }) => createFileWithId(file, id));
|
}, [indexedDB]);
|
||||||
}, []);
|
|
||||||
|
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
|
||||||
|
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
||||||
|
}, [indexedDB]);
|
||||||
|
|
||||||
const pinFileWrapper = useCallback((file: FileWithId) => {
|
const pinFileWrapper = useCallback((file: FileWithId) => {
|
||||||
baseActions.pinFile(file.fileId);
|
baseActions.pinFile(file.fileId);
|
||||||
@ -124,11 +144,11 @@ function FileContextInner({
|
|||||||
...baseActions,
|
...baseActions,
|
||||||
addFiles: addRawFiles,
|
addFiles: addRawFiles,
|
||||||
addProcessedFiles,
|
addProcessedFiles,
|
||||||
addStoredFiles,
|
addStoredFiles,
|
||||||
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
|
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
|
||||||
// Remove from memory and cleanup resources
|
// Remove from memory and cleanup resources
|
||||||
lifecycleManager.removeFiles(fileIds, stateRef);
|
lifecycleManager.removeFiles(fileIds, stateRef);
|
||||||
|
|
||||||
// Remove from IndexedDB if enabled
|
// Remove from IndexedDB if enabled
|
||||||
if (indexedDB && enablePersistence && deleteFromStorage !== false) {
|
if (indexedDB && enablePersistence && deleteFromStorage !== false) {
|
||||||
try {
|
try {
|
||||||
@ -138,7 +158,7 @@ function FileContextInner({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
|
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
|
||||||
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
|
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
|
||||||
reorderFiles: (orderedFileIds: FileId[]) => {
|
reorderFiles: (orderedFileIds: FileId[]) => {
|
||||||
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
||||||
@ -147,7 +167,7 @@ function FileContextInner({
|
|||||||
lifecycleManager.cleanupAllFiles();
|
lifecycleManager.cleanupAllFiles();
|
||||||
filesRef.current.clear();
|
filesRef.current.clear();
|
||||||
dispatch({ type: 'RESET_CONTEXT' });
|
dispatch({ type: 'RESET_CONTEXT' });
|
||||||
|
|
||||||
// Don't clear IndexedDB automatically - only clear in-memory state
|
// Don't clear IndexedDB automatically - only clear in-memory state
|
||||||
// IndexedDB should only be cleared when explicitly requested by user
|
// IndexedDB should only be cleared when explicitly requested by user
|
||||||
},
|
},
|
||||||
@ -156,7 +176,7 @@ function FileContextInner({
|
|||||||
lifecycleManager.cleanupAllFiles();
|
lifecycleManager.cleanupAllFiles();
|
||||||
filesRef.current.clear();
|
filesRef.current.clear();
|
||||||
dispatch({ type: 'RESET_CONTEXT' });
|
dispatch({ type: 'RESET_CONTEXT' });
|
||||||
|
|
||||||
// Then clear IndexedDB storage
|
// Then clear IndexedDB storage
|
||||||
if (indexedDB && enablePersistence) {
|
if (indexedDB && enablePersistence) {
|
||||||
try {
|
try {
|
||||||
@ -170,19 +190,21 @@ function FileContextInner({
|
|||||||
pinFile: pinFileWrapper,
|
pinFile: pinFileWrapper,
|
||||||
unpinFile: unpinFileWrapper,
|
unpinFile: unpinFileWrapper,
|
||||||
consumeFiles: consumeFilesWrapper,
|
consumeFiles: consumeFilesWrapper,
|
||||||
|
undoConsumeFiles: undoConsumeFilesWrapper,
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
||||||
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef),
|
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef),
|
||||||
scheduleCleanup: (fileId: string, delay?: number) =>
|
scheduleCleanup: (fileId: string, delay?: number) =>
|
||||||
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
|
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
|
||||||
}), [
|
}), [
|
||||||
baseActions,
|
baseActions,
|
||||||
addRawFiles,
|
addRawFiles,
|
||||||
addProcessedFiles,
|
addProcessedFiles,
|
||||||
addStoredFiles,
|
addStoredFiles,
|
||||||
lifecycleManager,
|
lifecycleManager,
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
consumeFilesWrapper,
|
consumeFilesWrapper,
|
||||||
|
undoConsumeFilesWrapper,
|
||||||
pinFileWrapper,
|
pinFileWrapper,
|
||||||
unpinFileWrapper,
|
unpinFileWrapper,
|
||||||
indexedDB,
|
indexedDB,
|
||||||
@ -228,12 +250,12 @@ function FileContextInner({
|
|||||||
export function FileContextProvider({
|
export function FileContextProvider({
|
||||||
children,
|
children,
|
||||||
enableUrlSync = true,
|
enableUrlSync = true,
|
||||||
enablePersistence = true
|
enablePersistence = true
|
||||||
}: FileContextProviderProps) {
|
}: FileContextProviderProps) {
|
||||||
if (enablePersistence) {
|
if (enablePersistence) {
|
||||||
return (
|
return (
|
||||||
<IndexedDBProvider>
|
<IndexedDBProvider>
|
||||||
<FileContextInner
|
<FileContextInner
|
||||||
enableUrlSync={enableUrlSync}
|
enableUrlSync={enableUrlSync}
|
||||||
enablePersistence={enablePersistence}
|
enablePersistence={enablePersistence}
|
||||||
>
|
>
|
||||||
@ -243,7 +265,7 @@ export function FileContextProvider({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<FileContextInner
|
<FileContextInner
|
||||||
enableUrlSync={enableUrlSync}
|
enableUrlSync={enableUrlSync}
|
||||||
enablePersistence={enablePersistence}
|
enablePersistence={enablePersistence}
|
||||||
>
|
>
|
||||||
@ -266,4 +288,4 @@ export {
|
|||||||
useSelectedFiles,
|
useSelectedFiles,
|
||||||
// Primary API hooks for tools
|
// Primary API hooks for tools
|
||||||
useFileContext
|
useFileContext
|
||||||
} from './file/fileHooks';
|
} from './file/fileHooks';
|
||||||
|
@ -62,6 +62,7 @@ const initialState: NavigationState = {
|
|||||||
// Navigation context actions interface
|
// Navigation context actions interface
|
||||||
export interface NavigationContextActions {
|
export interface NavigationContextActions {
|
||||||
setMode: (mode: ModeType) => void;
|
setMode: (mode: ModeType) => void;
|
||||||
|
setWorkbench: (mode: ModeType) => void; // Alias for V2 compatibility
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||||
showNavigationWarning: (show: boolean) => void;
|
showNavigationWarning: (show: boolean) => void;
|
||||||
requestNavigation: (navigationFn: () => void) => void;
|
requestNavigation: (navigationFn: () => void) => void;
|
||||||
@ -101,6 +102,10 @@ export const NavigationProvider: React.FC<{
|
|||||||
dispatch({ type: 'SET_MODE', payload: { mode } });
|
dispatch({ type: 'SET_MODE', payload: { mode } });
|
||||||
}, []),
|
}, []),
|
||||||
|
|
||||||
|
setWorkbench: useCallback((mode: ModeType) => {
|
||||||
|
dispatch({ type: 'SET_MODE', payload: { mode } });
|
||||||
|
}, []),
|
||||||
|
|
||||||
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
|
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
|
||||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||||
}, []),
|
}, []),
|
||||||
|
@ -2,10 +2,10 @@
|
|||||||
* FileContext reducer - Pure state management for file operations
|
* FileContext reducer - Pure state management for file operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FileContextState,
|
FileContextState,
|
||||||
FileContextAction,
|
FileContextAction,
|
||||||
FileId,
|
FileId,
|
||||||
FileRecord
|
FileRecord
|
||||||
} from '../../types/fileContext';
|
} from '../../types/fileContext';
|
||||||
|
|
||||||
@ -25,6 +25,47 @@ export const initialFileContextState: FileContextState = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function for consume/undo operations
|
||||||
|
function processFileSwap(
|
||||||
|
state: FileContextState,
|
||||||
|
filesToRemove: FileId[],
|
||||||
|
filesToAdd: FileRecord[]
|
||||||
|
): FileContextState {
|
||||||
|
// Only remove unpinned files
|
||||||
|
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
||||||
|
const remainingIds = state.files.ids.filter(id => !unpinnedRemoveIds.includes(id));
|
||||||
|
|
||||||
|
// Remove unpinned files from state
|
||||||
|
const newById = { ...state.files.byId };
|
||||||
|
unpinnedRemoveIds.forEach(id => {
|
||||||
|
delete newById[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new files
|
||||||
|
const addedIds: FileId[] = [];
|
||||||
|
filesToAdd.forEach(record => {
|
||||||
|
if (!newById[record.id]) {
|
||||||
|
addedIds.push(record.id);
|
||||||
|
newById[record.id] = record;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear selections that reference removed files
|
||||||
|
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedRemoveIds.includes(id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: {
|
||||||
|
ids: [...addedIds, ...remainingIds],
|
||||||
|
byId: newById
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
...state.ui,
|
||||||
|
selectedFileIds: validSelectedFileIds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Pure reducer function
|
// Pure reducer function
|
||||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
@ -32,7 +73,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
const { fileRecords } = action.payload;
|
const { fileRecords } = action.payload;
|
||||||
const newIds: FileId[] = [];
|
const newIds: FileId[] = [];
|
||||||
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
||||||
|
|
||||||
fileRecords.forEach(record => {
|
fileRecords.forEach(record => {
|
||||||
// Only add if not already present (dedupe by stable ID)
|
// Only add if not already present (dedupe by stable ID)
|
||||||
if (!newById[record.id]) {
|
if (!newById[record.id]) {
|
||||||
@ -40,7 +81,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
newById[record.id] = record;
|
newById[record.id] = record;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
files: {
|
files: {
|
||||||
@ -49,20 +90,20 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'REMOVE_FILES': {
|
case 'REMOVE_FILES': {
|
||||||
const { fileIds } = action.payload;
|
const { fileIds } = action.payload;
|
||||||
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
|
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
|
||||||
const newById = { ...state.files.byId };
|
const newById = { ...state.files.byId };
|
||||||
|
|
||||||
// Remove files from state (resource cleanup handled by lifecycle manager)
|
// Remove files from state (resource cleanup handled by lifecycle manager)
|
||||||
fileIds.forEach(id => {
|
fileIds.forEach(id => {
|
||||||
delete newById[id];
|
delete newById[id];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear selections that reference removed files
|
// Clear selections that reference removed files
|
||||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
|
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
files: {
|
files: {
|
||||||
@ -75,15 +116,15 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'UPDATE_FILE_RECORD': {
|
case 'UPDATE_FILE_RECORD': {
|
||||||
const { id, updates } = action.payload;
|
const { id, updates } = action.payload;
|
||||||
const existingRecord = state.files.byId[id];
|
const existingRecord = state.files.byId[id];
|
||||||
|
|
||||||
if (!existingRecord) {
|
if (!existingRecord) {
|
||||||
return state; // File doesn't exist, no-op
|
return state; // File doesn't exist, no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
files: {
|
files: {
|
||||||
@ -98,13 +139,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'REORDER_FILES': {
|
case 'REORDER_FILES': {
|
||||||
const { orderedFileIds } = action.payload;
|
const { orderedFileIds } = action.payload;
|
||||||
|
|
||||||
// Validate that all IDs exist in current state
|
// Validate that all IDs exist in current state
|
||||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
files: {
|
files: {
|
||||||
@ -113,7 +154,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'SET_SELECTED_FILES': {
|
case 'SET_SELECTED_FILES': {
|
||||||
const { fileIds } = action.payload;
|
const { fileIds } = action.payload;
|
||||||
return {
|
return {
|
||||||
@ -124,7 +165,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'SET_SELECTED_PAGES': {
|
case 'SET_SELECTED_PAGES': {
|
||||||
const { pageNumbers } = action.payload;
|
const { pageNumbers } = action.payload;
|
||||||
return {
|
return {
|
||||||
@ -135,7 +176,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'CLEAR_SELECTIONS': {
|
case 'CLEAR_SELECTIONS': {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@ -146,7 +187,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'SET_PROCESSING': {
|
case 'SET_PROCESSING': {
|
||||||
const { isProcessing, progress } = action.payload;
|
const { isProcessing, progress } = action.payload;
|
||||||
return {
|
return {
|
||||||
@ -158,7 +199,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'SET_UNSAVED_CHANGES': {
|
case 'SET_UNSAVED_CHANGES': {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@ -168,73 +209,45 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'PIN_FILE': {
|
case 'PIN_FILE': {
|
||||||
const { fileId } = action.payload;
|
const { fileId } = action.payload;
|
||||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||||
newPinnedFiles.add(fileId);
|
newPinnedFiles.add(fileId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
pinnedFiles: newPinnedFiles
|
pinnedFiles: newPinnedFiles
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'UNPIN_FILE': {
|
case 'UNPIN_FILE': {
|
||||||
const { fileId } = action.payload;
|
const { fileId } = action.payload;
|
||||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||||
newPinnedFiles.delete(fileId);
|
newPinnedFiles.delete(fileId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
pinnedFiles: newPinnedFiles
|
pinnedFiles: newPinnedFiles
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'CONSUME_FILES': {
|
case 'CONSUME_FILES': {
|
||||||
const { inputFileIds, outputFileRecords } = action.payload;
|
const { inputFileIds, outputFileRecords } = action.payload;
|
||||||
|
return processFileSwap(state, inputFileIds, outputFileRecords);
|
||||||
// Only remove unpinned input files
|
|
||||||
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
|
|
||||||
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
|
|
||||||
|
|
||||||
// Remove unpinned files from state
|
|
||||||
const newById = { ...state.files.byId };
|
|
||||||
unpinnedInputIds.forEach(id => {
|
|
||||||
delete newById[id];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add output files
|
|
||||||
const outputIds: FileId[] = [];
|
|
||||||
outputFileRecords.forEach(record => {
|
|
||||||
if (!newById[record.id]) {
|
|
||||||
outputIds.push(record.id);
|
|
||||||
newById[record.id] = record;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear selections that reference removed files
|
|
||||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
files: {
|
|
||||||
ids: [...remainingIds, ...outputIds],
|
|
||||||
byId: newById
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
...state.ui,
|
|
||||||
selectedFileIds: validSelectedFileIds
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'UNDO_CONSUME_FILES': {
|
||||||
|
const { inputFileRecords, outputFileIds } = action.payload;
|
||||||
|
return processFileSwap(state, outputFileIds, inputFileRecords);
|
||||||
|
}
|
||||||
|
|
||||||
case 'RESET_CONTEXT': {
|
case 'RESET_CONTEXT': {
|
||||||
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
|
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
|
||||||
return { ...initialFileContextState };
|
return { ...initialFileContextState };
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
* File actions - Unified file operations with single addFiles helper
|
* File actions - Unified file operations with single addFiles helper
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FileId,
|
FileId,
|
||||||
FileRecord,
|
FileRecord,
|
||||||
FileContextAction,
|
FileContextAction,
|
||||||
FileContextState,
|
FileContextState,
|
||||||
toFileRecord,
|
toFileRecord,
|
||||||
@ -78,13 +78,13 @@ type AddFileKind = 'raw' | 'processed' | 'stored';
|
|||||||
interface AddFileOptions {
|
interface AddFileOptions {
|
||||||
// For 'raw' files
|
// For 'raw' files
|
||||||
files?: File[];
|
files?: File[];
|
||||||
|
|
||||||
// For 'processed' files
|
// For 'processed' files
|
||||||
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
||||||
|
|
||||||
// For 'stored' files
|
// For 'stored' files
|
||||||
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
|
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
|
||||||
|
|
||||||
// Insertion position
|
// Insertion position
|
||||||
insertAfterPageId?: string;
|
insertAfterPageId?: string;
|
||||||
}
|
}
|
||||||
@ -102,37 +102,37 @@ export async function addFiles(
|
|||||||
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
|
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
|
||||||
// Acquire mutex to prevent race conditions
|
// Acquire mutex to prevent race conditions
|
||||||
await addFilesMutex.lock();
|
await addFilesMutex.lock();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileRecords: FileRecord[] = [];
|
const fileRecords: FileRecord[] = [];
|
||||||
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
|
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
|
||||||
|
|
||||||
// Build quickKey lookup from existing files for deduplication
|
// Build quickKey lookup from existing files for deduplication
|
||||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
|
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
|
||||||
|
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'raw': {
|
case 'raw': {
|
||||||
const { files = [] } = options;
|
const { files = [] } = options;
|
||||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const quickKey = createQuickKey(file);
|
const quickKey = createQuickKey(file);
|
||||||
|
|
||||||
// Soft deduplication: Check if file already exists by metadata
|
// Soft deduplication: Check if file already exists by metadata
|
||||||
if (existingQuickKeys.has(quickKey)) {
|
if (existingQuickKeys.has(quickKey)) {
|
||||||
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||||
|
|
||||||
const fileId = createFileId();
|
const fileId = createFileId();
|
||||||
filesRef.current.set(fileId, file);
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
// Generate thumbnail and page count immediately
|
// Generate thumbnail and page count immediately
|
||||||
let thumbnail: string | undefined;
|
let thumbnail: string | undefined;
|
||||||
let pageCount: number = 1;
|
let pageCount: number = 1;
|
||||||
|
|
||||||
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
|
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
|
||||||
if (file.type.startsWith('application/pdf')) {
|
if (file.type.startsWith('application/pdf')) {
|
||||||
try {
|
try {
|
||||||
@ -156,7 +156,7 @@ export async function addFiles(
|
|||||||
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create record with immediate thumbnail and page metadata
|
// Create record with immediate thumbnail and page metadata
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toFileRecord(file, fileId);
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
@ -166,40 +166,40 @@ export async function addFiles(
|
|||||||
lifecycleManager.trackBlobUrl(thumbnail);
|
lifecycleManager.trackBlobUrl(thumbnail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store insertion position if provided
|
// Store insertion position if provided
|
||||||
if (options.insertAfterPageId !== undefined) {
|
if (options.insertAfterPageId !== undefined) {
|
||||||
record.insertAfterPageId = options.insertAfterPageId;
|
record.insertAfterPageId = options.insertAfterPageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create initial processedFile metadata with page count
|
// Create initial processedFile metadata with page count
|
||||||
if (pageCount > 0) {
|
if (pageCount > 0) {
|
||||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||||
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||||
}
|
}
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
fileRecords.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'processed': {
|
case 'processed': {
|
||||||
const { filesWithThumbnails = [] } = options;
|
const { filesWithThumbnails = [] } = options;
|
||||||
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
|
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
|
||||||
|
|
||||||
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
|
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
|
||||||
const quickKey = createQuickKey(file);
|
const quickKey = createQuickKey(file);
|
||||||
|
|
||||||
if (existingQuickKeys.has(quickKey)) {
|
if (existingQuickKeys.has(quickKey)) {
|
||||||
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
|
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileId = createFileId();
|
const fileId = createFileId();
|
||||||
filesRef.current.set(fileId, file);
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toFileRecord(file, fileId);
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
record.thumbnailUrl = thumbnail;
|
record.thumbnailUrl = thumbnail;
|
||||||
@ -208,64 +208,64 @@ export async function addFiles(
|
|||||||
lifecycleManager.trackBlobUrl(thumbnail);
|
lifecycleManager.trackBlobUrl(thumbnail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store insertion position if provided
|
// Store insertion position if provided
|
||||||
if (options.insertAfterPageId !== undefined) {
|
if (options.insertAfterPageId !== undefined) {
|
||||||
record.insertAfterPageId = options.insertAfterPageId;
|
record.insertAfterPageId = options.insertAfterPageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create processedFile with provided metadata
|
// Create processedFile with provided metadata
|
||||||
if (pageCount > 0) {
|
if (pageCount > 0) {
|
||||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||||
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||||
}
|
}
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
fileRecords.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'stored': {
|
case 'stored': {
|
||||||
const { filesWithMetadata = [] } = options;
|
const { filesWithMetadata = [] } = options;
|
||||||
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
|
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
|
||||||
|
|
||||||
for (const { file, originalId, metadata } of filesWithMetadata) {
|
for (const { file, originalId, metadata } of filesWithMetadata) {
|
||||||
const quickKey = createQuickKey(file);
|
const quickKey = createQuickKey(file);
|
||||||
|
|
||||||
if (existingQuickKeys.has(quickKey)) {
|
if (existingQuickKeys.has(quickKey)) {
|
||||||
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
|
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
|
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||||
|
|
||||||
// Try to preserve original ID, but generate new if it conflicts
|
// Try to preserve original ID, but generate new if it conflicts
|
||||||
let fileId = originalId;
|
let fileId = originalId;
|
||||||
if (filesRef.current.has(originalId)) {
|
if (filesRef.current.has(originalId)) {
|
||||||
fileId = createFileId();
|
fileId = createFileId();
|
||||||
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
|
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
filesRef.current.set(fileId, file);
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toFileRecord(file, fileId);
|
||||||
|
|
||||||
// Generate processedFile metadata for stored files
|
// Generate processedFile metadata for stored files
|
||||||
let pageCount: number = 1;
|
let pageCount: number = 1;
|
||||||
|
|
||||||
// Only process PDFs through PDF worker manager, non-PDFs have no page count
|
// Only process PDFs through PDF worker manager, non-PDFs have no page count
|
||||||
if (file.type.startsWith('application/pdf')) {
|
if (file.type.startsWith('application/pdf')) {
|
||||||
try {
|
try {
|
||||||
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
|
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
|
||||||
|
|
||||||
// Get page count from PDF
|
// Get page count from PDF
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
|
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
|
||||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
pageCount = pdf.numPages;
|
pageCount = pdf.numPages;
|
||||||
pdfWorkerManager.destroyDocument(pdf);
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
|
|
||||||
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
|
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
|
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
|
||||||
@ -274,7 +274,7 @@ export async function addFiles(
|
|||||||
pageCount = 0; // Non-PDFs have no page count
|
pageCount = 0; // Non-PDFs have no page count
|
||||||
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
|
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore metadata from storage
|
// Restore metadata from storage
|
||||||
if (metadata.thumbnail) {
|
if (metadata.thumbnail) {
|
||||||
record.thumbnailUrl = metadata.thumbnail;
|
record.thumbnailUrl = metadata.thumbnail;
|
||||||
@ -283,33 +283,33 @@ export async function addFiles(
|
|||||||
lifecycleManager.trackBlobUrl(metadata.thumbnail);
|
lifecycleManager.trackBlobUrl(metadata.thumbnail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store insertion position if provided
|
// Store insertion position if provided
|
||||||
if (options.insertAfterPageId !== undefined) {
|
if (options.insertAfterPageId !== undefined) {
|
||||||
record.insertAfterPageId = options.insertAfterPageId;
|
record.insertAfterPageId = options.insertAfterPageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create processedFile metadata with correct page count
|
// Create processedFile metadata with correct page count
|
||||||
if (pageCount > 0) {
|
if (pageCount > 0) {
|
||||||
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
|
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
|
||||||
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
|
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||||
}
|
}
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
fileRecords.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||||
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch ADD_FILES action if we have new files
|
// Dispatch ADD_FILES action if we have new files
|
||||||
if (fileRecords.length > 0) {
|
if (fileRecords.length > 0) {
|
||||||
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
||||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
|
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return addedFiles;
|
return addedFiles;
|
||||||
} finally {
|
} finally {
|
||||||
// Always release mutex even if error occurs
|
// Always release mutex even if error occurs
|
||||||
@ -317,6 +317,61 @@ export async function addFiles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to process files into records with thumbnails and metadata
|
||||||
|
*/
|
||||||
|
async function processFilesIntoRecords(
|
||||||
|
files: File[],
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
|
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||||
|
return Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
const fileId = createFileId();
|
||||||
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
|
// Generate thumbnail and page count
|
||||||
|
let thumbnail: string | undefined;
|
||||||
|
let pageCount: number = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (DEBUG) console.log(`📄 Generating thumbnail for file ${file.name}`);
|
||||||
|
const result = await generateThumbnailWithMetadata(file);
|
||||||
|
thumbnail = result.thumbnail;
|
||||||
|
pageCount = result.pageCount;
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = toFileRecord(file, fileId);
|
||||||
|
if (thumbnail) {
|
||||||
|
record.thumbnailUrl = thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageCount > 0) {
|
||||||
|
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { record, file, fileId, thumbnail };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to persist files to IndexedDB
|
||||||
|
*/
|
||||||
|
async function persistFilesToIndexedDB(
|
||||||
|
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||||
|
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => {
|
||||||
|
try {
|
||||||
|
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consume files helper - replace unpinned input files with output files
|
* Consume files helper - replace unpinned input files with output files
|
||||||
*/
|
*/
|
||||||
@ -325,56 +380,139 @@ export async function consumeFiles(
|
|||||||
outputFiles: File[],
|
outputFiles: File[],
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
dispatch: React.Dispatch<FileContextAction>
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
|
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
|
||||||
|
): Promise<FileId[]> {
|
||||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||||
|
|
||||||
// Process output files through the 'processed' path to generate thumbnails
|
// Process output files with thumbnails and metadata
|
||||||
const processedOutputs: Array<{ file: File; id: FileId; thumbnail?: string; record: FileRecord }> = await Promise.all(
|
const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef);
|
||||||
outputFiles.map(async (file) => {
|
|
||||||
const fileId = createFileId();
|
// Persist output files to IndexedDB if available
|
||||||
filesRef.current.set(fileId, file);
|
if (indexedDB) {
|
||||||
|
await persistFilesToIndexedDB(outputFileRecords, indexedDB);
|
||||||
// Generate thumbnail and page count for output file
|
}
|
||||||
let thumbnail: string | undefined;
|
|
||||||
let pageCount: number = 1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
|
|
||||||
const result = await generateThumbnailWithMetadata(file);
|
|
||||||
thumbnail = result.thumbnail;
|
|
||||||
pageCount = result.pageCount;
|
|
||||||
} catch (error) {
|
|
||||||
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = toFileRecord(file, fileId);
|
|
||||||
if (thumbnail) {
|
|
||||||
record.thumbnailUrl = thumbnail;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageCount > 0) {
|
|
||||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { file, id: fileId, thumbnail, record };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const outputFileRecords = processedOutputs.map(({ record }) => record);
|
|
||||||
|
|
||||||
// Dispatch the consume action
|
// Dispatch the consume action
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'CONSUME_FILES',
|
type: 'CONSUME_FILES',
|
||||||
payload: {
|
payload: {
|
||||||
inputFileIds,
|
inputFileIds,
|
||||||
outputFileRecords
|
outputFileRecords: outputFileRecords.map(({ record }) => record)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
||||||
|
|
||||||
return processedOutputs.map(({ file, id, thumbnail }) => ({ file, id, thumbnail }));
|
// Return the output file IDs for undo tracking
|
||||||
|
return outputFileRecords.map(({ fileId }) => fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
||||||
|
*/
|
||||||
|
async function restoreFilesAndCleanup(
|
||||||
|
filesToRestore: Array<{ file: File; record: FileRecord }>,
|
||||||
|
fileIdsToRemove: FileId[],
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
|
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||||
|
): Promise<void> {
|
||||||
|
// Remove files from filesRef
|
||||||
|
fileIdsToRemove.forEach(id => {
|
||||||
|
if (filesRef.current.has(id)) {
|
||||||
|
if (DEBUG) console.log(`📄 Removing file ${id} from filesRef`);
|
||||||
|
filesRef.current.delete(id);
|
||||||
|
} else {
|
||||||
|
if (DEBUG) console.warn(`📄 File ${id} not found in filesRef`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore files to filesRef
|
||||||
|
filesToRestore.forEach(({ file, record }) => {
|
||||||
|
if (file && record) {
|
||||||
|
// Validate the file before restoring
|
||||||
|
if (file.size === 0) {
|
||||||
|
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the file to filesRef
|
||||||
|
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
||||||
|
filesRef.current.set(record.id, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up IndexedDB
|
||||||
|
if (indexedDB) {
|
||||||
|
const indexedDBPromises = fileIdsToRemove.map(fileId =>
|
||||||
|
indexedDB.deleteFile(fileId).catch(error => {
|
||||||
|
console.error('Failed to delete file from IndexedDB:', fileId, error);
|
||||||
|
throw error; // Re-throw to trigger rollback
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute all IndexedDB operations
|
||||||
|
await Promise.all(indexedDBPromises);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undoes a previous consumeFiles operation by restoring input files and removing output files (unless pinned)
|
||||||
|
*/
|
||||||
|
export async function undoConsumeFiles(
|
||||||
|
inputFiles: File[],
|
||||||
|
inputFileRecords: FileRecord[],
|
||||||
|
outputFileIds: FileId[],
|
||||||
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
|
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`);
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (inputFiles.length !== inputFileRecords.length) {
|
||||||
|
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a backup of current filesRef state for rollback
|
||||||
|
const backupFilesRef = new Map(filesRef.current);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare files to restore
|
||||||
|
const filesToRestore = inputFiles.map((file, index) => ({
|
||||||
|
file,
|
||||||
|
record: inputFileRecords[index]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Restore input files and clean up output files
|
||||||
|
await restoreFilesAndCleanup(
|
||||||
|
filesToRestore,
|
||||||
|
outputFileIds,
|
||||||
|
filesRef,
|
||||||
|
indexedDB
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dispatch the undo action (only if everything else succeeded)
|
||||||
|
dispatch({
|
||||||
|
type: 'UNDO_CONSUME_FILES',
|
||||||
|
payload: {
|
||||||
|
inputFileRecords,
|
||||||
|
outputFileIds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback filesRef to previous state
|
||||||
|
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
||||||
|
filesRef.current.clear();
|
||||||
|
backupFilesRef.forEach((file, id) => {
|
||||||
|
filesRef.current.set(id, file);
|
||||||
|
});
|
||||||
|
throw error; // Re-throw to let caller handle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -3,11 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useContext, useMemo } from 'react';
|
import { useContext, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
FileStateContext,
|
FileStateContext,
|
||||||
FileActionsContext,
|
FileActionsContext,
|
||||||
FileContextStateValue,
|
FileContextStateValue,
|
||||||
FileContextActionsValue
|
FileContextActionsValue
|
||||||
} from './contexts';
|
} from './contexts';
|
||||||
import { FileId, FileRecord, FileWithId } from '../../types/fileContext';
|
import { FileId, FileRecord, FileWithId } from '../../types/fileContext';
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ export function useFileActions(): FileContextActionsValue {
|
|||||||
*/
|
*/
|
||||||
export function useCurrentFile(): { file?: File; record?: FileRecord } {
|
export function useCurrentFile(): { file?: File; record?: FileRecord } {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
const primaryFileId = state.files.ids[0];
|
const primaryFileId = state.files.ids[0];
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
||||||
@ -81,7 +81,7 @@ export function useFileSelection() {
|
|||||||
*/
|
*/
|
||||||
export function useFileManagement() {
|
export function useFileManagement() {
|
||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
addFiles: actions.addFiles,
|
addFiles: actions.addFiles,
|
||||||
removeFiles: actions.removeFiles,
|
removeFiles: actions.removeFiles,
|
||||||
@ -112,7 +112,7 @@ export function useFileUI() {
|
|||||||
*/
|
*/
|
||||||
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
|
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
file: selectors.getFile(fileId),
|
file: selectors.getFile(fileId),
|
||||||
record: selectors.getFileRecord(fileId)
|
record: selectors.getFileRecord(fileId)
|
||||||
@ -124,7 +124,7 @@ export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecor
|
|||||||
*/
|
*/
|
||||||
export function useAllFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
|
export function useAllFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
files: selectors.getFiles(),
|
files: selectors.getFiles(),
|
||||||
records: selectors.getFileRecords(),
|
records: selectors.getFileRecords(),
|
||||||
@ -137,7 +137,7 @@ export function useAllFiles(): { files: FileWithId[]; records: FileRecord[]; fil
|
|||||||
*/
|
*/
|
||||||
export function useSelectedFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
|
export function useSelectedFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
files: selectors.getSelectedFiles(),
|
files: selectors.getSelectedFiles(),
|
||||||
records: selectors.getSelectedFileRecords(),
|
records: selectors.getSelectedFileRecords(),
|
||||||
@ -160,19 +160,38 @@ export function useFileContext() {
|
|||||||
trackBlobUrl: actions.trackBlobUrl,
|
trackBlobUrl: actions.trackBlobUrl,
|
||||||
scheduleCleanup: actions.scheduleCleanup,
|
scheduleCleanup: actions.scheduleCleanup,
|
||||||
setUnsavedChanges: actions.setHasUnsavedChanges,
|
setUnsavedChanges: actions.setHasUnsavedChanges,
|
||||||
|
|
||||||
// File management
|
// File management
|
||||||
addFiles: actions.addFiles,
|
addFiles: actions.addFiles,
|
||||||
consumeFiles: actions.consumeFiles,
|
consumeFiles: actions.consumeFiles,
|
||||||
|
undoConsumeFiles: actions.undoConsumeFiles,
|
||||||
|
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
|
||||||
|
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
|
||||||
|
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
|
||||||
|
|
||||||
|
// File ID lookup
|
||||||
|
findFileId: (file: File) => {
|
||||||
|
return state.files.ids.find(id => {
|
||||||
|
const record = state.files.byId[id];
|
||||||
|
return record &&
|
||||||
|
record.name === file.name &&
|
||||||
|
record.size === file.size &&
|
||||||
|
record.lastModified === file.lastModified;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Pinned files
|
// Pinned files
|
||||||
pinnedFiles: state.pinnedFiles,
|
pinnedFiles: state.pinnedFiles,
|
||||||
pinFile: actions.pinFile,
|
pinFile: actions.pinFile,
|
||||||
unpinFile: actions.unpinFile,
|
unpinFile: actions.unpinFile,
|
||||||
isFilePinned: selectors.isFilePinned,
|
isFilePinned: selectors.isFilePinned,
|
||||||
|
|
||||||
// Active files
|
// Active files
|
||||||
activeFiles: selectors.getFiles()
|
activeFiles: selectors.getFiles(),
|
||||||
|
|
||||||
|
// Direct access to actions and selectors (for advanced use cases)
|
||||||
|
actions,
|
||||||
|
selectors
|
||||||
}), [state, selectors, actions]);
|
}), [state, selectors, actions]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,6 +42,7 @@ describe('useAddPasswordOperation', () => {
|
|||||||
resetResults: vi.fn(),
|
resetResults: vi.fn(),
|
||||||
clearError: vi.fn(),
|
clearError: vi.fn(),
|
||||||
cancelOperation: vi.fn(),
|
cancelOperation: vi.fn(),
|
||||||
|
undoOperation: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -41,6 +41,7 @@ describe('useChangePermissionsOperation', () => {
|
|||||||
resetResults: vi.fn(),
|
resetResults: vi.fn(),
|
||||||
clearError: vi.fn(),
|
clearError: vi.fn(),
|
||||||
cancelOperation: vi.fn(),
|
cancelOperation: vi.fn(),
|
||||||
|
undoOperation: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -41,6 +41,7 @@ describe('useRemovePasswordOperation', () => {
|
|||||||
resetResults: vi.fn(),
|
resetResults: vi.fn(),
|
||||||
clearError: vi.fn(),
|
clearError: vi.fn(),
|
||||||
cancelOperation: vi.fn(),
|
cancelOperation: vi.fn(),
|
||||||
|
undoOperation: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
126
frontend/src/hooks/tools/shared/useBaseTool.ts
Normal file
126
frontend/src/hooks/tools/shared/useBaseTool.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useFileSelection } from '../../../contexts/FileContext';
|
||||||
|
import { useEndpointEnabled } from '../../useEndpointConfig';
|
||||||
|
import { BaseToolProps } from '../../../types/tool';
|
||||||
|
import { ToolOperationHook } from './useToolOperation';
|
||||||
|
import { BaseParametersHook } from './useBaseParameters';
|
||||||
|
import { FileWithId } from '../../../types/fileContext';
|
||||||
|
|
||||||
|
interface BaseToolReturn<TParams> {
|
||||||
|
// File management
|
||||||
|
selectedFiles: FileWithId[];
|
||||||
|
|
||||||
|
// Tool-specific hooks
|
||||||
|
params: BaseParametersHook<TParams>;
|
||||||
|
operation: ToolOperationHook<TParams>;
|
||||||
|
|
||||||
|
// Endpoint validation
|
||||||
|
endpointEnabled: boolean | null;
|
||||||
|
endpointLoading: boolean;
|
||||||
|
|
||||||
|
// Standard handlers
|
||||||
|
handleExecute: () => Promise<void>;
|
||||||
|
handleThumbnailClick: (file: File) => void;
|
||||||
|
handleSettingsReset: () => void;
|
||||||
|
handleUndo: () => Promise<void>;
|
||||||
|
|
||||||
|
// Standard computed state
|
||||||
|
hasFiles: boolean;
|
||||||
|
hasResults: boolean;
|
||||||
|
settingsCollapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base tool hook for tool components. Manages standard behaviour for tools.
|
||||||
|
*/
|
||||||
|
export function useBaseTool<TParams>(
|
||||||
|
toolName: string,
|
||||||
|
useParams: () => BaseParametersHook<TParams>,
|
||||||
|
useOperation: () => ToolOperationHook<TParams>,
|
||||||
|
props: BaseToolProps,
|
||||||
|
): BaseToolReturn<TParams> {
|
||||||
|
const { onPreviewFile, onComplete, onError } = props;
|
||||||
|
|
||||||
|
// File selection
|
||||||
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
|
// Tool-specific hooks
|
||||||
|
const params = useParams();
|
||||||
|
const operation = useOperation();
|
||||||
|
|
||||||
|
// Endpoint validation using parameters hook
|
||||||
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(params.getEndpointName());
|
||||||
|
|
||||||
|
// Reset results when parameters change
|
||||||
|
useEffect(() => {
|
||||||
|
operation.resetResults();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
}, [params.parameters]);
|
||||||
|
|
||||||
|
// Reset results when selected files change
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
operation.resetResults();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
}
|
||||||
|
}, [selectedFiles.length]);
|
||||||
|
|
||||||
|
// Standard handlers
|
||||||
|
const handleExecute = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await operation.executeOperation(params.parameters, selectedFiles);
|
||||||
|
if (operation.files && onComplete) {
|
||||||
|
onComplete(operation.files);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (onError) {
|
||||||
|
const message = error instanceof Error ? error.message : `${toolName} operation failed`;
|
||||||
|
onError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [operation, params.parameters, selectedFiles, onComplete, onError, toolName]);
|
||||||
|
|
||||||
|
const handleThumbnailClick = useCallback((file: File) => {
|
||||||
|
onPreviewFile?.(file);
|
||||||
|
sessionStorage.setItem('previousMode', toolName);
|
||||||
|
}, [onPreviewFile, toolName]);
|
||||||
|
|
||||||
|
const handleSettingsReset = useCallback(() => {
|
||||||
|
operation.resetResults();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
}, [operation, onPreviewFile]);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(async () => {
|
||||||
|
await operation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
}, [operation, onPreviewFile]);
|
||||||
|
|
||||||
|
// Standard computed state
|
||||||
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||||
|
const settingsCollapsed = !hasFiles || hasResults;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// File management
|
||||||
|
selectedFiles,
|
||||||
|
|
||||||
|
// Tool-specific hooks
|
||||||
|
params,
|
||||||
|
operation,
|
||||||
|
|
||||||
|
// Endpoint validation
|
||||||
|
endpointEnabled,
|
||||||
|
endpointLoading,
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
handleExecute,
|
||||||
|
handleThumbnailClick,
|
||||||
|
handleSettingsReset,
|
||||||
|
handleUndo,
|
||||||
|
|
||||||
|
// State
|
||||||
|
hasFiles,
|
||||||
|
hasResults,
|
||||||
|
settingsCollapsed
|
||||||
|
};
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useRef, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
import { useFileContext } from '../../../contexts/FileContext';
|
||||||
@ -6,7 +6,7 @@ import { useToolState, type ProcessingProgress } from './useToolState';
|
|||||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||||
import { useToolResources } from './useToolResources';
|
import { useToolResources } from './useToolResources';
|
||||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||||
import { FileWithId, extractFiles } from '../../../types/fileContext';
|
import { FileWithId, extractFiles, FileId, FileRecord } from '../../../types/fileContext';
|
||||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
@ -86,6 +86,7 @@ export interface ToolOperationHook<TParams = void> {
|
|||||||
resetResults: () => void;
|
resetResults: () => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
cancelOperation: () => void;
|
cancelOperation: () => void;
|
||||||
|
undoOperation: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
@ -107,13 +108,20 @@ export const useToolOperation = <TParams = void>(
|
|||||||
config: ToolOperationConfig<TParams>
|
config: ToolOperationConfig<TParams>
|
||||||
): ToolOperationHook<TParams> => {
|
): ToolOperationHook<TParams> => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { addFiles, consumeFiles } = useFileContext();
|
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
|
||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
||||||
|
|
||||||
|
// Track last operation for undo functionality
|
||||||
|
const lastOperationRef = useRef<{
|
||||||
|
inputFiles: File[];
|
||||||
|
inputFileRecords: FileRecord[];
|
||||||
|
outputFileIds: FileId[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
const executeOperation = useCallback(async (
|
||||||
params: TParams,
|
params: TParams,
|
||||||
selectedFiles: FileWithId[]
|
selectedFiles: FileWithId[]
|
||||||
@ -158,8 +166,8 @@ export const useToolOperation = <TParams = void>(
|
|||||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||||
if (config.responseHandler) {
|
if (config.responseHandler) {
|
||||||
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
||||||
processedFiles = await config.responseHandler(response.data, validRegularFiles);
|
processedFiles = await config.responseHandler(response.data, validFiles);
|
||||||
} else if (response.data.type === 'application/pdf' ||
|
} else if (response.data.type === 'application/pdf' ||
|
||||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||||
// Single PDF response (e.g. split with merge option) - use original filename
|
// Single PDF response (e.g. split with merge option) - use original filename
|
||||||
const originalFileName = validRegularFiles[0]?.name || 'document.pdf';
|
const originalFileName = validRegularFiles[0]?.name || 'document.pdf';
|
||||||
@ -207,8 +215,34 @@ export const useToolOperation = <TParams = void>(
|
|||||||
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
||||||
|
|
||||||
// Replace input files with processed files (consumeFiles handles pinning)
|
// Replace input files with processed files (consumeFiles handles pinning)
|
||||||
const inputFileIds = validFiles.map(file => file.fileId);
|
const inputFileIds: FileId[] = [];
|
||||||
await consumeFiles(inputFileIds, processedFiles);
|
const inputFileRecords: FileRecord[] = [];
|
||||||
|
|
||||||
|
// Build parallel arrays of IDs and records for undo tracking
|
||||||
|
for (const file of validFiles) {
|
||||||
|
const fileId = findFileId(file);
|
||||||
|
if (fileId) {
|
||||||
|
const record = selectors.getFileRecord(fileId);
|
||||||
|
if (record) {
|
||||||
|
inputFileIds.push(fileId);
|
||||||
|
inputFileRecords.push(record);
|
||||||
|
} else {
|
||||||
|
console.warn(`No file record found for file: ${file.name}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`No file ID found for file: ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
|
||||||
|
|
||||||
|
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||||
|
lastOperationRef.current = {
|
||||||
|
inputFiles: validFiles, // Keep original File objects for undo
|
||||||
|
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
||||||
|
outputFileIds
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -231,8 +265,65 @@ export const useToolOperation = <TParams = void>(
|
|||||||
const resetResults = useCallback(() => {
|
const resetResults = useCallback(() => {
|
||||||
cleanupBlobUrls();
|
cleanupBlobUrls();
|
||||||
actions.resetResults();
|
actions.resetResults();
|
||||||
|
// Clear undo data when results are reset to prevent memory leaks
|
||||||
|
lastOperationRef.current = null;
|
||||||
}, [cleanupBlobUrls, actions]);
|
}, [cleanupBlobUrls, actions]);
|
||||||
|
|
||||||
|
// Cleanup on unmount to prevent memory leaks
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
lastOperationRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const undoOperation = useCallback(async () => {
|
||||||
|
if (!lastOperationRef.current) {
|
||||||
|
actions.setError(t('noOperationToUndo', 'No operation to undo'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
|
||||||
|
|
||||||
|
// Validate that we have data to undo
|
||||||
|
if (inputFiles.length === 0 || inputFileRecords.length === 0) {
|
||||||
|
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputFileIds.length === 0) {
|
||||||
|
actions.setError(t('noFilesToUndo', 'Cannot undo: no files were processed in the last operation'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Undo the consume operation
|
||||||
|
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
|
||||||
|
|
||||||
|
// Clear results and operation tracking
|
||||||
|
resetResults();
|
||||||
|
lastOperationRef.current = null;
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
actions.setStatus(t('undoSuccess', 'Operation undone successfully'));
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = extractErrorMessage(error);
|
||||||
|
|
||||||
|
// Provide more specific error messages based on error type
|
||||||
|
if (error.message?.includes('Mismatch between input files')) {
|
||||||
|
errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted');
|
||||||
|
} else if (error.message?.includes('IndexedDB')) {
|
||||||
|
errorMessage = t('undoStorageError', 'Undo completed but some files could not be saved to storage');
|
||||||
|
} else if (error.name === 'QuotaExceededError') {
|
||||||
|
errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space');
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`);
|
||||||
|
|
||||||
|
// Don't clear the operation data if undo failed - user might want to try again
|
||||||
|
}
|
||||||
|
}, [undoConsumeFiles, resetResults, actions, t]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
files: state.files,
|
files: state.files,
|
||||||
@ -249,6 +340,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
executeOperation,
|
executeOperation,
|
||||||
resetResults,
|
resetResults,
|
||||||
clearError: actions.clearError,
|
clearError: actions.clearError,
|
||||||
cancelOperation
|
cancelOperation,
|
||||||
|
undoOperation
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -9,12 +9,12 @@ export const useFileHandler = () => {
|
|||||||
|
|
||||||
const addToActiveFiles = useCallback(async (file: File) => {
|
const addToActiveFiles = useCallback(async (file: File) => {
|
||||||
// Let FileContext handle deduplication with quickKey logic
|
// Let FileContext handle deduplication with quickKey logic
|
||||||
await actions.addFiles([file]);
|
await actions.addFiles([file], { selectFiles: true });
|
||||||
}, [actions.addFiles]);
|
}, [actions.addFiles]);
|
||||||
|
|
||||||
const addMultipleFiles = useCallback(async (files: File[]) => {
|
const addMultipleFiles = useCallback(async (files: File[]) => {
|
||||||
// Let FileContext handle deduplication with quickKey logic
|
// Let FileContext handle deduplication with quickKey logic
|
||||||
await actions.addFiles(files);
|
await actions.addFiles(files, { selectFiles: true });
|
||||||
}, [actions.addFiles]);
|
}, [actions.addFiles]);
|
||||||
|
|
||||||
// Add stored files preserving their original IDs to prevent session duplicates
|
// Add stored files preserving their original IDs to prevent session duplicates
|
||||||
@ -29,7 +29,7 @@ export const useFileHandler = () => {
|
|||||||
file,
|
file,
|
||||||
originalId: originalId as FileId,
|
originalId: originalId as FileId,
|
||||||
metadata
|
metadata
|
||||||
})));
|
})), { selectFiles: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);
|
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);
|
||||||
|
@ -60,6 +60,11 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await addPasswordOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
|
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
|
||||||
const passwordsCollapsed = !hasFiles || hasResults;
|
const passwordsCollapsed = !hasFiles || hasResults;
|
||||||
@ -110,6 +115,7 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
operation: addPasswordOperation,
|
operation: addPasswordOperation,
|
||||||
title: t("addPassword.results.title", "Encrypted PDFs"),
|
title: t("addPassword.results.title", "Encrypted PDFs"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: handleThumbnailClick,
|
||||||
|
onUndo: handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -79,6 +79,11 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
|||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await watermarkOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = watermarkOperation.files.length > 0 || watermarkOperation.downloadUrl !== null;
|
const hasResults = watermarkOperation.files.length > 0 || watermarkOperation.downloadUrl !== null;
|
||||||
|
|
||||||
@ -203,6 +208,7 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
|||||||
operation: watermarkOperation,
|
operation: watermarkOperation,
|
||||||
title: t("watermark.results.title", "Watermark Results"),
|
title: t("watermark.results.title", "Watermark Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: handleThumbnailClick,
|
||||||
|
onUndo: handleUndo,
|
||||||
},
|
},
|
||||||
forceStepNumbers: true,
|
forceStepNumbers: true,
|
||||||
});
|
});
|
||||||
|
@ -2,7 +2,7 @@ import React, { useState, useMemo, useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useFileSelection } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
import { useNavigation } from "../contexts/NavigationContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
@ -21,7 +21,7 @@ import { AUTOMATION_STEPS } from "../constants/automation";
|
|||||||
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
const { setMode } = useNavigation();
|
const { actions } = useNavigationActions();
|
||||||
const { registerToolReset } = useToolWorkflow();
|
const { registerToolReset } = useToolWorkflow();
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION);
|
const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION);
|
||||||
@ -43,6 +43,11 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await automateOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Register reset function with the tool workflow context - only once on mount
|
// Register reset function with the tool workflow context - only once on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const stableResetFunction = () => {
|
const stableResetFunction = () => {
|
||||||
@ -223,8 +228,9 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
title: t('automate.reviewTitle', 'Automation Results'),
|
title: t('automate.reviewTitle', 'Automation Results'),
|
||||||
onFileClick: (file: File) => {
|
onFileClick: (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
setMode('viewer');
|
actions.setWorkbench('viewer');
|
||||||
}
|
},
|
||||||
|
onUndo: handleUndo
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,96 +1,56 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
import { useFileSelection } from "../contexts/FileContext";
|
|
||||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||||
|
|
||||||
import { useChangePermissionsParameters } from "../hooks/tools/changePermissions/useChangePermissionsParameters";
|
import { useChangePermissionsParameters } from "../hooks/tools/changePermissions/useChangePermissionsParameters";
|
||||||
import { useChangePermissionsOperation } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
import { useChangePermissionsOperation } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||||
import { useChangePermissionsTips } from "../components/tooltips/useChangePermissionsTips";
|
import { useChangePermissionsTips } from "../components/tooltips/useChangePermissionsTips";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
|
||||||
const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const ChangePermissions = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { actions } = useNavigationActions();
|
|
||||||
const { selectedFiles } = useFileSelection();
|
|
||||||
|
|
||||||
const changePermissionsParams = useChangePermissionsParameters();
|
|
||||||
const changePermissionsOperation = useChangePermissionsOperation();
|
|
||||||
const changePermissionsTips = useChangePermissionsTips();
|
const changePermissionsTips = useChangePermissionsTips();
|
||||||
|
|
||||||
// Endpoint validation
|
const base = useBaseTool(
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(changePermissionsParams.getEndpointName());
|
'changePermissions',
|
||||||
|
useChangePermissionsParameters,
|
||||||
useEffect(() => {
|
useChangePermissionsOperation,
|
||||||
changePermissionsOperation.resetResults();
|
props
|
||||||
onPreviewFile?.(null);
|
);
|
||||||
}, [changePermissionsParams.parameters]);
|
|
||||||
|
|
||||||
const handleChangePermissions = async () => {
|
|
||||||
try {
|
|
||||||
await changePermissionsOperation.executeOperation(changePermissionsParams.parameters, selectedFiles);
|
|
||||||
if (changePermissionsOperation.files && onComplete) {
|
|
||||||
onComplete(changePermissionsOperation.files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (onError) {
|
|
||||||
onError(
|
|
||||||
error instanceof Error ? error.message : t("changePermissions.error.failed", "Change permissions operation failed")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
|
||||||
onPreviewFile?.(file);
|
|
||||||
sessionStorage.setItem("previousMode", "changePermissions");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
|
||||||
changePermissionsOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
|
||||||
const hasResults = changePermissionsOperation.files.length > 0 || changePermissionsOperation.downloadUrl !== null;
|
|
||||||
const settingsCollapsed = !hasFiles || hasResults;
|
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: hasResults,
|
isCollapsed: base.hasResults,
|
||||||
},
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
title: t("changePermissions.title", "Document Permissions"),
|
title: t("changePermissions.title", "Document Permissions"),
|
||||||
isCollapsed: settingsCollapsed,
|
isCollapsed: base.settingsCollapsed,
|
||||||
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
|
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||||
tooltip: changePermissionsTips,
|
tooltip: changePermissionsTips,
|
||||||
content: (
|
content: (
|
||||||
<ChangePermissionsSettings
|
<ChangePermissionsSettings
|
||||||
parameters={changePermissionsParams.parameters}
|
parameters={base.params.parameters}
|
||||||
onParameterChange={changePermissionsParams.updateParameter}
|
onParameterChange={base.params.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={base.endpointLoading}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("changePermissions.submit", "Change Permissions"),
|
text: t("changePermissions.submit", "Change Permissions"),
|
||||||
isVisible: !hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleChangePermissions,
|
onClick: base.handleExecute,
|
||||||
disabled: !changePermissionsParams.validateParameters() || !hasFiles || !endpointEnabled,
|
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: changePermissionsOperation,
|
operation: base.operation,
|
||||||
title: t("changePermissions.results.title", "Modified PDFs"),
|
title: t("changePermissions.results.title", "Modified PDFs"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,95 +1,56 @@
|
|||||||
import React, { use, useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
import { useFileSelection } from "../contexts/FileContext";
|
|
||||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||||
|
|
||||||
import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
|
import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
|
||||||
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
|
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
import { useCompressTips } from "../components/tooltips/useCompressTips";
|
import { useCompressTips } from "../components/tooltips/useCompressTips";
|
||||||
|
|
||||||
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Compress = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { actions } = useNavigationActions();
|
|
||||||
const { selectedFiles } = useFileSelection();
|
|
||||||
|
|
||||||
const compressParams = useCompressParameters();
|
|
||||||
const compressOperation = useCompressOperation();
|
|
||||||
const compressTips = useCompressTips();
|
const compressTips = useCompressTips();
|
||||||
|
|
||||||
// Endpoint validation
|
const base = useBaseTool(
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf");
|
'compress',
|
||||||
|
useCompressParameters,
|
||||||
useEffect(() => {
|
useCompressOperation,
|
||||||
compressOperation.resetResults();
|
props
|
||||||
onPreviewFile?.(null);
|
);
|
||||||
}, [compressParams.parameters]);
|
|
||||||
|
|
||||||
const handleCompress = async () => {
|
|
||||||
try {
|
|
||||||
await compressOperation.executeOperation(compressParams.parameters, selectedFiles);
|
|
||||||
if (compressOperation.files && onComplete) {
|
|
||||||
onComplete(compressOperation.files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (onError) {
|
|
||||||
onError(error instanceof Error ? error.message : "Compress operation failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
|
||||||
onPreviewFile?.(file);
|
|
||||||
sessionStorage.setItem("previousMode", "compress");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
|
||||||
compressOperation.resetResults();
|
|
||||||
onPreviewFile?.(null); };
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
|
||||||
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
|
|
||||||
const settingsCollapsed = !hasFiles || hasResults;
|
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: hasResults,
|
isCollapsed: base.hasResults,
|
||||||
},
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
isCollapsed: settingsCollapsed,
|
isCollapsed: base.settingsCollapsed,
|
||||||
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
|
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||||
tooltip: compressTips,
|
tooltip: compressTips,
|
||||||
content: (
|
content: (
|
||||||
<CompressSettings
|
<CompressSettings
|
||||||
parameters={compressParams.parameters}
|
parameters={base.params.parameters}
|
||||||
onParameterChange={compressParams.updateParameter}
|
onParameterChange={base.params.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={base.endpointLoading}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("compress.submit", "Compress"),
|
text: t("compress.submit", "Compress"),
|
||||||
isVisible: !hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleCompress,
|
onClick: base.handleExecute,
|
||||||
disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled,
|
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: compressOperation,
|
operation: base.operation,
|
||||||
title: t("compress.title", "Compression Results"),
|
title: t("compress.title", "Compression Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -93,6 +93,11 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await convertOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
@ -128,6 +133,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
operation: convertOperation,
|
operation: convertOperation,
|
||||||
title: t("convert.conversionResults", "Conversion Results"),
|
title: t("convert.conversionResults", "Conversion Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: handleThumbnailClick,
|
||||||
|
onUndo: handleUndo,
|
||||||
testId: "conversion-results",
|
testId: "conversion-results",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -74,6 +74,11 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await ocrOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
const settingsCollapsed = expandedStep !== "settings";
|
const settingsCollapsed = expandedStep !== "settings";
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
@ -132,6 +137,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
operation: ocrOperation,
|
operation: ocrOperation,
|
||||||
title: t("ocr.results.title", "OCR Results"),
|
title: t("ocr.results.title", "OCR Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: handleThumbnailClick,
|
||||||
|
onUndo: handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,78 +1,40 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
|
||||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
|
||||||
import { useFileSelection } from "../contexts/file/fileHooks";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
import { useRemoveCertificateSignParameters } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters";
|
import { useRemoveCertificateSignParameters } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters";
|
||||||
import { useRemoveCertificateSignOperation } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
import { useRemoveCertificateSignOperation } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
|
||||||
const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const RemoveCertificateSign = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { actions } = useNavigationActions();
|
|
||||||
const { selectedFiles } = useFileSelection();
|
|
||||||
|
|
||||||
const removeCertificateSignParams = useRemoveCertificateSignParameters();
|
const base = useBaseTool(
|
||||||
const removeCertificateSignOperation = useRemoveCertificateSignOperation();
|
'removeCertificateSign',
|
||||||
|
useRemoveCertificateSignParameters,
|
||||||
// Endpoint validation
|
useRemoveCertificateSignOperation,
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(removeCertificateSignParams.getEndpointName());
|
props
|
||||||
|
);
|
||||||
useEffect(() => {
|
|
||||||
removeCertificateSignOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
}, [removeCertificateSignParams.parameters]);
|
|
||||||
|
|
||||||
const handleRemoveSignature = async () => {
|
|
||||||
try {
|
|
||||||
await removeCertificateSignOperation.executeOperation(removeCertificateSignParams.parameters, selectedFiles);
|
|
||||||
if (removeCertificateSignOperation.files && onComplete) {
|
|
||||||
onComplete(removeCertificateSignOperation.files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (onError) {
|
|
||||||
onError(error instanceof Error ? error.message : t("removeCertSign.error.failed", "Remove certificate signature operation failed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
|
||||||
onPreviewFile?.(file);
|
|
||||||
sessionStorage.setItem("previousMode", "removeCertificateSign");
|
|
||||||
actions.setMode("viewer");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
|
||||||
removeCertificateSignOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
|
||||||
const hasResults = removeCertificateSignOperation.files.length > 0 || removeCertificateSignOperation.downloadUrl !== null;
|
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: hasFiles || hasResults,
|
isCollapsed: base.hasResults,
|
||||||
placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"),
|
placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("removeCertSign.submit", "Remove Signature"),
|
text: t("removeCertSign.submit", "Remove Signature"),
|
||||||
isVisible: !hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleRemoveSignature,
|
onClick: base.handleExecute,
|
||||||
disabled: !removeCertificateSignParams.validateParameters() || !hasFiles || !endpointEnabled,
|
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: removeCertificateSignOperation,
|
operation: base.operation,
|
||||||
title: t("removeCertSign.results.title", "Certificate Removal Results"),
|
title: t("removeCertSign.results.title", "Certificate Removal Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -80,4 +42,4 @@ const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolP
|
|||||||
// Static method to get the operation hook for automation
|
// Static method to get the operation hook for automation
|
||||||
RemoveCertificateSign.tool = () => useRemoveCertificateSignOperation;
|
RemoveCertificateSign.tool = () => useRemoveCertificateSignOperation;
|
||||||
|
|
||||||
export default RemoveCertificateSign as ToolComponent;
|
export default RemoveCertificateSign as ToolComponent;
|
||||||
|
@ -1,95 +1,56 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
import { useFileSelection } from "../contexts/FileContext";
|
|
||||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings";
|
import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings";
|
||||||
|
|
||||||
import { useRemovePasswordParameters } from "../hooks/tools/removePassword/useRemovePasswordParameters";
|
import { useRemovePasswordParameters } from "../hooks/tools/removePassword/useRemovePasswordParameters";
|
||||||
import { useRemovePasswordOperation } from "../hooks/tools/removePassword/useRemovePasswordOperation";
|
import { useRemovePasswordOperation } from "../hooks/tools/removePassword/useRemovePasswordOperation";
|
||||||
import { useRemovePasswordTips } from "../components/tooltips/useRemovePasswordTips";
|
import { useRemovePasswordTips } from "../components/tooltips/useRemovePasswordTips";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
|
||||||
const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const RemovePassword = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { actions } = useNavigationActions();
|
|
||||||
const { selectedFiles } = useFileSelection();
|
|
||||||
|
|
||||||
const removePasswordParams = useRemovePasswordParameters();
|
|
||||||
const removePasswordOperation = useRemovePasswordOperation();
|
|
||||||
const removePasswordTips = useRemovePasswordTips();
|
const removePasswordTips = useRemovePasswordTips();
|
||||||
|
|
||||||
// Endpoint validation
|
const base = useBaseTool(
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(removePasswordParams.getEndpointName());
|
'removePassword',
|
||||||
|
useRemovePasswordParameters,
|
||||||
|
useRemovePasswordOperation,
|
||||||
useEffect(() => {
|
props
|
||||||
removePasswordOperation.resetResults();
|
);
|
||||||
onPreviewFile?.(null);
|
|
||||||
}, [removePasswordParams.parameters]);
|
|
||||||
|
|
||||||
const handleRemovePassword = async () => {
|
|
||||||
try {
|
|
||||||
await removePasswordOperation.executeOperation(removePasswordParams.parameters, selectedFiles);
|
|
||||||
if (removePasswordOperation.files && onComplete) {
|
|
||||||
onComplete(removePasswordOperation.files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (onError) {
|
|
||||||
onError(error instanceof Error ? error.message : t("removePassword.error.failed", "Remove password operation failed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
|
||||||
onPreviewFile?.(file);
|
|
||||||
sessionStorage.setItem("previousMode", "removePassword");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
|
||||||
removePasswordOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
|
||||||
const hasResults = removePasswordOperation.files.length > 0 || removePasswordOperation.downloadUrl !== null;
|
|
||||||
const passwordCollapsed = !hasFiles || hasResults;
|
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: hasResults,
|
isCollapsed: base.hasResults,
|
||||||
},
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
title: t("removePassword.password.stepTitle", "Remove Password"),
|
title: t("removePassword.password.stepTitle", "Remove Password"),
|
||||||
isCollapsed: passwordCollapsed,
|
isCollapsed: base.settingsCollapsed,
|
||||||
onCollapsedClick: hasResults ? handleSettingsReset : undefined,
|
onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined,
|
||||||
tooltip: removePasswordTips,
|
tooltip: removePasswordTips,
|
||||||
content: (
|
content: (
|
||||||
<RemovePasswordSettings
|
<RemovePasswordSettings
|
||||||
parameters={removePasswordParams.parameters}
|
parameters={base.params.parameters}
|
||||||
onParameterChange={removePasswordParams.updateParameter}
|
onParameterChange={base.params.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={base.endpointLoading}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("removePassword.submit", "Remove Password"),
|
text: t("removePassword.submit", "Remove Password"),
|
||||||
isVisible: !hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleRemovePassword,
|
onClick: base.handleExecute,
|
||||||
disabled: !removePasswordParams.validateParameters() || !hasFiles || !endpointEnabled,
|
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: removePasswordOperation,
|
operation: base.operation,
|
||||||
title: t("removePassword.results.title", "Decrypted PDFs"),
|
title: t("removePassword.results.title", "Decrypted PDFs"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,78 +1,40 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
|
||||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
|
||||||
import { useFileSelection } from "../contexts/file/fileHooks";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
import { useRepairParameters } from "../hooks/tools/repair/useRepairParameters";
|
import { useRepairParameters } from "../hooks/tools/repair/useRepairParameters";
|
||||||
import { useRepairOperation } from "../hooks/tools/repair/useRepairOperation";
|
import { useRepairOperation } from "../hooks/tools/repair/useRepairOperation";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
|
||||||
const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Repair = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { actions } = useNavigationActions();
|
|
||||||
const { selectedFiles } = useFileSelection();
|
|
||||||
|
|
||||||
const repairParams = useRepairParameters();
|
const base = useBaseTool(
|
||||||
const repairOperation = useRepairOperation();
|
'repair',
|
||||||
|
useRepairParameters,
|
||||||
// Endpoint validation
|
useRepairOperation,
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(repairParams.getEndpointName());
|
props
|
||||||
|
);
|
||||||
useEffect(() => {
|
|
||||||
repairOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
}, [repairParams.parameters]);
|
|
||||||
|
|
||||||
const handleRepair = async () => {
|
|
||||||
try {
|
|
||||||
await repairOperation.executeOperation(repairParams.parameters, selectedFiles);
|
|
||||||
if (repairOperation.files && onComplete) {
|
|
||||||
onComplete(repairOperation.files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (onError) {
|
|
||||||
onError(error instanceof Error ? error.message : t("repair.error.failed", "Repair operation failed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
|
||||||
onPreviewFile?.(file);
|
|
||||||
sessionStorage.setItem("previousMode", "repair");
|
|
||||||
actions.setMode("viewer");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
|
||||||
repairOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
|
||||||
const hasResults = repairOperation.files.length > 0 || repairOperation.downloadUrl !== null;
|
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: hasResults,
|
isCollapsed: base.hasResults,
|
||||||
placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"),
|
placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("repair.submit", "Repair PDF"),
|
text: t("repair.submit", "Repair PDF"),
|
||||||
isVisible: !hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleRepair,
|
onClick: base.handleExecute,
|
||||||
disabled: !repairParams.validateParameters() || !hasFiles || !endpointEnabled,
|
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: repairOperation,
|
operation: base.operation,
|
||||||
title: t("repair.results.title", "Repair Results"),
|
title: t("repair.results.title", "Repair Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,90 +1,54 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
import { useFileSelection } from "../contexts/FileContext";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
||||||
|
|
||||||
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
|
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
|
||||||
import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation";
|
import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
|
||||||
const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Sanitize = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { selectedFiles } = useFileSelection();
|
const base = useBaseTool(
|
||||||
|
'sanitize',
|
||||||
const sanitizeParams = useSanitizeParameters();
|
useSanitizeParameters,
|
||||||
const sanitizeOperation = useSanitizeOperation();
|
useSanitizeOperation,
|
||||||
|
props
|
||||||
// Endpoint validation
|
);
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(sanitizeParams.getEndpointName());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
sanitizeOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
}, [sanitizeParams.parameters]);
|
|
||||||
|
|
||||||
const handleSanitize = async () => {
|
|
||||||
try {
|
|
||||||
await sanitizeOperation.executeOperation(sanitizeParams.parameters, selectedFiles);
|
|
||||||
if (sanitizeOperation.files && onComplete) {
|
|
||||||
onComplete(sanitizeOperation.files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (onError) {
|
|
||||||
onError(error instanceof Error ? error.message : t("sanitize.error.generic", "Sanitization failed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
|
||||||
sanitizeOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
|
||||||
onPreviewFile?.(file);
|
|
||||||
sessionStorage.setItem("previousMode", "sanitize");
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
|
||||||
const hasResults = sanitizeOperation.files.length > 0;
|
|
||||||
const settingsCollapsed = !hasFiles || hasResults;
|
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: hasResults,
|
isCollapsed: base.hasResults,
|
||||||
placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
|
placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
title: t("sanitize.steps.settings", "Settings"),
|
title: t("sanitize.steps.settings", "Settings"),
|
||||||
isCollapsed: settingsCollapsed,
|
isCollapsed: base.settingsCollapsed,
|
||||||
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
|
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||||
content: (
|
content: (
|
||||||
<SanitizeSettings
|
<SanitizeSettings
|
||||||
parameters={sanitizeParams.parameters}
|
parameters={base.params.parameters}
|
||||||
onParameterChange={sanitizeParams.updateParameter}
|
onParameterChange={base.params.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={base.endpointLoading}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("sanitize.submit", "Sanitize PDF"),
|
text: t("sanitize.submit", "Sanitize PDF"),
|
||||||
isVisible: !hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleSanitize,
|
onClick: base.handleExecute,
|
||||||
disabled: !sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled,
|
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: sanitizeOperation,
|
operation: base.operation,
|
||||||
title: t("sanitize.sanitizationResults", "Sanitization Results"),
|
title: t("sanitize.sanitizationResults", "Sanitization Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,78 +1,40 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
|
||||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
|
||||||
import { useFileSelection } from "../contexts/file/fileHooks";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
import { useSingleLargePageParameters } from "../hooks/tools/singleLargePage/useSingleLargePageParameters";
|
import { useSingleLargePageParameters } from "../hooks/tools/singleLargePage/useSingleLargePageParameters";
|
||||||
import { useSingleLargePageOperation } from "../hooks/tools/singleLargePage/useSingleLargePageOperation";
|
import { useSingleLargePageOperation } from "../hooks/tools/singleLargePage/useSingleLargePageOperation";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
|
||||||
const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const SingleLargePage = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { actions } = useNavigationActions();
|
|
||||||
const { selectedFiles } = useFileSelection();
|
|
||||||
|
|
||||||
const singleLargePageParams = useSingleLargePageParameters();
|
const base = useBaseTool(
|
||||||
const singleLargePageOperation = useSingleLargePageOperation();
|
'singleLargePage',
|
||||||
|
useSingleLargePageParameters,
|
||||||
// Endpoint validation
|
useSingleLargePageOperation,
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(singleLargePageParams.getEndpointName());
|
props
|
||||||
|
);
|
||||||
useEffect(() => {
|
|
||||||
singleLargePageOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
}, [singleLargePageParams.parameters]);
|
|
||||||
|
|
||||||
const handleConvert = async () => {
|
|
||||||
try {
|
|
||||||
await singleLargePageOperation.executeOperation(singleLargePageParams.parameters, selectedFiles);
|
|
||||||
if (singleLargePageOperation.files && onComplete) {
|
|
||||||
onComplete(singleLargePageOperation.files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (onError) {
|
|
||||||
onError(error instanceof Error ? error.message : t("pdfToSinglePage.error.failed", "Single large page operation failed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
|
||||||
onPreviewFile?.(file);
|
|
||||||
sessionStorage.setItem("previousMode", "single-large-page");
|
|
||||||
actions.setMode("viewer");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
|
||||||
singleLargePageOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
|
||||||
const hasResults = singleLargePageOperation.files.length > 0 || singleLargePageOperation.downloadUrl !== null;
|
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: hasFiles || hasResults,
|
isCollapsed: base.hasResults,
|
||||||
placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"),
|
placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("pdfToSinglePage.submit", "Convert To Single Page"),
|
text: t("pdfToSinglePage.submit", "Convert To Single Page"),
|
||||||
isVisible: !hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleConvert,
|
onClick: base.handleExecute,
|
||||||
disabled: !singleLargePageParams.validateParameters() || !hasFiles || !endpointEnabled,
|
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: singleLargePageOperation,
|
operation: base.operation,
|
||||||
title: t("pdfToSinglePage.results.title", "Single Page Results"),
|
title: t("pdfToSinglePage.results.title", "Single Page Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -80,4 +42,4 @@ const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps)
|
|||||||
// Static method to get the operation hook for automation
|
// Static method to get the operation hook for automation
|
||||||
SingleLargePage.tool = () => useSingleLargePageOperation;
|
SingleLargePage.tool = () => useSingleLargePageOperation;
|
||||||
|
|
||||||
export default SingleLargePage as ToolComponent;
|
export default SingleLargePage as ToolComponent;
|
||||||
|
@ -1,84 +1,37 @@
|
|||||||
import React, { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
import { useFileSelection } from "../contexts/FileContext";
|
|
||||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||||
|
|
||||||
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
|
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
|
||||||
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
|
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
|
||||||
const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Split = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { actions } = useNavigationActions();
|
|
||||||
const { selectedFiles } = useFileSelection();
|
|
||||||
|
|
||||||
const splitParams = useSplitParameters();
|
const base = useBaseTool(
|
||||||
const splitOperation = useSplitOperation();
|
'split',
|
||||||
|
useSplitParameters,
|
||||||
// Endpoint validation
|
useSplitOperation,
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(splitParams.getEndpointName());
|
props
|
||||||
|
);
|
||||||
useEffect(() => {
|
|
||||||
// Only reset results when parameters change, not when files change
|
|
||||||
splitOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
}, [splitParams.parameters]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset results when selected files change (user selected different files)
|
|
||||||
if (selectedFiles.length > 0) {
|
|
||||||
splitOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
}
|
|
||||||
}, [selectedFiles]);
|
|
||||||
|
|
||||||
const handleSplit = async () => {
|
|
||||||
try {
|
|
||||||
await splitOperation.executeOperation(splitParams.parameters, selectedFiles);
|
|
||||||
if (splitOperation.files && onComplete) {
|
|
||||||
onComplete(splitOperation.files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (onError) {
|
|
||||||
onError(error instanceof Error ? error.message : "Split operation failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
|
||||||
onPreviewFile?.(file);
|
|
||||||
sessionStorage.setItem("previousMode", "split");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
|
||||||
splitOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
|
||||||
const hasResults = splitOperation.files.length > 0 || splitOperation.downloadUrl !== null;
|
|
||||||
const settingsCollapsed = !hasFiles || hasResults;
|
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: hasResults,
|
isCollapsed: base.hasResults,
|
||||||
placeholder: "Select a PDF file in the main view to get started",
|
|
||||||
},
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
isCollapsed: settingsCollapsed,
|
isCollapsed: base.settingsCollapsed,
|
||||||
onCollapsedClick: hasResults ? handleSettingsReset : undefined,
|
onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined,
|
||||||
content: (
|
content: (
|
||||||
<SplitSettings
|
<SplitSettings
|
||||||
parameters={splitParams.parameters}
|
parameters={base.params.parameters}
|
||||||
onParameterChange={splitParams.updateParameter}
|
onParameterChange={base.params.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={base.endpointLoading}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -86,15 +39,16 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("split.submit", "Split PDF"),
|
text: t("split.submit", "Split PDF"),
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleSplit,
|
onClick: base.handleExecute,
|
||||||
isVisible: !hasResults,
|
isVisible: !base.hasResults,
|
||||||
disabled: !splitParams.validateParameters() || !hasFiles || !endpointEnabled,
|
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: splitOperation,
|
operation: base.operation,
|
||||||
title: "Split Results",
|
title: "Split Results",
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,78 +1,40 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
|
||||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
|
||||||
import { useFileSelection } from "../contexts/file/fileHooks";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
import { useUnlockPdfFormsParameters } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters";
|
import { useUnlockPdfFormsParameters } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters";
|
||||||
import { useUnlockPdfFormsOperation } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
|
import { useUnlockPdfFormsOperation } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
|
||||||
const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const UnlockPdfForms = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { actions } = useNavigationActions();
|
|
||||||
const { selectedFiles } = useFileSelection();
|
|
||||||
|
|
||||||
const unlockPdfFormsParams = useUnlockPdfFormsParameters();
|
const base = useBaseTool(
|
||||||
const unlockPdfFormsOperation = useUnlockPdfFormsOperation();
|
'unlockPdfForms',
|
||||||
|
useUnlockPdfFormsParameters,
|
||||||
// Endpoint validation
|
useUnlockPdfFormsOperation,
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(unlockPdfFormsParams.getEndpointName());
|
props
|
||||||
|
);
|
||||||
useEffect(() => {
|
|
||||||
unlockPdfFormsOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
}, [unlockPdfFormsParams.parameters]);
|
|
||||||
|
|
||||||
const handleUnlock = async () => {
|
|
||||||
try {
|
|
||||||
await unlockPdfFormsOperation.executeOperation(unlockPdfFormsParams.parameters, selectedFiles);
|
|
||||||
if (unlockPdfFormsOperation.files && onComplete) {
|
|
||||||
onComplete(unlockPdfFormsOperation.files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (onError) {
|
|
||||||
onError(error instanceof Error ? error.message : t("unlockPDFForms.error.failed", "Unlock PDF forms operation failed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
|
||||||
onPreviewFile?.(file);
|
|
||||||
sessionStorage.setItem("previousMode", "unlockPdfForms");
|
|
||||||
actions.setMode("viewer");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
|
||||||
unlockPdfFormsOperation.resetResults();
|
|
||||||
onPreviewFile?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
|
||||||
const hasResults = unlockPdfFormsOperation.files.length > 0 || unlockPdfFormsOperation.downloadUrl !== null;
|
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: hasFiles || hasResults,
|
isCollapsed: base.hasFiles || base.hasResults,
|
||||||
placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"),
|
placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
text: t("unlockPDFForms.submit", "Unlock Forms"),
|
text: t("unlockPDFForms.submit", "Unlock Forms"),
|
||||||
isVisible: !hasResults,
|
isVisible: !base.hasResults,
|
||||||
loadingText: t("loading"),
|
loadingText: t("loading"),
|
||||||
onClick: handleUnlock,
|
onClick: base.handleExecute,
|
||||||
disabled: !unlockPdfFormsParams.validateParameters() || !hasFiles || !endpointEnabled,
|
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: unlockPdfFormsOperation,
|
operation: base.operation,
|
||||||
title: t("unlockPDFForms.results.title", "Unlocked Forms Results"),
|
title: t("unlockPDFForms.results.title", "Unlocked Forms Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -80,4 +42,4 @@ const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
|||||||
// Static method to get the operation hook for automation
|
// Static method to get the operation hook for automation
|
||||||
UnlockPdfForms.tool = () => useUnlockPdfFormsOperation;
|
UnlockPdfForms.tool = () => useUnlockPdfFormsOperation;
|
||||||
|
|
||||||
export default UnlockPdfForms as ToolComponent;
|
export default UnlockPdfForms as ToolComponent;
|
||||||
|
@ -101,32 +101,32 @@ export function isFileWithId(file: File): file is FileWithId {
|
|||||||
export function createFileWithId(file: File, id?: FileId): FileWithId {
|
export function createFileWithId(file: File, id?: FileId): FileWithId {
|
||||||
const fileId = id || createFileId();
|
const fileId = id || createFileId();
|
||||||
const quickKey = createQuickKey(file);
|
const quickKey = createQuickKey(file);
|
||||||
|
|
||||||
const newFile = new File([file], file.name, {
|
const newFile = new File([file], file.name, {
|
||||||
type: file.type,
|
type: file.type,
|
||||||
lastModified: file.lastModified
|
lastModified: file.lastModified
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.defineProperty(newFile, 'fileId', {
|
Object.defineProperty(newFile, 'fileId', {
|
||||||
value: fileId,
|
value: fileId,
|
||||||
writable: false,
|
writable: false,
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
configurable: false
|
configurable: false
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.defineProperty(newFile, 'quickKey', {
|
Object.defineProperty(newFile, 'quickKey', {
|
||||||
value: quickKey,
|
value: quickKey,
|
||||||
writable: false,
|
writable: false,
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
configurable: false
|
configurable: false
|
||||||
});
|
});
|
||||||
|
|
||||||
return newFile as FileWithId;
|
return newFile as FileWithId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap array of Files with FileIds
|
// Wrap array of Files with FileIds
|
||||||
export function wrapFilesWithIds(files: File[], ids?: FileId[]): FileWithId[] {
|
export function wrapFilesWithIds(files: File[], ids?: FileId[]): FileWithId[] {
|
||||||
return files.map((file, index) =>
|
return files.map((file, index) =>
|
||||||
createFileWithId(file, ids?.[index])
|
createFileWithId(file, ids?.[index])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -144,7 +144,7 @@ export function extractFiles(files: FileWithId[]): File[] {
|
|||||||
|
|
||||||
// Check if an object is a File or FileWithId (replaces instanceof File checks)
|
// Check if an object is a File or FileWithId (replaces instanceof File checks)
|
||||||
export function isFileObject(obj: any): obj is File | FileWithId {
|
export function isFileObject(obj: any): obj is File | FileWithId {
|
||||||
return obj &&
|
return obj &&
|
||||||
typeof obj.name === 'string' &&
|
typeof obj.name === 'string' &&
|
||||||
typeof obj.size === 'number' &&
|
typeof obj.size === 'number' &&
|
||||||
typeof obj.type === 'string' &&
|
typeof obj.type === 'string' &&
|
||||||
@ -172,12 +172,12 @@ export function isDangerousFileNameAsId(fileName: string, context: string = ''):
|
|||||||
if (isValidFileId(fileName)) {
|
if (isValidFileId(fileName)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a quickKey (safe) - format: name|size|lastModified
|
// Check if it's a quickKey (safe) - format: name|size|lastModified
|
||||||
if (/^.+\|\d+\|\d+$/.test(fileName)) {
|
if (/^.+\|\d+\|\d+$/.test(fileName)) {
|
||||||
return false; // quickKeys are legitimate, not dangerous
|
return false; // quickKeys are legitimate, not dangerous
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common patterns that suggest file.name is being used as ID
|
// Common patterns that suggest file.name is being used as ID
|
||||||
const dangerousPatterns = [
|
const dangerousPatterns = [
|
||||||
/^[^-]+-page-\d+$/, // pattern: filename-page-123
|
/^[^-]+-page-\d+$/, // pattern: filename-page-123
|
||||||
@ -187,14 +187,14 @@ export function isDangerousFileNameAsId(fileName: string, context: string = ''):
|
|||||||
/['"]/, // contains quotes
|
/['"]/, // contains quotes
|
||||||
/[^a-zA-Z0-9\-._]/ // contains special characters not in UUIDs
|
/[^a-zA-Z0-9\-._]/ // contains special characters not in UUIDs
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check dangerous patterns
|
// Check dangerous patterns
|
||||||
const isDangerous = dangerousPatterns.some(pattern => pattern.test(fileName));
|
const isDangerous = dangerousPatterns.some(pattern => pattern.test(fileName));
|
||||||
|
|
||||||
if (isDangerous && context) {
|
if (isDangerous && context) {
|
||||||
console.warn(`⚠️ Potentially dangerous file.name usage detected in ${context}: "${fileName}"`);
|
console.warn(`⚠️ Potentially dangerous file.name usage detected in ${context}: "${fileName}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return isDangerous;
|
return isDangerous;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,7 +203,7 @@ export function safeGetFileId(file: File, context: string = ''): FileId {
|
|||||||
if (isFileWithId(file)) {
|
if (isFileWithId(file)) {
|
||||||
return file.fileId;
|
return file.fileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we reach here, someone is trying to use a regular File without embedded ID
|
// If we reach here, someone is trying to use a regular File without embedded ID
|
||||||
throw new Error(`Attempted to get FileId from regular File object in ${context}. Use FileWithId instead.`);
|
throw new Error(`Attempted to get FileId from regular File object in ${context}. Use FileWithId instead.`);
|
||||||
}
|
}
|
||||||
@ -304,10 +304,10 @@ export interface FileContextState {
|
|||||||
ids: FileId[];
|
ids: FileId[];
|
||||||
byId: Record<FileId, FileRecord>;
|
byId: Record<FileId, FileRecord>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pinned files - files that won't be consumed by tools
|
// Pinned files - files that won't be consumed by tools
|
||||||
pinnedFiles: Set<FileId>;
|
pinnedFiles: Set<FileId>;
|
||||||
|
|
||||||
// UI state - file-related UI state only
|
// UI state - file-related UI state only
|
||||||
ui: {
|
ui: {
|
||||||
selectedFileIds: FileId[];
|
selectedFileIds: FileId[];
|
||||||
@ -319,35 +319,36 @@ export interface FileContextState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Action types for reducer pattern
|
// Action types for reducer pattern
|
||||||
export type FileContextAction =
|
export type FileContextAction =
|
||||||
// File management actions
|
// File management actions
|
||||||
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
|
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
|
||||||
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
||||||
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
|
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
|
||||||
| { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } }
|
| { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } }
|
||||||
|
|
||||||
// Pinned files actions
|
// Pinned files actions
|
||||||
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
|
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
|
||||||
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
|
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
|
||||||
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
|
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
|
||||||
|
| { type: 'UNDO_CONSUME_FILES'; payload: { inputFileRecords: FileRecord[]; outputFileIds: FileId[] } }
|
||||||
// UI actions
|
|
||||||
|
// UI actions
|
||||||
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
|
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
|
||||||
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
|
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
|
||||||
| { type: 'CLEAR_SELECTIONS' }
|
| { type: 'CLEAR_SELECTIONS' }
|
||||||
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
|
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
|
||||||
|
|
||||||
// Navigation guard actions (minimal for file-related unsaved changes only)
|
// Navigation guard actions (minimal for file-related unsaved changes only)
|
||||||
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||||
|
|
||||||
// Context management
|
// Context management
|
||||||
| { type: 'RESET_CONTEXT' };
|
| { type: 'RESET_CONTEXT' };
|
||||||
|
|
||||||
export interface FileContextActions {
|
export interface FileContextActions {
|
||||||
// File management - lightweight actions only
|
// File management - lightweight actions only
|
||||||
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<FileWithId[]>;
|
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<FileWithId[]>;
|
||||||
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<FileWithId[]>;
|
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<FileWithId[]>;
|
||||||
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<FileWithId[]>;
|
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<FileWithId[]>;
|
||||||
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
|
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
|
||||||
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
|
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
|
||||||
reorderFiles: (orderedFileIds: FileId[]) => void;
|
reorderFiles: (orderedFileIds: FileId[]) => void;
|
||||||
@ -358,22 +359,23 @@ export interface FileContextActions {
|
|||||||
pinFile: (file: FileWithId) => void;
|
pinFile: (file: FileWithId) => void;
|
||||||
unpinFile: (file: FileWithId) => void;
|
unpinFile: (file: FileWithId) => void;
|
||||||
|
|
||||||
// File consumption (replace unpinned files with outputs) - now returns FileWithId
|
// File consumption (replace unpinned files with outputs)
|
||||||
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileWithId[]>;
|
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>;
|
||||||
|
undoConsumeFiles: (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]) => Promise<void>;
|
||||||
// Selection management
|
// Selection management
|
||||||
setSelectedFiles: (fileIds: FileId[]) => void;
|
setSelectedFiles: (fileIds: FileId[]) => void;
|
||||||
setSelectedPages: (pageNumbers: number[]) => void;
|
setSelectedPages: (pageNumbers: number[]) => void;
|
||||||
clearSelections: () => void;
|
clearSelections: () => void;
|
||||||
|
|
||||||
// Processing state - simple flags only
|
// Processing state - simple flags only
|
||||||
setProcessing: (isProcessing: boolean, progress?: number) => void;
|
setProcessing: (isProcessing: boolean, progress?: number) => void;
|
||||||
|
|
||||||
// File-related unsaved changes (minimal navigation guard support)
|
// File-related unsaved changes (minimal navigation guard support)
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||||
|
|
||||||
// Context management
|
// Context management
|
||||||
resetContext: () => void;
|
resetContext: () => void;
|
||||||
|
|
||||||
// Resource management
|
// Resource management
|
||||||
trackBlobUrl: (url: string) => void;
|
trackBlobUrl: (url: string) => void;
|
||||||
scheduleCleanup: (fileId: FileId, delay?: number) => void;
|
scheduleCleanup: (fileId: FileId, delay?: number) => void;
|
||||||
@ -393,7 +395,7 @@ export interface FileContextSelectors {
|
|||||||
getPinnedFiles: () => FileWithId[];
|
getPinnedFiles: () => FileWithId[];
|
||||||
getPinnedFileRecords: () => FileRecord[];
|
getPinnedFileRecords: () => FileRecord[];
|
||||||
isFilePinned: (file: FileWithId) => boolean;
|
isFilePinned: (file: FileWithId) => boolean;
|
||||||
|
|
||||||
// Stable signature for effect dependencies
|
// Stable signature for effect dependencies
|
||||||
getFilesSignature: () => string;
|
getFilesSignature: () => string;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user