Implement 'Add Password' and 'Change Permissions' tools in V2 (#4195)

# Description of Changes
Implement Add Password and Change Permissions tools in V2 (both in one
because Change Permissions is a fake endpoint which just calls Add
Password behind the scenes).

---------

Co-authored-by: James <james@crosscourtanalytics.com>
This commit is contained in:
James Brunton 2025-08-14 14:27:23 +01:00 committed by GitHub
parent 0ea4410dd3
commit ecf30d1028
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1936 additions and 88 deletions

View File

@ -384,6 +384,10 @@
"title": "Add Password",
"desc": "Encrypt your PDF document with a password."
},
"changePermissions": {
"title": "Change Permissions",
"desc": "Change document restrictions and permissions."
},
"removePassword": {
"title": "Remove Password",
"desc": "Remove password protection from your PDF document."
@ -816,30 +820,6 @@
"removePages": {
"tags": "Remove pages,delete pages"
},
"addPassword": {
"tags": "secure,security",
"title": "Add Password",
"header": "Add password (Encrypt)",
"selectText": {
"1": "Select PDF to encrypt",
"2": "User Password",
"3": "Encryption Key Length",
"4": "Higher values are stronger, but lower values have better compatibility.",
"5": "Permissions to set (Recommended to be used along with Owner password)",
"6": "Prevent assembly of document",
"7": "Prevent content extraction",
"8": "Prevent extraction for accessibility",
"9": "Prevent filling in form",
"10": "Prevent modification",
"11": "Prevent annotation modification",
"12": "Prevent printing",
"13": "Prevent printing different formats",
"14": "Owner Password",
"15": "Restricts what can be done with the document once it is opened (Not supported by all readers)",
"16": "Restricts the opening of the document itself"
},
"submit": "Encrypt"
},
"removePassword": {
"tags": "secure,Decrypt,security,unpassword,delete password",
"title": "Remove password",
@ -1798,5 +1778,109 @@
"removeFonts": "Remove Fonts",
"removeFonts.desc": "Remove embedded fonts from the PDF"
}
},
"addPassword": {
"completed": "Password protection applied",
"submit": "Encrypt",
"filenamePrefix": "encrypted",
"error": {
"failed": "An error occurred while encrypting the PDF."
},
"passwords": {
"title": "Passwords",
"stepTitle": "Passwords & Encryption",
"completed": "Passwords configured",
"user": {
"label": "User Password",
"placeholder": "Enter user password"
},
"owner": {
"label": "Owner Password",
"placeholder": "Enter owner password"
}
},
"permissions": {
"stepTitle": "Document Permissions"
},
"encryption": {
"title": "Encryption",
"keyLength": {
"label": "Key Length",
"40bit": "40-bit (Low)",
"128bit": "128-bit (Standard)",
"256bit": "256-bit (High)"
}
},
"results": {
"title": "Encrypted PDFs"
},
"tooltip": {
"header": {
"title": "Password Protection Overview"
},
"passwords": {
"title": "Password Types",
"text": "User passwords restrict opening the document, while owner passwords control what can be done with the document once opened. You can set both or just one.",
"bullet1": "User Password: Required to open the PDF",
"bullet2": "Owner Password: Controls document permissions (not supported by all PDF viewers)"
},
"encryption": {
"title": "Encryption Levels",
"text": "Higher encryption levels provide better security but may not be supported by older PDF viewers.",
"bullet1": "40-bit: Basic security, compatible with older viewers",
"bullet2": "128-bit: Standard security, widely supported",
"bullet3": "256-bit: Maximum security, requires modern viewers"
},
"restrictions": {
"title": "Document Restrictions",
"text": "These restrictions control what users can do with the PDF. Most effective when combined with an owner password."
}
}
},
"changePermissions": {
"completed": "Permissions changed",
"submit": "Change Permissions",
"error": {
"failed": "An error occurred while changing PDF permissions."
},
"restrictions": {
"title": "Document Restrictions",
"preventAssembly": {
"label": "Prevent assembly of document"
},
"preventExtractContent": {
"label": "Prevent content extraction"
},
"preventExtractForAccessibility": {
"label": "Prevent extraction for accessibility"
},
"preventFillInForm": {
"label": "Prevent filling in form"
},
"preventModify": {
"label": "Prevent modification"
},
"preventModifyAnnotations": {
"label": "Prevent annotation modification"
},
"preventPrinting": {
"label": "Prevent printing"
},
"preventPrintingFaithful": {
"label": "Prevent printing different formats"
}
},
"results": {
"title": "Modified PDFs"
},
"tooltip": {
"header": {
"title": "Change Permissions"
},
"description": {
"title": "Description",
"text": "Changes document permissions. Warning: To make these restrictions unchangeable, use the Add Password tool to set an owner password."
}
}
}
}

View File

