mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 09:29:24 +00:00
Add Merge UI to V2 (#4235)
# Description of Changes Add UI for Merge into V2.
This commit is contained in:
parent
9d723eae69
commit
f3fd85d777
@ -51,11 +51,11 @@
|
||||
"filesSelected": "{{count}} files selected",
|
||||
"files": {
|
||||
"title": "Files",
|
||||
"placeholder": "Select a PDF file in the main view to get started",
|
||||
"upload": "Upload",
|
||||
"uploadFiles": "Upload Files",
|
||||
"addFiles": "Add files",
|
||||
"selectFromWorkbench": "Select files from the workbench or "
|
||||
"selectFromWorkbench": "Select files from the workbench or ",
|
||||
"selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or "
|
||||
},
|
||||
"noFavourites": "No favourites added",
|
||||
"downloadComplete": "Download Complete",
|
||||
@ -644,11 +644,29 @@
|
||||
"merge": {
|
||||
"tags": "merge,Page operations,Back end,server side",
|
||||
"title": "Merge",
|
||||
"header": "Merge multiple PDFs (2+)",
|
||||
"sortByName": "Sort by name",
|
||||
"sortByDate": "Sort by date",
|
||||
"removeCertSign": "Remove digital signature in the merged file?",
|
||||
"submit": "Merge"
|
||||
"removeDigitalSignature": "Remove digital signature in the merged file?",
|
||||
"generateTableOfContents": "Generate table of contents in the merged file?",
|
||||
"removeDigitalSignature.tooltip": {
|
||||
"title": "Remove Digital Signature",
|
||||
"description": "Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF."
|
||||
},
|
||||
"generateTableOfContents.tooltip": {
|
||||
"title": "Generate Table of Contents",
|
||||
"description": "Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers."
|
||||
},
|
||||
"submit": "Merge",
|
||||
"sortBy": {
|
||||
"description": "Files will be merged in the order they're selected. Drag to reorder or sort below.",
|
||||
"label": "Sort By",
|
||||
"filename": "File Name",
|
||||
"dateModified": "Date Modified",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending",
|
||||
"sort": "Sort"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while merging the PDFs."
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
"tags": "Page operations,divide,Multi Page,cut,server side",
|
||||
|
182
frontend/src/components/tools/merge/MergeFileSorter.test.tsx
Normal file
182
frontend/src/components/tools/merge/MergeFileSorter.test.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import MergeFileSorter from './MergeFileSorter';
|
||||
|
||||
// Mock useTranslation with predictable return values
|
||||
const mockT = vi.fn((key: string) => `mock-${key}`);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('MergeFileSorter', () => {
|
||||
const mockOnSortFiles = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render sort options dropdown, direction toggle, and sort button', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should have a select dropdown (Mantine Select uses textbox role)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
|
||||
// Should have direction toggle button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2); // ActionIcon + Sort Button
|
||||
|
||||
// Should have sort button with text
|
||||
expect(screen.getByText('mock-merge.sortBy.sort')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render description text', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should have filename selected by default', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('textbox');
|
||||
expect(select).toHaveValue('mock-merge.sortBy.filename');
|
||||
});
|
||||
|
||||
test('should show ascending direction by default', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should show ascending arrow icon
|
||||
const directionButton = screen.getAllByRole('button')[0];
|
||||
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
|
||||
});
|
||||
|
||||
test('should toggle direction when direction button is clicked', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const directionButton = screen.getAllByRole('button')[0];
|
||||
|
||||
// Initially ascending
|
||||
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
|
||||
|
||||
// Click to toggle to descending
|
||||
fireEvent.click(directionButton);
|
||||
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.descending');
|
||||
|
||||
// Click again to toggle back to ascending
|
||||
fireEvent.click(directionButton);
|
||||
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
|
||||
});
|
||||
|
||||
test('should call onSortFiles with correct parameters when sort button is clicked', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const sortButton = screen.getByText('mock-merge.sortBy.sort');
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
// Should be called with default values (filename, ascending)
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('filename', true);
|
||||
});
|
||||
|
||||
test('should call onSortFiles with dateModified when dropdown is changed', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Open the dropdown by clicking on the current selected value
|
||||
const currentSelection = screen.getByText('mock-merge.sortBy.filename');
|
||||
fireEvent.mouseDown(currentSelection);
|
||||
|
||||
// Click on the dateModified option
|
||||
const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
|
||||
fireEvent.click(dateModifiedOption);
|
||||
|
||||
const sortButton = screen.getByText('mock-merge.sortBy.sort');
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
|
||||
});
|
||||
|
||||
test('should call onSortFiles with descending direction when toggled', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const directionButton = screen.getAllByRole('button')[0];
|
||||
const sortButton = screen.getByText('mock-merge.sortBy.sort');
|
||||
|
||||
// Toggle to descending
|
||||
fireEvent.click(directionButton);
|
||||
|
||||
// Click sort
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('filename', false);
|
||||
});
|
||||
|
||||
test('should handle complex user interaction sequence', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const directionButton = screen.getAllByRole('button')[0];
|
||||
const sortButton = screen.getByText('mock-merge.sortBy.sort');
|
||||
|
||||
// 1. Change to dateModified
|
||||
const currentSelection = screen.getByText('mock-merge.sortBy.filename');
|
||||
fireEvent.mouseDown(currentSelection);
|
||||
const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
|
||||
fireEvent.click(dateModifiedOption);
|
||||
|
||||
// 2. Toggle to descending
|
||||
fireEvent.click(directionButton);
|
||||
|
||||
// 3. Click sort
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', false);
|
||||
|
||||
// 4. Toggle back to ascending
|
||||
fireEvent.click(directionButton);
|
||||
|
||||
// 5. Sort again
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
|
||||
});
|
||||
});
|
77
frontend/src/components/tools/merge/MergeFileSorter.tsx
Normal file
77
frontend/src/components/tools/merge/MergeFileSorter.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Group, Button, Text, ActionIcon, Stack, Select } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SortIcon from '@mui/icons-material/Sort';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
|
||||
interface MergeFileSorterProps {
|
||||
onSortFiles: (sortType: 'filename' | 'dateModified', ascending: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MergeFileSorter: React.FC<MergeFileSorterProps> = ({
|
||||
onSortFiles,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [sortType, setSortType] = useState<'filename' | 'dateModified'>('filename');
|
||||
const [ascending, setAscending] = useState(true);
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'filename', label: t('merge.sortBy.filename', 'File Name') },
|
||||
{ value: 'dateModified', label: t('merge.sortBy.dateModified', 'Date Modified') },
|
||||
];
|
||||
|
||||
const handleSort = () => {
|
||||
onSortFiles(sortType, ascending);
|
||||
};
|
||||
|
||||
const handleDirectionToggle = () => {
|
||||
setAscending(!ascending);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('merge.sortBy.description', "Files will be merged in the order they're selected. Drag to reorder or sort below.")}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="end" justify="space-between">
|
||||
<Select
|
||||
data={sortOptions}
|
||||
value={sortType}
|
||||
onChange={(value) => setSortType(value as 'filename' | 'dateModified')}
|
||||
disabled={disabled}
|
||||
label={t('merge.sortBy.label', 'Sort By')}
|
||||
size='xs'
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="md"
|
||||
onClick={handleDirectionToggle}
|
||||
disabled={disabled}
|
||||
title={ascending ? t('merge.sortBy.ascending', 'Ascending') : t('merge.sortBy.descending', 'Descending')}
|
||||
>
|
||||
{ascending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<SortIcon />}
|
||||
onClick={handleSort}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
>
|
||||
{t('merge.sortBy.sort', 'Sort')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MergeFileSorter;
|
100
frontend/src/components/tools/merge/MergeSettings.test.tsx
Normal file
100
frontend/src/components/tools/merge/MergeSettings.test.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import MergeSettings from './MergeSettings';
|
||||
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
|
||||
|
||||
// Mock useTranslation with predictable return values
|
||||
const mockT = vi.fn((key: string) => `mock-${key}`);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('MergeSettings', () => {
|
||||
const defaultParameters: MergeParameters = {
|
||||
removeDigitalSignature: false,
|
||||
generateTableOfContents: false,
|
||||
};
|
||||
|
||||
const mockOnParameterChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render both merge option checkboxes', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should render one checkbox for each parameter
|
||||
const expectedCheckboxCount = Object.keys(defaultParameters).length;
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(expectedCheckboxCount);
|
||||
});
|
||||
|
||||
test('should show correct initial checkbox states based on parameters', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
|
||||
// Both checkboxes should be unchecked initially
|
||||
checkboxes.forEach(checkbox => {
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call onParameterChange with correct parameters when checkboxes are clicked', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
|
||||
// Click the first checkbox (removeDigitalSignature - should toggle from false to true)
|
||||
fireEvent.click(checkboxes[0]);
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith('removeDigitalSignature', true);
|
||||
|
||||
// Click the second checkbox (generateTableOfContents - should toggle from false to true)
|
||||
fireEvent.click(checkboxes[1]);
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith('generateTableOfContents', true);
|
||||
});
|
||||
|
||||
test('should call translation function with correct keys', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Verify that translation keys are being called
|
||||
expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature', 'Remove digital signature in the merged file?');
|
||||
expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents', 'Generate table of contents in the merged file?');
|
||||
});
|
||||
|
||||
});
|
38
frontend/src/components/tools/merge/MergeSettings.tsx
Normal file
38
frontend/src/components/tools/merge/MergeSettings.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Stack, Checkbox } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
|
||||
|
||||
interface MergeSettingsProps {
|
||||
parameters: MergeParameters;
|
||||
onParameterChange: <K extends keyof MergeParameters>(key: K, value: MergeParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MergeSettings: React.FC<MergeSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Checkbox
|
||||
label={t('merge.removeDigitalSignature', 'Remove digital signature in the merged file?')}
|
||||
checked={parameters.removeDigitalSignature}
|
||||
onChange={(event) => onParameterChange('removeDigitalSignature', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label={t('merge.generateTableOfContents', 'Generate table of contents in the merged file?')}
|
||||
checked={parameters.generateTableOfContents}
|
||||
onChange={(event) => onParameterChange('generateTableOfContents', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MergeSettings;
|
@ -10,11 +10,12 @@ import { StirlingFile } from "../../../types/fileContext";
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
selectedFiles?: StirlingFile[];
|
||||
placeholder?: string;
|
||||
minFiles?: number;
|
||||
}
|
||||
|
||||
const FileStatusIndicator = ({
|
||||
selectedFiles = [],
|
||||
minFiles = 1,
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||
@ -55,6 +56,14 @@ const FileStatusIndicator = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (minFiles === undefined || minFiles === 1) {
|
||||
return t("files.selectFromWorkbench", "Select files from the workbench or ");
|
||||
} else {
|
||||
return t("files.selectMultipleFromWorkbench", "Select at least {{count}} files from the workbench or ", { count: minFiles });
|
||||
}
|
||||
};
|
||||
|
||||
// Check if there are no files in the workbench
|
||||
if (stirlingFileStubs.length === 0) {
|
||||
// If no recent files, show upload button
|
||||
@ -89,12 +98,12 @@ const FileStatusIndicator = ({
|
||||
}
|
||||
|
||||
// Show selection status when there are files in workbench
|
||||
if (selectedFiles.length === 0) {
|
||||
if (selectedFiles.length < minFiles) {
|
||||
// If no recent files, show upload option
|
||||
if (!hasRecentFiles) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||
{getPlaceholder() + " "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={handleNativeUpload}
|
||||
@ -109,7 +118,7 @@ const FileStatusIndicator = ({
|
||||
// If there are recent files, show add files option
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||
{getPlaceholder() + " "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => openFilesModal()}
|
||||
@ -125,7 +134,7 @@ const FileStatusIndicator = ({
|
||||
|
||||
return (
|
||||
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
|
||||
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
@ -7,7 +7,7 @@ export interface FilesToolStepProps {
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
placeholder?: string;
|
||||
minFiles?: number;
|
||||
}
|
||||
|
||||
export function createFilesToolStep(
|
||||
@ -23,7 +23,7 @@ export function createFilesToolStep(
|
||||
}, (
|
||||
<FileStatusIndicator
|
||||
selectedFiles={props.selectedFiles}
|
||||
placeholder={props.placeholder || t("files.placeholder", "Select a PDF file in the main view to get started")}
|
||||
minFiles={props.minFiles}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import { StirlingFile } from '../../../types/fileContext';
|
||||
export interface FilesStepConfig {
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
placeholder?: string;
|
||||
minFiles?: number;
|
||||
onCollapsedClick?: () => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
@ -76,7 +76,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
{config.files.isVisible !== false && steps.createFilesStep({
|
||||
selectedFiles: config.files.selectedFiles,
|
||||
isCollapsed: config.files.isCollapsed,
|
||||
placeholder: config.files.placeholder,
|
||||
minFiles: config.files.minFiles,
|
||||
onCollapsedClick: config.files.onCollapsedClick
|
||||
})}
|
||||
|
||||
|
19
frontend/src/components/tooltips/useMergeTips.tsx
Normal file
19
frontend/src/components/tooltips/useMergeTips.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useMergeTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
tips: [
|
||||
{
|
||||
title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
|
||||
description: t('merge.removeDigitalSignature.tooltip.description', 'Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF.')
|
||||
},
|
||||
{
|
||||
title: t('merge.generateTableOfContents.tooltip.title', 'Generate Table of Contents'),
|
||||
description: t('merge.generateTableOfContents.tooltip.description', 'Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers.')
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
@ -145,12 +145,17 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
|
||||
// Validate that all IDs exist in current state
|
||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||
|
||||
// Reorder selected files by passed order
|
||||
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
ids: validIds
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds,
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -234,11 +239,14 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
|
||||
case 'CONSUME_FILES': {
|
||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||
|
||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||
}
|
||||
|
||||
|
||||
case 'UNDO_CONSUME_FILES': {
|
||||
const { inputStirlingFileStubs, outputFileIds } = action.payload;
|
||||
|
||||
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
|
||||
}
|
||||
|
||||
|
@ -136,13 +136,13 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
|
||||
/**
|
||||
* Hook for selected files (optimized for selection-based UI)
|
||||
*/
|
||||
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getSelectedFiles(),
|
||||
records: selectors.getSelectedStirlingFileStubs(),
|
||||
fileIds: state.ui.selectedFileIds
|
||||
selectedFiles: selectors.getSelectedFiles(),
|
||||
selectedRecords: selectors.getSelectedStirlingFileStubs(),
|
||||
selectedFileIds: state.ui.selectedFileIds
|
||||
}), [state.ui.selectedFileIds, selectors]);
|
||||
}
|
||||
|
||||
@ -169,7 +169,6 @@ export function useFileContext() {
|
||||
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 => {
|
||||
|
@ -11,6 +11,7 @@ import ChangePermissions from "../tools/ChangePermissions";
|
||||
import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
import Merge from '../tools/Merge';
|
||||
import Repair from "../tools/Repair";
|
||||
import AutoRename from "../tools/AutoRename";
|
||||
import SingleLargePage from "../tools/SingleLargePage";
|
||||
@ -30,6 +31,7 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
|
||||
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
|
||||
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
|
||||
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
||||
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
||||
@ -48,6 +50,7 @@ import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
||||
import Redact from "../tools/Redact";
|
||||
import { ToolId } from "../types/toolId";
|
||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
|
||||
@ -672,12 +675,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
mergePdfs: {
|
||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.merge.title", "Merge"),
|
||||
component: null,
|
||||
|
||||
component: Merge,
|
||||
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["merge-pdfs"],
|
||||
operationConfig: mergeOperationConfig,
|
||||
settingsComponent: MergeSettings
|
||||
},
|
||||
"multi-tool": {
|
||||
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
|
||||
|
138
frontend/src/hooks/tools/merge/useMergeOperation.test.ts
Normal file
138
frontend/src/hooks/tools/merge/useMergeOperation.test.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useMergeOperation } from './useMergeOperation';
|
||||
import type { MergeParameters } from './useMergeParameters';
|
||||
|
||||
// Mock the useToolOperation hook
|
||||
vi.mock('../shared/useToolOperation', async () => {
|
||||
const actual = await vi.importActual('../shared/useToolOperation');
|
||||
return {
|
||||
...actual,
|
||||
useToolOperation: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the translation hook
|
||||
const mockT = vi.fn((key: string) => `translated-${key}`);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Mock the error handler
|
||||
vi.mock('../../../utils/toolErrorHandler', () => ({
|
||||
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
|
||||
}));
|
||||
|
||||
// Import the mocked function
|
||||
import { MultiFileToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
|
||||
|
||||
describe('useMergeOperation', () => {
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation<MergeParameters>);
|
||||
|
||||
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as MultiFileToolOperationConfig<MergeParameters>;
|
||||
|
||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
||||
files: [],
|
||||
thumbnails: [],
|
||||
downloadUrl: null,
|
||||
downloadFilename: '',
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
status: '',
|
||||
isGeneratingThumbnails: false,
|
||||
progress: null,
|
||||
executeOperation: vi.fn(),
|
||||
resetResults: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
cancelOperation: vi.fn(),
|
||||
undoOperation: function (): Promise<void> {
|
||||
throw new Error('Function not implemented.');
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
|
||||
});
|
||||
|
||||
test('should build FormData correctly', () => {
|
||||
renderHook(() => useMergeOperation());
|
||||
|
||||
const config = getToolConfig();
|
||||
const mockFiles = [
|
||||
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
|
||||
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
|
||||
];
|
||||
const parameters: MergeParameters = {
|
||||
removeDigitalSignature: true,
|
||||
generateTableOfContents: false
|
||||
};
|
||||
|
||||
const formData = config.buildFormData(parameters, mockFiles);
|
||||
|
||||
// Verify files are appended
|
||||
expect(formData.getAll('fileInput')).toHaveLength(2);
|
||||
expect(formData.getAll('fileInput')[0]).toBe(mockFiles[0]);
|
||||
expect(formData.getAll('fileInput')[1]).toBe(mockFiles[1]);
|
||||
|
||||
// Verify parameters are appended correctly
|
||||
expect(formData.get('sortType')).toBe('orderProvided');
|
||||
expect(formData.get('removeCertSign')).toBe('true');
|
||||
expect(formData.get('generateToc')).toBe('false');
|
||||
});
|
||||
|
||||
test('should handle response correctly', () => {
|
||||
renderHook(() => useMergeOperation());
|
||||
|
||||
const config = getToolConfig();
|
||||
const mockBlob = new Blob(['merged content'], { type: 'application/pdf' });
|
||||
const mockFiles = [
|
||||
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
|
||||
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
|
||||
];
|
||||
|
||||
const result = config.responseHandler!(mockBlob, mockFiles) as File[];
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('merged_file1.pdf');
|
||||
expect(result[0].type).toBe('application/pdf');
|
||||
expect(result[0].size).toBe(mockBlob.size);
|
||||
});
|
||||
|
||||
test('should return the hook result from useToolOperation', () => {
|
||||
const { result } = renderHook(() => useMergeOperation());
|
||||
|
||||
expect(result.current).toBe(mockToolOperationReturn);
|
||||
});
|
||||
|
||||
test('should use correct translation keys for error handling', () => {
|
||||
renderHook(() => useMergeOperation());
|
||||
|
||||
expect(mockT).toHaveBeenCalledWith('merge.error.failed', 'An error occurred while merging the PDFs.');
|
||||
});
|
||||
|
||||
test('should build FormData with different parameter combinations', () => {
|
||||
renderHook(() => useMergeOperation());
|
||||
|
||||
const config = getToolConfig();
|
||||
const mockFiles = [new File(['test'], 'test.pdf', { type: 'application/pdf' })];
|
||||
|
||||
// Test case 1: All options disabled
|
||||
const params1: MergeParameters = {
|
||||
removeDigitalSignature: false,
|
||||
generateTableOfContents: false
|
||||
};
|
||||
const formData1 = config.buildFormData(params1, mockFiles);
|
||||
expect(formData1.get('removeCertSign')).toBe('false');
|
||||
expect(formData1.get('generateToc')).toBe('false');
|
||||
|
||||
// Test case 2: All options enabled
|
||||
const params2: MergeParameters = {
|
||||
removeDigitalSignature: true,
|
||||
generateTableOfContents: true
|
||||
};
|
||||
const formData2 = config.buildFormData(params2, mockFiles);
|
||||
expect(formData2.get('removeCertSign')).toBe('true');
|
||||
expect(formData2.get('generateToc')).toBe('true');
|
||||
});
|
||||
});
|
41
frontend/src/hooks/tools/merge/useMergeOperation.ts
Normal file
41
frontend/src/hooks/tools/merge/useMergeOperation.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ResponseHandler, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { MergeParameters } from './useMergeParameters';
|
||||
|
||||
const buildFormData = (parameters: MergeParameters, files: File[]): FormData => {
|
||||
const formData = new FormData();
|
||||
|
||||
files.forEach((file) => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
|
||||
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
|
||||
formData.append("generateToc", parameters.generateTableOfContents.toString());
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
const mergeResponseHandler: ResponseHandler = (blob: Blob, originalFiles: File[]): File[] => {
|
||||
const filename = `merged_${originalFiles[0].name}`
|
||||
return [new File([blob], filename, { type: 'application/pdf' })];
|
||||
};
|
||||
|
||||
// Operation configuration for automation
|
||||
export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
|
||||
toolType: ToolType.multiFile,
|
||||
buildFormData,
|
||||
operationType: 'merge',
|
||||
endpoint: '/api/v1/general/merge-pdfs',
|
||||
filePrefix: 'merged_',
|
||||
responseHandler: mergeResponseHandler,
|
||||
};
|
||||
|
||||
export const useMergeOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<MergeParameters>({
|
||||
...mergeOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.'))
|
||||
});
|
||||
};
|
68
frontend/src/hooks/tools/merge/useMergeParameters.test.ts
Normal file
68
frontend/src/hooks/tools/merge/useMergeParameters.test.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useMergeParameters, defaultParameters } from './useMergeParameters';
|
||||
|
||||
describe('useMergeParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ paramName: 'removeDigitalSignature' as const, value: true },
|
||||
{ paramName: 'removeDigitalSignature' as const, value: false },
|
||||
{ paramName: 'generateTableOfContents' as const, value: true },
|
||||
{ paramName: 'generateTableOfContents' as const, value: false }
|
||||
])('should update parameter $paramName to $value', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
});
|
||||
|
||||
expect(result.current.parameters[paramName]).toBe(value);
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
|
||||
// First, change some parameters
|
||||
act(() => {
|
||||
result.current.updateParameter('removeDigitalSignature', true);
|
||||
result.current.updateParameter('generateTableOfContents', true);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.removeDigitalSignature).toBe(true);
|
||||
expect(result.current.parameters.generateTableOfContents).toBe(true);
|
||||
|
||||
// Then reset
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
});
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
test('should validate parameters correctly - always returns true', () => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
|
||||
// Default state should be valid
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
|
||||
// Change parameters and validate again
|
||||
act(() => {
|
||||
result.current.updateParameter('removeDigitalSignature', true);
|
||||
result.current.updateParameter('generateTableOfContents', true);
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
|
||||
// Reset and validate again
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
});
|
||||
});
|
21
frontend/src/hooks/tools/merge/useMergeParameters.ts
Normal file
21
frontend/src/hooks/tools/merge/useMergeParameters.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { BaseParametersHook, useBaseParameters } from '../shared/useBaseParameters';
|
||||
|
||||
export interface MergeParameters extends BaseParameters {
|
||||
removeDigitalSignature: boolean;
|
||||
generateTableOfContents: boolean;
|
||||
};
|
||||
|
||||
export const defaultParameters: MergeParameters = {
|
||||
removeDigitalSignature: false,
|
||||
generateTableOfContents: false,
|
||||
};
|
||||
|
||||
export type MergeParametersHook = BaseParametersHook<MergeParameters>;
|
||||
|
||||
export const useMergeParameters = (): MergeParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: "merge-pdfs",
|
||||
});
|
||||
};
|
@ -38,7 +38,9 @@ export function useBaseTool<TParams>(
|
||||
useParams: () => BaseParametersHook<TParams>,
|
||||
useOperation: () => ToolOperationHook<TParams>,
|
||||
props: BaseToolProps,
|
||||
options?: { minFiles?: number }
|
||||
): BaseToolReturn<TParams> {
|
||||
const minFiles = options?.minFiles ?? 1;
|
||||
const { onPreviewFile, onComplete, onError } = props;
|
||||
|
||||
// File selection
|
||||
@ -96,7 +98,7 @@ export function useBaseTool<TParams>(
|
||||
}, [operation, onPreviewFile]);
|
||||
|
||||
// Standard computed state
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasFiles = selectedFiles.length >= minFiles;
|
||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
const settingsCollapsed = !hasFiles || hasResults;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileSelection } from "../contexts/FileContext";
|
||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||
@ -161,25 +161,10 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
content
|
||||
});
|
||||
|
||||
// Dynamic file placeholder based on supported types
|
||||
const filesPlaceholder = useMemo(() => {
|
||||
if (currentStep === AUTOMATION_STEPS.RUN && stepData.automation?.operations?.length) {
|
||||
const firstOperation = stepData.automation.operations[0];
|
||||
const toolConfig = toolRegistry[firstOperation.operation as keyof typeof toolRegistry];
|
||||
|
||||
// Check if the tool has supportedFormats that include non-PDF formats
|
||||
if (toolConfig?.supportedFormats && toolConfig.supportedFormats.length > 1) {
|
||||
return t('automate.files.placeholder.multiFormat', 'Select files to process (supports various formats)');
|
||||
}
|
||||
}
|
||||
return t('automate.files.placeholder', 'Select PDF files to process with this automation');
|
||||
}, [currentStep, stepData.automation, toolRegistry, t]);
|
||||
|
||||
// Always create files step to avoid conditional hook calls
|
||||
const filesStep = createFilesToolStep(createStep, {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
placeholder: filesPlaceholder
|
||||
});
|
||||
|
||||
const automationSteps = [
|
||||
|
@ -100,7 +100,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"),
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
|
@ -22,7 +22,6 @@ const Flatten = (props: BaseToolProps) => {
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
placeholder: t("flatten.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
|
98
frontend/src/tools/Merge.tsx
Normal file
98
frontend/src/tools/Merge.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import MergeSettings from "../components/tools/merge/MergeSettings";
|
||||
import MergeFileSorter from "../components/tools/merge/MergeFileSorter";
|
||||
import { useMergeParameters } from "../hooks/tools/merge/useMergeParameters";
|
||||
import { useMergeOperation } from "../hooks/tools/merge/useMergeOperation";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useMergeTips } from "../components/tooltips/useMergeTips";
|
||||
import { useFileManagement, useSelectedFiles, useAllFiles } from "../contexts/FileContext";
|
||||
|
||||
const Merge = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const mergeTips = useMergeTips();
|
||||
|
||||
// File selection hooks for custom sorting
|
||||
const { fileIds } = useAllFiles();
|
||||
const { selectedRecords } = useSelectedFiles();
|
||||
const { reorderFiles } = useFileManagement();
|
||||
|
||||
const base = useBaseTool(
|
||||
'merge',
|
||||
useMergeParameters,
|
||||
useMergeOperation,
|
||||
props,
|
||||
{ minFiles: 2 }
|
||||
);
|
||||
|
||||
// Custom file sorting logic for merge tool
|
||||
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
|
||||
const sortedRecords = [...selectedRecords].sort((recordA, recordB) => {
|
||||
let comparison = 0;
|
||||
switch (sortType) {
|
||||
case 'filename':
|
||||
comparison = recordA.name.localeCompare(recordB.name);
|
||||
break;
|
||||
case 'dateModified':
|
||||
comparison = recordA.lastModified - recordB.lastModified;
|
||||
break;
|
||||
}
|
||||
return ascending ? comparison : -comparison;
|
||||
});
|
||||
|
||||
const selectedIds = sortedRecords.map(record => record.id);
|
||||
const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
|
||||
reorderFiles([...selectedIds, ...deselectedIds]);
|
||||
}, [selectedRecords, fileIds, reorderFiles]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
minFiles: 2,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Sort Files",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
content: (
|
||||
<MergeFileSorter
|
||||
onSortFiles={sortFiles}
|
||||
disabled={!base.hasFiles || base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: mergeTips,
|
||||
content: (
|
||||
<MergeSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("merge.submit", "Merge PDFs"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("merge.title", "Merge Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default Merge as ToolComponent;
|
@ -19,7 +19,6 @@ const RemoveCertificateSign = (props: BaseToolProps) => {
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
|
@ -19,7 +19,6 @@ const Repair = (props: BaseToolProps) => {
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
|
@ -20,7 +20,6 @@ const Sanitize = (props: BaseToolProps) => {
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
|
@ -19,7 +19,6 @@ const SingleLargePage = (props: BaseToolProps) => {
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
|
@ -19,7 +19,6 @@ const UnlockPdfForms = (props: BaseToolProps) => {
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasFiles || base.hasResults,
|
||||
placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
|
74
testing/test_pdf_1.pdf
Normal file
74
testing/test_pdf_1.pdf
Normal file
@ -0,0 +1,74 @@
|
||||
%PDF-1.3
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
||||
/Subject (unspecified) /Title (untitled) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
|
||||
>>
|
||||
stream
|
||||
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp2V9^`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngc-TW~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 9
|
||||
0000000000 65535 f
|
||||
0000000073 00000 n
|
||||
0000000114 00000 n
|
||||
0000000221 00000 n
|
||||
0000000333 00000 n
|
||||
0000000526 00000 n
|
||||
0000000594 00000 n
|
||||
0000000890 00000 n
|
||||
0000000949 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<cb35d644a26f0c9be3597a7f8189b123><cb35d644a26f0c9be3597a7f8189b123>]
|
||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
||||
|
||||
/Info 6 0 R
|
||||
/Root 5 0 R
|
||||
/Size 9
|
||||
>>
|
||||
startxref
|
||||
1186
|
||||
%%EOF
|
74
testing/test_pdf_2.pdf
Normal file
74
testing/test_pdf_2.pdf
Normal file
@ -0,0 +1,74 @@
|
||||
%PDF-1.3
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
||||
/Subject (unspecified) /Title (untitled) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
|
||||
>>
|
||||
stream
|
||||
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3Iif`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngmo`i~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 9
|
||||
0000000000 65535 f
|
||||
0000000073 00000 n
|
||||
0000000114 00000 n
|
||||
0000000221 00000 n
|
||||
0000000333 00000 n
|
||||
0000000526 00000 n
|
||||
0000000594 00000 n
|
||||
0000000890 00000 n
|
||||
0000000949 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<46f6a3460762da2956d1d3fc19ab996f><46f6a3460762da2956d1d3fc19ab996f>]
|
||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
||||
|
||||
/Info 6 0 R
|
||||
/Root 5 0 R
|
||||
/Size 9
|
||||
>>
|
||||
startxref
|
||||
1186
|
||||
%%EOF
|
74
testing/test_pdf_3.pdf
Normal file
74
testing/test_pdf_3.pdf
Normal file
@ -0,0 +1,74 @@
|
||||
%PDF-1.3
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
||||
/Subject (unspecified) /Title (untitled) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
|
||||
>>
|
||||
stream
|
||||
GarW0YmS?5&4HDC`<2TCEOpM_A^cO6ZEVtG&1rQ7k5R.W5uPe>'T[Ma*9KfZqZs*-57""%'<u)dPtNs!.p_7Cem+LKojd:CaF,4$g:S_<`9sPL'Dq([aoCSX;_^WU4Wa'KgNd255,.iQh#\m&~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 9
|
||||
0000000000 65535 f
|
||||
0000000073 00000 n
|
||||
0000000114 00000 n
|
||||
0000000221 00000 n
|
||||
0000000333 00000 n
|
||||
0000000526 00000 n
|
||||
0000000594 00000 n
|
||||
0000000890 00000 n
|
||||
0000000949 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<8c4eba11c30780ded30147f80c0aa46f><8c4eba11c30780ded30147f80c0aa46f>]
|
||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
||||
|
||||
/Info 6 0 R
|
||||
/Root 5 0 R
|
||||
/Size 9
|
||||
>>
|
||||
startxref
|
||||
1186
|
||||
%%EOF
|
74
testing/test_pdf_4.pdf
Normal file
74
testing/test_pdf_4.pdf
Normal file
@ -0,0 +1,74 @@
|
||||
%PDF-1.3
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
||||
/Subject (unspecified) /Title (untitled) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
|
||||
>>
|
||||
stream
|
||||
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3%Qb`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^nh.J$8~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 9
|
||||
0000000000 65535 f
|
||||
0000000073 00000 n
|
||||
0000000114 00000 n
|
||||
0000000221 00000 n
|
||||
0000000333 00000 n
|
||||
0000000526 00000 n
|
||||
0000000594 00000 n
|
||||
0000000890 00000 n
|
||||
0000000949 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<ade40b97468692afaf20f74813f90619><ade40b97468692afaf20f74813f90619>]
|
||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
||||
|
||||
/Info 6 0 R
|
||||
/Root 5 0 R
|
||||
/Size 9
|
||||
>>
|
||||
startxref
|
||||
1186
|
||||
%%EOF
|
Loading…
x
Reference in New Issue
Block a user