@ -380,6 +380,10 @@
"title": "Add Password",
"desc": "Encrypt your PDF document with a password."
},
"changePermissions": {
"title": "Change Permissions",
"desc": "Change document restrictions and permissions."
},
"removePassword": {
"title": "Remove Password",
"desc": "Remove password protection from your PDF document."
@ -745,30 +749,6 @@
"removePages": {
"tags": "Remove pages,delete pages"
},
"addPassword": {
"tags": "secure,security",
"title": "Add Password",
"header": "Add password (Encrypt)",
"selectText": {
"1": "Select PDF to encrypt",
"2": "User Password",
"3": "Encryption Key Length",
"4": "Higher values are stronger, but lower values have better compatibility.",
"5": "Permissions to set (Recommended to be used along with Owner password)",
"6": "Prevent assembly of document",
"7": "Prevent content extraction",
"8": "Prevent extraction for accessibility",
"9": "Prevent filling in form",
"10": "Prevent modification",
"11": "Prevent annotation modification",
"12": "Prevent printing",
"13": "Prevent printing different formats",
"14": "Owner Password",
"15": "Restricts what can be done with the document once it is opened (Not supported by all readers)",
"16": "Restricts the opening of the document itself"
},
"submit": "Encrypt"
},
"removePassword": {
"tags": "secure,Decrypt,security,unpassword,delete password",
"title": "Remove password",
@ -1650,5 +1630,109 @@
"removeFonts.desc": "Remove embedded fonts from the PDF"
}
}
},
"addPassword": {
"completed": "Password protection applied",
"submit": "Encrypt",
"filenamePrefix": "encrypted",
"error": {
"failed": "An error occurred while encrypting the PDF."
},
"passwords": {
"title": "Passwords",
"stepTitle": "Passwords & Encryption",
"completed": "Passwords configured",
"user": {
"label": "User Password",
"placeholder": "Enter user password"
},
"owner": {
"label": "Owner Password",
"placeholder": "Enter owner password"
}
},
"permissions": {
"stepTitle": "Document Permissions"
},
"encryption": {
"title": "Encryption",
"keyLength": {
"label": "Key Length",
"40bit": "40-bit (Low)",
"128bit": "128-bit (Standard)",
"256bit": "256-bit (High)"
}
},
"results": {
"title": "Password Protected PDFs"
},
"tooltip": {
"header": {
"title": "Password Protection Overview"
},
"passwords": {
"title": "Password Types",
"text": "User passwords restrict opening the document, while owner passwords control what can be done with the document once opened. You can set both or just one.",
"bullet1": "User Password: Required to open the PDF",
"bullet2": "Owner Password: Controls document permissions (not supported by all PDF viewers)"
},
"encryption": {
"title": "Encryption Levels",
"text": "Higher encryption levels provide better security but may not be supported by older PDF viewers.",
"bullet1": "40-bit: Basic security, compatible with older viewers",
"bullet2": "128-bit: Standard security, widely supported",
"bullet3": "256-bit: Maximum security, requires modern viewers"
},
"restrictions": {
"title": "Document Restrictions",
"text": "These restrictions control what users can do with the PDF. Most effective when combined with an owner password."
}
}
},
"changePermissions": {
"completed": "Permissions changed",
"submit": "Change Permissions",
"error": {
"failed": "An error occurred while changing PDF permissions."
},
"restrictions": {
"title": "Document Restrictions",
"preventAssembly": {
"label": "Prevent assembly of document"
},
"preventExtractContent": {
"label": "Prevent content extraction"
},
"preventExtractForAccessibility": {
"label": "Prevent extraction for accessibility"
},
"preventFillInForm": {
"label": "Prevent filling in form"
},
"preventModify": {
"label": "Prevent modification"
},
"preventModifyAnnotations": {
"label": "Prevent annotation modification"
},
"preventPrinting": {
"label": "Prevent printing"
},
"preventPrintingFaithful": {
"label": "Prevent printing different formats"
}
},
"results": {
"title": "Modified PDFs"
},
"tooltip": {
"header": {
"title": "Change Permissions"
},
"description": {
"title": "Description",
"text": "Changes document permissions. Warning: To make these restrictions unchangeable, use the Add Password tool to set an owner password."
}
}
}
}

View File

@ -0,0 +1,179 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import AddPasswordSettings from './AddPasswordSettings';
import { defaultParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
import type { AddPasswordParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
// 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('AddPasswordSettings', () => {
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render password input fields', () => {
render(
<TestWrapper>
<AddPasswordSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Should render user and owner password fields labels
expect(screen.getByText('mock-addPassword.passwords.user.label')).toBeInTheDocument();
expect(screen.getByText('mock-addPassword.passwords.owner.label')).toBeInTheDocument();
});
test('should render encryption key length select', () => {
render(
<TestWrapper>
<AddPasswordSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Should render key length select input
expect(screen.getByRole('textbox', { name: /keyLength/i })).toBeInTheDocument();
});
test('should render main component sections', () => {
render(
<TestWrapper>
<AddPasswordSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check that main section titles are rendered
expect(screen.getByText('mock-addPassword.passwords.title')).toBeInTheDocument();
expect(screen.getByText('mock-addPassword.encryption.title')).toBeInTheDocument();
});
test('should call onParameterChange when password fields are modified', () => {
render(
<TestWrapper>
<AddPasswordSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// This test is complex with Mantine's PasswordInput, just verify the component renders
expect(screen.getByText('mock-addPassword.passwords.user.label')).toBeInTheDocument();
});
test('should call onParameterChange when key length is changed', () => {
render(
<TestWrapper>
<AddPasswordSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Find key length select and change it
const keyLengthSelect = screen.getByText('mock-addPassword.encryption.keyLength.128bit');
fireEvent.mouseDown(keyLengthSelect);
const option256 = screen.getByText('mock-addPassword.encryption.keyLength.256bit');
fireEvent.click(option256);
expect(mockOnParameterChange).toHaveBeenCalledWith('keyLength', 256);
});
test('should disable all form elements when disabled prop is true', () => {
render(
<TestWrapper>
<AddPasswordSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={true}
/>
</TestWrapper>
);
// Check password inputs are disabled
const passwordInputs = screen.getAllByRole('textbox');
passwordInputs.forEach(input => {
expect(input).toBeDisabled();
});
// Check key length select is disabled - simplified test due to Mantine complexity
expect(screen.getByText('mock-addPassword.encryption.keyLength.128bit')).toBeInTheDocument();
});
test('should enable all form elements when disabled prop is false', () => {
render(
<TestWrapper>
<AddPasswordSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={false}
/>
</TestWrapper>
);
// Check password inputs are enabled
const passwordInputs = screen.getAllByRole('textbox');
passwordInputs.forEach(input => {
expect(input).not.toBeDisabled();
});
// Check key length select is enabled - simplified test due to Mantine complexity
expect(screen.getByText('mock-addPassword.encryption.keyLength.128bit')).toBeInTheDocument();
});
test('should call translation function with correct keys', () => {
render(
<TestWrapper>
<AddPasswordSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Verify that translation keys are being called
expect(mockT).toHaveBeenCalledWith('addPassword.passwords.title', 'Passwords');
expect(mockT).toHaveBeenCalledWith('addPassword.encryption.title', 'Encryption');
expect(mockT).toHaveBeenCalledWith('addPassword.passwords.user.label', 'User Password');
expect(mockT).toHaveBeenCalledWith('addPassword.passwords.owner.label', 'Owner Password');
});
test.each([
{ keyLength: 40, expectedLabel: 'mock-addPassword.encryption.keyLength.40bit' },
{ keyLength: 128, expectedLabel: 'mock-addPassword.encryption.keyLength.128bit' },
{ keyLength: 256, expectedLabel: 'mock-addPassword.encryption.keyLength.256bit' }
])('should handle key length $keyLength correctly', ({ keyLength, expectedLabel }) => {
render(
<TestWrapper>
<AddPasswordSettings
parameters={{ ...defaultParameters, keyLength }}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
expect(screen.getByText(expectedLabel)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,60 @@
import React from "react";
import { Stack, Text, PasswordInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters";
interface AddPasswordSettingsProps {
parameters: AddPasswordParameters;
onParameterChange: (key: keyof AddPasswordParameters, value: any) => void;
disabled?: boolean;
}
const AddPasswordSettings = ({ parameters, onParameterChange, disabled = false }: AddPasswordSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
{/* Password Settings */}
<Stack gap="sm">
<Text size="sm" fw={500}>{t('addPassword.passwords.title', 'Passwords')}</Text>
<PasswordInput
label={t('addPassword.passwords.user.label', 'User Password')}
placeholder={t('addPassword.passwords.user.placeholder', 'Enter user password')}
value={parameters.password}
onChange={(e) => onParameterChange('password', e.target.value)}
disabled={disabled}
/>
<PasswordInput
label={t('addPassword.passwords.owner.label', 'Owner Password')}
placeholder={t('addPassword.passwords.owner.placeholder', 'Enter owner password')}
value={parameters.ownerPassword}
onChange={(e) => onParameterChange('ownerPassword', e.target.value)}
disabled={disabled}
/>
</Stack>
{/* Encryption Settings */}
<Stack gap="sm">
<Text size="sm" fw={500}>{t('addPassword.encryption.title', 'Encryption')}</Text>
<Select
label={t('addPassword.encryption.keyLength.label', 'Key Length')}
value={parameters.keyLength.toString()}
onChange={(value) => {
if (value) {
onParameterChange('keyLength', parseInt(value));
}
}}
disabled={disabled}
data={[
{ value: '40', label: t('addPassword.encryption.keyLength.40bit', '40-bit (Low)') },
{ value: '128', label: t('addPassword.encryption.keyLength.128bit', '128-bit (Standard)') },
{ value: '256', label: t('addPassword.encryption.keyLength.256bit', '256-bit (High)') }
]}
/>
</Stack>
</Stack>
);
};
export default AddPasswordSettings;

View File

@ -0,0 +1,226 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import ChangePermissionsSettings from './ChangePermissionsSettings';
import { defaultParameters } from '../../../hooks/tools/changePermissions/useChangePermissionsParameters';
import type { ChangePermissionsParameters } from '../../../hooks/tools/changePermissions/useChangePermissionsParameters';
// 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('ChangePermissionsSettings', () => {
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render section title', () => {
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
expect(screen.getByText('mock-changePermissions.restrictions.title')).toBeInTheDocument();
});
test('should render all permission checkboxes', () => {
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Should render checkboxes for all permission types
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(permissionKeys.length);
// Verify specific permission labels are rendered
expect(screen.getByText('mock-changePermissions.restrictions.preventAssembly.label')).toBeInTheDocument();
expect(screen.getByText('mock-changePermissions.restrictions.preventPrinting.label')).toBeInTheDocument();
expect(screen.getByText('mock-changePermissions.restrictions.preventModify.label')).toBeInTheDocument();
expect(screen.getByText('mock-changePermissions.restrictions.preventExtractContent.label')).toBeInTheDocument();
});
test('should render all permission types with correct labels', () => {
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
permissionKeys.forEach(permission => {
expect(screen.getByText(`mock-changePermissions.restrictions.${permission}.label`)).toBeInTheDocument();
});
});
test('should show checkboxes as unchecked by default', () => {
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
checkboxes.forEach(checkbox => {
expect(checkbox.checked).toBe(false);
});
});
test('should show checkboxes as checked when parameters are true', () => {
const checkedParameters: ChangePermissionsParameters = {
...defaultParameters,
preventAssembly: true,
preventPrinting: true,
preventModify: true
};
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={checkedParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Find specific checkboxes by their labels and verify they are checked
const assemblyCheckbox = screen.getByLabelText('mock-changePermissions.restrictions.preventAssembly.label') as HTMLInputElement;
const printingCheckbox = screen.getByLabelText('mock-changePermissions.restrictions.preventPrinting.label') as HTMLInputElement;
const modifyCheckbox = screen.getByLabelText('mock-changePermissions.restrictions.preventModify.label') as HTMLInputElement;
const formCheckbox = screen.getByLabelText('mock-changePermissions.restrictions.preventFillInForm.label') as HTMLInputElement;
expect(assemblyCheckbox.checked).toBe(true);
expect(printingCheckbox.checked).toBe(true);
expect(modifyCheckbox.checked).toBe(true);
expect(formCheckbox.checked).toBe(false); // Ensure other checkboxes are unaffected
});
test.each([
{ initialValue: false, expectedValue: true, description: 'checking an unchecked box' },
{ initialValue: true, expectedValue: false, description: 'unchecking a checked box' }
])('should call onParameterChange with $expectedValue when $description', ({ initialValue, expectedValue }) => {
const testParameters: ChangePermissionsParameters = {
...defaultParameters,
preventAssembly: initialValue
};
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={testParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const assemblyCheckbox = screen.getByLabelText('mock-changePermissions.restrictions.preventAssembly.label');
fireEvent.click(assemblyCheckbox);
expect(mockOnParameterChange).toHaveBeenCalledWith('preventAssembly', expectedValue);
});
test('should handle multiple checkbox interactions', () => {
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Click multiple checkboxes
const assemblyCheckbox = screen.getByLabelText('mock-changePermissions.restrictions.preventAssembly.label');
const printingCheckbox = screen.getByLabelText('mock-changePermissions.restrictions.preventPrinting.label');
fireEvent.click(assemblyCheckbox);
fireEvent.click(printingCheckbox);
expect(mockOnParameterChange).toHaveBeenCalledWith('preventAssembly', true);
expect(mockOnParameterChange).toHaveBeenCalledWith('preventPrinting', true);
expect(mockOnParameterChange).toHaveBeenCalledTimes(2);
});
test.each([
{ disabled: true, expectedState: true },
{ disabled: false, expectedState: false }
])('should set checkboxes disabled=$disabled when disabled prop is $disabled', ({ disabled, expectedState }) => {
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={disabled}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
checkboxes.forEach(checkbox => {
expect(checkbox.disabled).toBe(expectedState);
});
});
test('should call translation function with correct keys', () => {
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Verify that translation keys are being called
expect(mockT).toHaveBeenCalledWith('changePermissions.restrictions.title', 'Document Restrictions');
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
permissionKeys.forEach(permission => {
expect(mockT).toHaveBeenCalledWith(`changePermissions.restrictions.${permission}.label`, permission);
});
});
test.each(Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>)('should handle %s permission type individually', (permission) => {
const testParameters: ChangePermissionsParameters = {
...defaultParameters,
[permission]: true
};
render(
<TestWrapper>
<ChangePermissionsSettings
parameters={testParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkbox = screen.getByLabelText(`mock-changePermissions.restrictions.${permission}.label`) as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
});

View File

@ -0,0 +1,33 @@
import { Stack, Text, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters";
interface ChangePermissionsSettingsProps {
parameters: ChangePermissionsParameters;
onParameterChange: (key: keyof ChangePermissionsParameters, value: boolean) => void;
disabled?: boolean;
}
const ChangePermissionsSettings = ({ parameters, onParameterChange, disabled = false }: ChangePermissionsSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<Text size="sm" fw={500}>{t('changePermissions.restrictions.title', 'Document Restrictions')}</Text>
<Stack gap="xs">
{(Object.keys(parameters) as Array<keyof ChangePermissionsParameters>).map((key) => (
<Checkbox
key={key}
label={t(`changePermissions.restrictions.${key}.label`, key)}
checked={parameters[key]}
onChange={(e) => onParameterChange(key, e.target.checked)}
disabled={disabled}
/>
))}
</Stack>
</Stack>
);
};
export default ChangePermissionsSettings;

View File

@ -1,19 +1,7 @@
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { isSplitMode, SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
export interface SplitParameters {
mode: SplitMode | '';
pages: string;
hDiv: string;
vDiv: string;
merge: boolean;
splitType: SplitType | '';
splitValue: string;
bookmarkLevel: string;
includeMetadata: boolean;
allowDuplicates: boolean;
}
import { isSplitMode, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters';
export interface SplitSettingsProps {
parameters: SplitParameters;

View File

@ -0,0 +1,35 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useAddPasswordTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("addPassword.tooltip.header.title", "Password Protection Overview")
},
tips: [
{
title: t("addPassword.tooltip.passwords.title", "Password Types"),
description: t("addPassword.tooltip.passwords.text", "User passwords restrict opening the document, while owner passwords control what can be done with the document once opened. You can set both or just one."),
bullets: [
t("addPassword.tooltip.passwords.bullet1", "User Password: Required to open the PDF"),
t("addPassword.tooltip.passwords.bullet2", "Owner Password: Controls document permissions (not supported by all PDF viewers)")
]
},
{
title: t("addPassword.tooltip.encryption.title", "Encryption Levels"),
description: t("addPassword.tooltip.encryption.text", "Higher encryption levels provide better security but may not be supported by older PDF viewers."),
bullets: [
t("addPassword.tooltip.encryption.bullet1", "40-bit: Basic security, compatible with older viewers"),
t("addPassword.tooltip.encryption.bullet2", "128-bit: Standard security, widely supported"),
t("addPassword.tooltip.encryption.bullet3", "256-bit: Maximum security, requires modern viewers")
]
},
{
title: t("addPassword.tooltip.restrictions.title", "Document Restrictions"),
description: t("addPassword.tooltip.restrictions.text", "These restrictions control what users can do with the PDF. Most effective when combined with an owner password."),
}
]
};
};

View File

@ -0,0 +1,18 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useChangePermissionsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("changePermissions.tooltip.header.title", "Change Permissions")
},
tips: [
{
title: t("changePermissions.tooltip.description.title", "Description"),
description: t("changePermissions.tooltip.description.text", "Changes document permissions. Warning: To make these restrictions unchangeable, use the Add Password tool to set an owner password.")
}
]
};
};

View File

@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const CompressTips = (): TooltipContent => {
export const useCompressTips = (): TooltipContent => {
const { t } = useTranslation();
return {
@ -27,4 +27,4 @@ export const CompressTips = (): TooltipContent => {
}
]
};
};
};

View File

@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const OcrTips = (): TooltipContent => {
export const useOCRTips = (): TooltipContent => {
const { t } = useTranslation();
return {
@ -33,4 +33,4 @@ export const OcrTips = (): TooltipContent => {
}
]
};
};
};

View File

@ -460,9 +460,7 @@ export function FileContextProvider({
thumbnailGenerationService.destroy();
// Force garbage collection hint
if (typeof window !== 'undefined') {
setTimeout(() => window.gc && window.gc(), 100);
}
setTimeout(() => window?.gc?.(), 100);
} catch (error) {
console.warn('Error during cleanup all files:', error);
@ -596,9 +594,9 @@ export function FileContextProvider({
dispatch({ type: 'SET_CURRENT_MODE', payload: mode });
if (state.currentMode !== mode && state.activeFiles.length > 0) {
if (window.requestIdleCallback && typeof window !== 'undefined') {
if (window.requestIdleCallback) {
window.requestIdleCallback(() => {
window.gc && window.gc();
window.gc?.();
}, { timeout: 5000 });
}
}
@ -610,9 +608,9 @@ export function FileContextProvider({
dispatch({ type: 'SET_CURRENT_VIEW', payload: view });
if (state.currentView !== view && state.activeFiles.length > 0) {
if (window.requestIdleCallback && typeof window !== 'undefined') {
if (window.requestIdleCallback) {
window.requestIdleCallback(() => {
window.gc && window.gc();
window.gc?.();
}, { timeout: 5000 });
}
}

View File

@ -0,0 +1,144 @@
import { describe, expect, test, vi, beforeEach, MockedFunction } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAddPasswordOperation } from './useAddPasswordOperation';
import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', () => ({
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 { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
describe('useAddPasswordOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0];
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(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test('should configure useToolOperation with correct parameters', () => {
renderHook(() => useAddPasswordOperation());
expect(mockUseToolOperation).toHaveBeenCalledWith({
operationType: 'addPassword',
endpoint: '/api/v1/security/add-password',
buildFormData: expect.any(Function),
filePrefix: 'translated-addPassword.filenamePrefix_',
multiFileEndpoint: false,
getErrorMessage: 'error-handler-function'
});
});
test('should return the result from useToolOperation', () => {
const { result } = renderHook(() => useAddPasswordOperation());
expect(result.current).toBe(mockToolOperationReturn);
});
test.each([
{
description: 'with all parameters filled',
password: 'user-password',
ownerPassword: 'owner-password',
keyLength: 256
},
{
description: 'with empty passwords',
password: '',
ownerPassword: '',
keyLength: 128
},
{
description: 'with 40-bit key length',
password: 'test',
ownerPassword: '',
keyLength: 40
}
])('should create form data correctly $description', ({ password, ownerPassword, keyLength }) => {
renderHook(() => useAddPasswordOperation());
const callArgs = getToolConfig();
const buildFormData = callArgs.buildFormData;
const testParameters: AddPasswordFullParameters = {
password,
ownerPassword,
keyLength,
permissions: {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false
}
};
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = buildFormData(testParameters, testFile as any /* FIX ME */);
// Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile);
// Verify password parameters
expect(formData.get('password')).toBe(password);
expect(formData.get('ownerPassword')).toBe(ownerPassword);
expect(formData.get('keyLength')).toBe(keyLength.toString());
});
test('should use correct translation for error messages', () => {
renderHook(() => useAddPasswordOperation());
expect(mockT).toHaveBeenCalledWith(
'addPassword.error.failed',
'An error occurred while encrypting the PDF.'
);
});
test.each([
{ property: 'multiFileEndpoint' as const, expectedValue: false },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
{ property: 'filePrefix' as const, expectedValue: 'translated-addPassword.filenamePrefix_' },
{ property: 'operationType' as const, expectedValue: 'addPassword' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useAddPasswordOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});

View File

@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddPasswordFullParameters } from './useAddPasswordParameters';
import { getFormData } from '../changePermissions/useChangePermissionsOperation';
export const useAddPasswordOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: AddPasswordFullParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
formData.append("ownerPassword", parameters.ownerPassword);
formData.append("keyLength", parameters.keyLength.toString());
getFormData(parameters.permissions).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
return useToolOperation<AddPasswordFullParameters>({
operationType: 'addPassword',
endpoint: '/api/v1/security/add-password',
buildFormData,
filePrefix: t('addPassword.filenamePrefix', 'encrypted') + '_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(t('addPassword.error.failed', 'An error occurred while encrypting the PDF.'))
});
};

View File

@ -0,0 +1,152 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAddPasswordParameters, defaultParameters, AddPasswordParametersHook } from './useAddPasswordParameters';
import { defaultParameters as defaultChangePermissionsParameters, ChangePermissionsParameters } from '../changePermissions/useChangePermissionsParameters';
describe('useAddPasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'password' as const, value: 'test-password' },
{ paramName: 'ownerPassword' as const, value: 'owner-password' },
{ paramName: 'keyLength' as const, value: 256 }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test.each([
{ paramName: 'preventAssembly' as const },
{ paramName: 'preventPrinting' as const }
])('should update boolean permission parameter $paramName', ({ paramName }) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.permissions.updateParameter(paramName, true);
});
expect(result.current.permissions.parameters[paramName]).toBe(true);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAddPasswordParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('password', 'test');
result.current.updateParameter('keyLength', 256);
result.current.permissions.updateParameter('preventAssembly', true);
});
expect(result.current.parameters.password).toBe('test');
expect(result.current.parameters.keyLength).toBe(256);
expect(result.current.permissions.parameters.preventAssembly).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.getEndpointName()).toBe('add-password');
});
test.each([
{
description: 'with user password only',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', 'user-password');
}
},
{
description: 'with owner password only',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('ownerPassword', 'owner-password');
}
},
{
description: 'with both passwords',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', 'user-password');
hook.updateParameter('ownerPassword', 'owner-password');
}
},
{
description: 'with whitespace only password',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', ' \t ');
}
},
{
description: 'with whitespace only owner password',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('ownerPassword', ' \t ');
}
},
{
description: 'with restrictions only',
setup: (hook: AddPasswordParametersHook) => {
hook.permissions.updateParameter('preventAssembly', true);
hook.permissions.updateParameter('preventPrinting', true);
}
},
{
description: 'with passwords and restrictions',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', 'test-password');
hook.permissions.updateParameter('preventAssembly', true);
}
}
])('should validate parameters correctly $description', ({ setup }) => {
const { result } = renderHook(() => useAddPasswordParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Apply the test scenario setup
act(() => {
setup(result.current);
});
expect(result.current.validateParameters()).toBe(true);
});
test.each(Object.keys(defaultChangePermissionsParameters) as Array<keyof ChangePermissionsParameters>)('should handle boolean restriction parameter %s', (param) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.resetParameters();
result.current.permissions.updateParameter(param, true);
});
expect(result.current.validateParameters()).toBe(true);
});
test('should handle mixed parameter types in updateParameter', () => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter('password', 'test-string');
result.current.updateParameter('keyLength', 40);
result.current.permissions.updateParameter('preventAssembly', true);
});
expect(result.current.parameters.password).toBe('test-string');
expect(result.current.parameters.keyLength).toBe(40);
expect(result.current.permissions.parameters.preventAssembly).toBe(true);
});
});

View File

@ -0,0 +1,69 @@
import { useState } from 'react';
import { ChangePermissionsParameters, ChangePermissionsParametersHook, useChangePermissionsParameters } from '../changePermissions/useChangePermissionsParameters';
export interface AddPasswordParameters {
password: string;
ownerPassword: string;
keyLength: number;
}
export interface AddPasswordFullParameters extends AddPasswordParameters {
permissions: ChangePermissionsParameters;
}
export interface AddPasswordParametersHook {
fullParameters: AddPasswordFullParameters;
parameters: AddPasswordParameters;
permissions: ChangePermissionsParametersHook;
updateParameter: <K extends keyof AddPasswordParameters>(parameter: K, value: AddPasswordParameters[K]) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
}
export const defaultParameters: AddPasswordParameters = {
password: '',
ownerPassword: '',
keyLength: 128,
};
export const useAddPasswordParameters = (): AddPasswordParametersHook => {
const [parameters, setParameters] = useState<AddPasswordParameters>(defaultParameters);
const permissions = useChangePermissionsParameters();
const fullParameters: AddPasswordFullParameters = {
...parameters,
permissions: permissions.parameters,
};
const updateParameter = <K extends keyof AddPasswordParameters>(parameter: K, value: AddPasswordParameters[K]) => {
setParameters(prev => ({
...prev,
[parameter]: value,
})
);
};
const resetParameters = () => {
setParameters(defaultParameters);
permissions.resetParameters();
};
const validateParameters = () => {
// No required parameters for Add Password. Defer to permissions validation.
return permissions.validateParameters();
};
const getEndpointName = () => {
return 'add-password';
};
return {
fullParameters,
parameters,
permissions,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
};
};

View File

@ -0,0 +1,138 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useChangePermissionsOperation } from './useChangePermissionsOperation';
import type { ChangePermissionsParameters } from './useChangePermissionsParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', () => ({
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 { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
describe('useChangePermissionsOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<ChangePermissionsParameters> => mockUseToolOperation.mock.calls[0][0];
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(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test('should configure useToolOperation with correct parameters', () => {
renderHook(() => useChangePermissionsOperation());
expect(mockUseToolOperation).toHaveBeenCalledWith({
operationType: 'changePermissions',
endpoint: '/api/v1/security/add-password',
buildFormData: expect.any(Function),
filePrefix: 'permissions_',
multiFileEndpoint: false,
getErrorMessage: 'error-handler-function'
});
});
test('should return the result from useToolOperation', () => {
const { result } = renderHook(() => useChangePermissionsOperation());
expect(result.current).toBe(mockToolOperationReturn);
});
test.each([
{
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
},
{
preventAssembly: true,
preventExtractContent: false,
preventExtractForAccessibility: true,
preventFillInForm: false,
preventModify: true,
preventModifyAnnotations: false,
preventPrinting: true,
preventPrintingFaithful: false,
},
{
preventAssembly: true,
preventExtractContent: true,
preventExtractForAccessibility: true,
preventFillInForm: true,
preventModify: true,
preventModifyAnnotations: true,
preventPrinting: true,
preventPrintingFaithful: true,
},
])('should create form data correctly', (testParameters: ChangePermissionsParameters) => {
renderHook(() => useChangePermissionsOperation());
const callArgs = getToolConfig();
const buildFormData = callArgs.buildFormData;
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = buildFormData(testParameters, testFile as any /* FIX ME */);
// Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile);
(Object.keys(testParameters) as Array<keyof ChangePermissionsParameters>).forEach(key => {
expect(formData.get(key), `Parameter ${key} should be set correctly`).toBe(testParameters[key].toString());
});
});
test('should use correct translation for error messages', () => {
renderHook(() => useChangePermissionsOperation());
expect(mockT).toHaveBeenCalledWith(
'changePermissions.error.failed',
'An error occurred while changing PDF permissions.'
);
});
test.each([
{ property: 'multiFileEndpoint' as const, expectedValue: false },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
{ property: 'filePrefix' as const, expectedValue: 'permissions_' },
{ property: 'operationType' as const, expectedValue: 'changePermissions' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useChangePermissionsOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});

View File

@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import type { ChangePermissionsParameters } from './useChangePermissionsParameters';
export const getFormData = ((parameters: ChangePermissionsParameters) =>
Object.entries(parameters).map(([key, value]) =>
[key, value.toString()]
) as string[][]
);
export const useChangePermissionsOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: ChangePermissionsParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
return useToolOperation({
operationType: 'changePermissions',
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
buildFormData,
filePrefix: 'permissions_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(
t('changePermissions.error.failed', 'An error occurred while changing PDF permissions.')
)
});
};

View File

@ -0,0 +1,110 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useChangePermissionsParameters, defaultParameters, ChangePermissionsParameters } from './useChangePermissionsParameters';
describe('useChangePermissionsParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual boolean parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
act(() => {
result.current.updateParameter('preventAssembly', true);
});
expect(result.current.parameters.preventAssembly).toBe(true);
expect(result.current.parameters.preventPrinting).toBe(false); // Other parameters should remain unchanged
act(() => {
result.current.updateParameter('preventPrinting', true);
});
expect(result.current.parameters.preventPrinting).toBe(true);
expect(result.current.parameters.preventAssembly).toBe(true);
});
test('should update all permission parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
// Set all to true
act(() => {
permissionKeys.forEach(key => {
result.current.updateParameter(key, true);
});
});
permissionKeys.forEach(key => {
expect(result.current.parameters[key]).toBe(true);
});
// Set all to false
act(() => {
permissionKeys.forEach(key => {
result.current.updateParameter(key, false);
});
});
permissionKeys.forEach(key => {
expect(result.current.parameters[key]).toBe(false);
});
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('preventAssembly', true);
result.current.updateParameter('preventPrinting', true);
result.current.updateParameter('preventModify', true);
});
expect(result.current.parameters.preventAssembly).toBe(true);
expect(result.current.parameters.preventPrinting).toBe(true);
expect(result.current.parameters.preventModify).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
expect(result.current.getEndpointName()).toBe('add-password');
});
test('should always validate as true', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Set some restrictions - should still be valid
act(() => {
result.current.updateParameter('preventAssembly', true);
result.current.updateParameter('preventPrinting', true);
});
expect(result.current.validateParameters()).toBe(true);
// Set all restrictions - should still be valid
act(() => {
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
permissionKeys.forEach(key => {
result.current.updateParameter(key, true);
});
});
expect(result.current.validateParameters()).toBe(true);
});
});

View File

@ -0,0 +1,64 @@
import { useState } from 'react';
export interface ChangePermissionsParameters {
preventAssembly: boolean;
preventExtractContent: boolean;
preventExtractForAccessibility: boolean;
preventFillInForm: boolean;
preventModify: boolean;
preventModifyAnnotations: boolean;
preventPrinting: boolean;
preventPrintingFaithful: boolean;
}
export interface ChangePermissionsParametersHook {
parameters: ChangePermissionsParameters;
updateParameter: (parameter: keyof ChangePermissionsParameters, value: boolean) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
}
export const defaultParameters: ChangePermissionsParameters = {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
};
export const useChangePermissionsParameters = (): ChangePermissionsParametersHook => {
const [parameters, setParameters] = useState<ChangePermissionsParameters>(defaultParameters);
const updateParameter = <K extends keyof ChangePermissionsParameters>(parameter: K, value: ChangePermissionsParameters[K]) => {
setParameters(prev => ({
...prev,
[parameter]: value,
})
);
};
const resetParameters = () => {
setParameters(defaultParameters);
};
const validateParameters = () => {
// Always valid - any combination of permissions is allowed
return true;
};
const getEndpointName = () => {
return 'add-password'; // Change Permissions is a fake endpoint for the Add Password tool
};
return {
parameters,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
};
};

View File

@ -36,7 +36,7 @@ export interface ToolOperationConfig<TParams = void> {
* - (params, files: File[]) => FormData: Multi-file processing
* Not used when customProcessor is provided.
*/
buildFormData: ((params: TParams, file: File) => FormData) | ((params: TParams, files: File[]) => FormData);
buildFormData: ((params: TParams, file: File) => FormData) | ((params: TParams, files: File[]) => FormData); /* FIX ME */
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
filePrefix: string;

View File

@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
import { SplitParameters } from './useSplitParameters';
import { SPLIT_MODES } from '../../../constants/splitConstants';

View File

@ -1,6 +1,18 @@
import { useState } from 'react';
import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode } from '../../../constants/splitConstants';
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode, SplitType } from '../../../constants/splitConstants';
export interface SplitParameters {
mode: SplitMode | '';
pages: string;
hDiv: string;
vDiv: string;
merge: boolean;
splitType: SplitType | '';
splitValue: string;
bookmarkLevel: string;
includeMetadata: boolean;
allowDuplicates: boolean;
}
export interface SplitParametersHook {
parameters: SplitParameters;

View File

@ -5,6 +5,7 @@ import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import ApiIcon from "@mui/icons-material/Api";
import CleaningServicesIcon from "@mui/icons-material/CleaningServices";
import LockIcon from "@mui/icons-material/Lock";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
@ -85,6 +86,24 @@ const toolDefinitions: Record<string, ToolDefinition> = {
description: "Remove potentially harmful elements from PDF files",
endpoints: ["sanitize-pdf"]
},
addPassword: {
id: "addPassword",
icon: <LockIcon />,
component: React.lazy(() => import("../tools/AddPassword")),
maxFiles: -1,
category: "security",
description: "Add password protection and restrictions to PDF files",
endpoints: ["add-password"]
},
changePermissions: {
id: "changePermissions",
icon: <LockIcon />,
component: React.lazy(() => import("../tools/ChangePermissions")),
maxFiles: -1,
category: "security",
description: "Change document restrictions and permissions",
endpoints: ["add-password"]
},
};

View File

@ -519,9 +519,7 @@ export class EnhancedPDFProcessingService {
this.notifyListeners();
// Force memory cleanup hint
if (typeof window !== 'undefined') {
setTimeout(() => window.gc && window.gc(), 100);
}
setTimeout(() => window?.gc?.(), 100);
}
/**

View File

@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from "react";
import { Box, Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import { useAddPasswordParameters } from "../hooks/tools/addPassword/useAddPasswordParameters";
import { useAddPasswordOperation } from "../hooks/tools/addPassword/useAddPasswordOperation";
import { useAddPasswordTips } from "../components/tooltips/useAddPasswordTips";
import { BaseToolProps } from "../types/tool";
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
const addPasswordParams = useAddPasswordParameters();
const addPasswordOperation = useAddPasswordOperation();
const addPasswordTips = useAddPasswordTips();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(addPasswordParams.getEndpointName());
useEffect(() => {
addPasswordOperation.resetResults();
onPreviewFile?.(null);
}, [addPasswordParams.parameters, selectedFiles]);
const handleAddPassword = async () => {
try {
await addPasswordOperation.executeOperation(
addPasswordParams.fullParameters,
selectedFiles
);
if (addPasswordOperation.files && onComplete) {
onComplete(addPasswordOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : t('addPassword.error.failed', 'Add password operation failed'));
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'addPassword');
setCurrentMode('viewer');
};
const handleSettingsReset = () => {
addPasswordOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode('addPassword');
};
const hasFiles = selectedFiles.length > 0;
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const passwordsCollapsed = hasResults;
const permissionsCollapsed = collapsedPermissions || hasResults;
const previewResults = useMemo(() =>
addPasswordOperation.files?.map((file, index) => ({
file,
thumbnail: addPasswordOperation.thumbnails[index]
})) || [],
[addPasswordOperation.files, addPasswordOperation.thumbnails]
);
return (
<ToolStepContainer>
<Stack gap="sm" h="94vh" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title={t('files.title', 'Files')}
isVisible={true}
isCollapsed={filesCollapsed}
isCompleted={filesCollapsed}
completedMessage={hasFiles ?
selectedFiles.length === 1
? t('files.selected.single', 'Selected: {{filename}}', { filename: selectedFiles[0].name })
: t('files.selected.multiple', 'Selected: {{count}} files', { count: selectedFiles.length })
: undefined}
>
<FileStatusIndicator
selectedFiles={selectedFiles}
placeholder={t('files.placeholder', 'Select a PDF file in the main view to get started')}
/>
</ToolStep>
{/* Passwords & Encryption Step */}
<ToolStep
title={t('addPassword.passwords.stepTitle', 'Passwords & Encryption')}
isVisible={hasFiles}
isCollapsed={passwordsCollapsed}
isCompleted={passwordsCollapsed}
onCollapsedClick={hasResults ? handleSettingsReset : undefined}
completedMessage={passwordsCollapsed ? t('addPassword.passwords.completed', 'Passwords configured') : undefined}
tooltip={addPasswordTips}
>
<AddPasswordSettings
parameters={addPasswordParams.parameters}
onParameterChange={addPasswordParams.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
{/* Permissions Step */}
<ToolStep
title={t('addPassword.permissions.stepTitle', 'Document Permissions')}
isVisible={hasFiles}
isCollapsed={permissionsCollapsed}
isCompleted={permissionsCollapsed}
onCollapsedClick={hasResults ? handleSettingsReset : () => setCollapsedPermissions(!collapsedPermissions)}
>
<ChangePermissionsSettings
parameters={addPasswordParams.permissions.parameters}
onParameterChange={addPasswordParams.permissions.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
<Box mt="md">
<OperationButton
onClick={handleAddPassword}
isLoading={addPasswordOperation.isLoading}
disabled={!addPasswordParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t('loading')}
submitText={t('addPassword.submit', 'Encrypt')}
/>
</Box>
{/* Results Step */}
<ToolStep
title={t('results.title', 'Results')}
isVisible={hasResults}
>
<Stack gap="sm">
{addPasswordOperation.status && (
<Text size="sm" c="dimmed">{addPasswordOperation.status}</Text>
)}
<ErrorNotification
error={addPasswordOperation.errorMessage}
onClose={addPasswordOperation.clearError}
/>
{addPasswordOperation.downloadUrl && (
<Button
component="a"
href={addPasswordOperation.downloadUrl}
download={addPasswordOperation.downloadFilename}
leftSection={<DownloadIcon />}
color="green"
fullWidth
mb="md"
>
{t("download", "Download")}
</Button>
)}
<ResultsPreview
files={previewResults}
onFileClick={handleThumbnailClick}
isGeneratingThumbnails={addPasswordOperation.isGeneratingThumbnails}
title={t('addPassword.results.title', 'Encrypted PDFs')}
/>
</Stack>
</ToolStep>
</Stack>
</ToolStepContainer>
);
}
export default AddPassword;

View File

@ -0,0 +1,170 @@
import { useEffect, useMemo } from "react";
import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import { useChangePermissionsParameters } from "../hooks/tools/changePermissions/useChangePermissionsParameters";
import { useChangePermissionsOperation } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
import { useChangePermissionsTips } from "../components/tooltips/useChangePermissionsTips";
import { BaseToolProps } from "../types/tool";
const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const changePermissionsParams = useChangePermissionsParameters();
const changePermissionsOperation = useChangePermissionsOperation();
const changePermissionsTips = useChangePermissionsTips();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(changePermissionsParams.getEndpointName());
useEffect(() => {
changePermissionsOperation.resetResults();
onPreviewFile?.(null);
}, [changePermissionsParams.parameters, selectedFiles]);
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');
setCurrentMode('viewer');
};
const handleSettingsReset = () => {
changePermissionsOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode('changePermissions');
};
const hasFiles = selectedFiles.length > 0;
const hasResults = changePermissionsOperation.files.length > 0 || changePermissionsOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
const previewResults = useMemo(() =>
changePermissionsOperation.files?.map((file, index) => ({
file,
thumbnail: changePermissionsOperation.thumbnails[index]
})) || [],
[changePermissionsOperation.files, changePermissionsOperation.thumbnails]
);
return (
<ToolStepContainer>
<Stack gap="sm" h="94vh" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title={t('files.title', 'Files')}
isVisible={true}
isCollapsed={filesCollapsed}
isCompleted={filesCollapsed}
completedMessage={hasFiles ?
selectedFiles.length === 1
? t('files.selected.single', 'Selected: {{filename}}', { filename: selectedFiles[0].name })
: t('files.selected.multiple', 'Selected: {{count}} files', { count: selectedFiles.length })
: undefined}
>
<FileStatusIndicator
selectedFiles={selectedFiles}
placeholder={t('files.placeholder', 'Select a PDF file in the main view to get started')}
/>
</ToolStep>
{/* Settings Step */}
<ToolStep
title={t('settings.title', 'Settings')}
isVisible={hasFiles}
isCollapsed={settingsCollapsed}
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? t('changePermissions.completed', 'Permissions changed') : undefined}
tooltip={changePermissionsTips}
>
<Stack gap="sm">
<ChangePermissionsSettings
parameters={changePermissionsParams.parameters}
onParameterChange={changePermissionsParams.updateParameter}
disabled={endpointLoading}
/>
<OperationButton
onClick={handleChangePermissions}
isLoading={changePermissionsOperation.isLoading}
disabled={!changePermissionsParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t('loading')}
submitText={t('changePermissions.submit', 'Change Permissions')}
/>
</Stack>
</ToolStep>
{/* Results Step */}
<ToolStep
title={t('results.title', 'Results')}
isVisible={hasResults}
>
<Stack gap="sm">
{changePermissionsOperation.status && (
<Text size="sm" c="dimmed">{changePermissionsOperation.status}</Text>
)}
<ErrorNotification
error={changePermissionsOperation.errorMessage}
onClose={changePermissionsOperation.clearError}
/>
{changePermissionsOperation.downloadUrl && (
<Button
component="a"
href={changePermissionsOperation.downloadUrl}
download={changePermissionsOperation.downloadFilename}
leftSection={<DownloadIcon />}
color="green"
fullWidth
mb="md"
>
{t("download", "Download")}
</Button>
)}
<ResultsPreview
files={previewResults}
onFileClick={handleThumbnailClick}
isGeneratingThumbnails={changePermissionsOperation.isGeneratingThumbnails}
title={t('changePermissions.results.title', 'Modified PDFs')}
/>
</Stack>
</ToolStep>
</Stack>
</ToolStepContainer>
);
}
export default ChangePermissions;

View File

@ -17,7 +17,7 @@ import CompressSettings from "../components/tools/compress/CompressSettings";
import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
import { BaseToolProps } from "../types/tool";
import { CompressTips } from "../components/tooltips/CompressTips";
import { useCompressTips } from "../components/tooltips/useCompressTips";
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@ -26,7 +26,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const compressParams = useCompressParameters();
const compressOperation = useCompressOperation();
const compressTips = CompressTips();
const compressTips = useCompressTips();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf");

View File

@ -18,7 +18,7 @@ import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters";
import { useOCROperation } from "../hooks/tools/ocr/useOCROperation";
import { BaseToolProps } from "../types/tool";
import { OcrTips } from "../components/tooltips/OCRTips";
import { useOCRTips } from "../components/tooltips/useOCRTips";
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@ -27,7 +27,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const ocrParams = useOCRParameters();
const ocrOperation = useOCROperation();
const ocrTips = OcrTips();
const ocrTips = useOCRTips();
// Step expansion state management
const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files');
@ -105,7 +105,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
isCollapsed={hasFiles ? filesCollapsed : false}
isCompleted={hasFiles}
onCollapsedClick={undefined}
completedMessage={hasFiles && filesCollapsed ?
completedMessage={hasFiles && filesCollapsed ?
selectedFiles.length === 1
? `Selected: ${selectedFiles[0].name}`
: `Selected: ${selectedFiles.length} files`
@ -215,4 +215,4 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
);
}
export default OCR;
export default OCR;

View File

@ -70,7 +70,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
return (
<ToolStepContainer>
<Stack gap="sm" p="sm" style={{ height: '100vh', overflow: 'auto' }}>
<Stack gap="sm" h="94vh" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title={t('sanitize.steps.files', 'Files')}

View File

@ -5,7 +5,18 @@
import { ProcessedFile } from './processing';
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr' | 'convert' | 'sanitize';
export type ModeType =
| 'viewer'
| 'pageEditor'
| 'fileEditor'
| 'merge'
| 'split'
| 'compress'
| 'ocr'
| 'convert'
| 'sanitize'
| 'addPassword'
| 'changePermissions';
export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';