Merge branch 'V2' into feature/v2/bookmarks

This commit is contained in:
Reece Browne
2025-11-20 15:46:05 +00:00
committed by GitHub
24 changed files with 598 additions and 52 deletions
@@ -38,6 +38,7 @@ public class GeneralUtils {
Set.of(
"OCR images.json",
"Prepare-pdfs-for-email.json",
"Pre-publish-sanitization.json",
"split-rotate-auto-rename.json");
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
@@ -0,0 +1,54 @@
{
"name": "Pre-publish-sanitization",
"pipeline": [
{
"operation": "/api/v1/security/sanitize-pdf",
"parameters": {
"removeJavaScript": true,
"removeEmbeddedFiles": true,
"removeXMPMetadata": true,
"removeMetadata": true,
"removeLinks": true,
"removeFonts": false
}
},
{
"operation": "/api/v1/misc/flatten",
"parameters": {
"flattenOnlyForms": true
}
},
{
"operation": "/api/v1/general/remove-annotations",
"parameters": {}
},
{
"operation": "/api/v1/misc/update-metadata",
"parameters": {
"deleteAll": true,
"author": "",
"creationDate": "",
"creator": "",
"keywords": "",
"modificationDate": "",
"producer": "",
"subject": "",
"title": "",
"trapped": ""
}
},
{
"operation": "/api/v1/misc/compress-pdf",
"parameters": {
"optimizeLevel": 3,
"expectedOutputSize": ""
}
}
],
"_examples": {
"outputDir": "{outputFolder}/{folderName}",
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
},
"outputDir": "{outputFolder}",
"outputFileName": "pre_publish_{filename}.PDF"
}
+48 -1
View File
@@ -918,6 +918,11 @@
},
"error": {
"failed": "An error occurred while merging the PDFs."
},
"tooltip": {
"header": {
"title": "Merge Settings Overview"
}
}
},
"split": {
@@ -2372,6 +2377,14 @@
"title": "About Remove Annotations",
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
},
"tooltip": {
"header": {
"title": "About Remove Annotations"
},
"description": {
"title": "What it does"
}
},
"error": {
"failed": "An error occurred while removing annotations from the PDF."
}
@@ -2834,6 +2847,9 @@
"header": {
"title": "How Auto-Rename Works"
},
"description": {
"title": "What it does"
},
"howItWorks": {
"title": "Smart Renaming",
"text": "Automatically finds the title from your PDF content and uses it as the filename.",
@@ -2841,6 +2857,9 @@
"bullet2": "Creates a clean, valid filename from the detected title",
"bullet3": "Keeps the original name if no suitable title is found"
}
},
"settings": {
"title": "About"
}
},
"adjust-contrast": {
@@ -4841,7 +4860,9 @@
"secureWorkflow": "Security Workflow",
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.",
"processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images.",
"prePublishSanitization": "Pre-publish Sanitization",
"prePublishSanitizationDesc": "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."
}
},
"colorPicker": {
@@ -4933,6 +4954,14 @@
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"tooltip": {
"header": {
"title": "About Add Attachments"
},
"description": {
"title": "What it does"
}
},
"results": {
"title": "Attachment Results"
},
@@ -5558,6 +5587,24 @@
"starting": "Backend starting up...",
"wait": "Please wait for the backend to finish launching and try again."
},
"encryptedPdfUnlock": {
"unlockPrompt": "Unlock PDF to continue",
"title": "Remove password to continue",
"description": "This PDF is password protected. Enter the password so you can continue working with it.",
"password": {
"label": "PDF password",
"placeholder": "Enter the PDF password"
},
"skip": "Skip for now",
"unlock": "Unlock & Continue",
"incorrectPassword": "Incorrect password",
"missingFile": "The selected file is no longer available.",
"emptyResponse": "Password removal did not produce a file.",
"required": "Enter the password to continue.",
"successTitle": "Password removed",
"successBodyWithName": "Password removed from {{fileName}}",
"successBody": "Password removed successfully."
},
"setup": {
"welcome": "Welcome to Stirling PDF",
"description": "Get started by choosing how you want to use Stirling PDF",
@@ -9,6 +9,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { StirlingFileStub } from '@app/types/fileContext';
@@ -56,7 +57,14 @@ const FileEditorThumbnail = ({
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
const {
pinFile,
unpinFile,
isFilePinned,
activeFiles,
actions: fileActions,
openEncryptedUnlockPrompt,
} = useFileContext();
const { state } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
@@ -77,6 +85,7 @@ const FileEditorThumbnail = ({
const isZipFile = zipFileService.isZipFileStub(file);
const pageCount = file.processedFile?.totalPages || 0;
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
const handleRef = useRef<HTMLSpanElement | null>(null);
@@ -301,6 +310,21 @@ const FileEditorThumbnail = ({
{/* Action buttons group */}
<div className={styles.headerActions}>
{isEncrypted && (
<Tooltip label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}>
<ActionIcon
aria-label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
>
<LockOpenIcon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
<ActionIcon
@@ -0,0 +1,85 @@
import { Modal, Stack, Text, Button, PasswordInput, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { type KeyboardEventHandler } from 'react';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface EncryptedPdfUnlockModalProps {
opened: boolean;
fileName?: string;
password: string;
errorMessage?: string | null;
isProcessing: boolean;
onPasswordChange: (value: string) => void;
onUnlock: () => void;
onSkip: () => void;
}
const EncryptedPdfUnlockModal = ({
opened,
fileName,
password,
errorMessage,
isProcessing,
onPasswordChange,
onUnlock,
onSkip,
}: EncryptedPdfUnlockModalProps) => {
const { t } = useTranslation();
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) {
onUnlock();
}
};
return (
<Modal
opened={opened}
onClose={onSkip}
title={t('encryptedPdfUnlock.title', 'Remove password to continue')}
centered
size="md"
closeOnClickOutside={!isProcessing}
closeOnEscape={!isProcessing}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
>
<Stack gap="md">
<Text fw={600} ta="center">{fileName}</Text>
<Text c="dimmed" ta="center">
{t(
'encryptedPdfUnlock.description',
'This PDF is password protected. Enter the password so you can continue working with it.'
)}
</Text>
<Stack gap={4}>
<PasswordInput
label={t('encryptedPdfUnlock.password.label', 'PDF password')}
placeholder={t('encryptedPdfUnlock.password.placeholder', 'Enter the PDF password')}
value={password}
onChange={(event) => onPasswordChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={isProcessing}
autoFocus
/>
{errorMessage ? (
<Text c="red" size="sm">
{errorMessage}
</Text>
) : null}
</Stack>
<Group justify="space-between">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={onSkip} disabled={isProcessing}>
{t('encryptedPdfUnlock.skip', 'Skip for now')}
</Button>
<Button onClick={onUnlock} loading={isProcessing} disabled={password.trim().length === 0}>
{t('encryptedPdfUnlock.unlock', 'Unlock & Continue')}
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EncryptedPdfUnlockModal;
@@ -4,7 +4,7 @@
* Allows selecting files to attach to PDFs.
*/
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
import { Stack, Text, Group, ActionIcon, ScrollArea, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -20,16 +20,7 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals
return (
<Stack gap="md">
<Alert color="blue" variant="light">
<Text size="sm">
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
</Text>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
</Text>
<input
type="file"
multiple
@@ -1,24 +1,9 @@
import { useTranslation } from 'react-i18next';
import { Stack, Text, Alert } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Stack } from '@mantine/core';
const RemoveAnnotationsSettings = () => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Alert
icon={<LocalIcon icon="info-rounded" width="1.2rem" height="1.2rem" />}
title={t('removeAnnotations.info.title', 'About Remove Annotations')}
color="blue"
variant="light"
>
<Text size="sm">
{t('removeAnnotations.info.description',
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
)}
</Text>
</Alert>
{/* No settings needed for this tool - description is in tooltip */}
</Stack>
);
};
@@ -0,0 +1,18 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const useAddAttachmentsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("AddAttachmentsRequest.tooltip.header.title", "About Add Attachments")
},
tips: [
{
title: t("AddAttachmentsRequest.tooltip.description.title", "What it does"),
description: t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel."),
}
]
};
};
@@ -9,6 +9,10 @@ export const useAutoRenameTips = (): TooltipContent => {
title: t("auto-rename.tooltip.header.title", "How Auto-Rename Works")
},
tips: [
{
title: t("auto-rename.tooltip.description.title", "What it does"),
description: t("auto-rename.description", "Automatically finds the title from your PDF content and uses it as the filename."),
},
{
title: t("auto-rename.tooltip.howItWorks.title", "Smart Renaming"),
bullets: [
@@ -5,6 +5,9 @@ export const useMergeTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t('merge.tooltip.header.title', 'Merge Settings Overview')
},
tips: [
{
title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const useRemoveAnnotationsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("removeAnnotations.tooltip.header.title", "About Remove Annotations")
},
tips: [
{
title: t("removeAnnotations.tooltip.description.title", "What it does"),
description: t('removeAnnotations.info.description',
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
),
}
]
};
};
+195 -4
View File
@@ -12,7 +12,7 @@
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
*/
import { useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
import { useReducer, useCallback, useEffect, useRef, useMemo, useState } from 'react';
import {
FileContextProviderProps,
FileContextSelectors,
@@ -22,17 +22,27 @@ import {
FileId,
StirlingFileStub,
StirlingFile,
createStirlingFile,
} from '@app/types/fileContext';
// Import modular components
import { fileContextReducer, initialFileContextState } from '@app/contexts/file/FileReducer';
import { createFileSelectors } from '@app/contexts/file/fileSelectors';
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from '@app/contexts/file/fileActions';
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions, createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
import { FileLifecycleManager } from '@app/contexts/file/lifecycle';
import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts';
import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext';
import { useZipConfirmation } from '@app/hooks/useZipConfirmation';
import ZipWarningModal from '@app/components/shared/ZipWarningModal';
import EncryptedPdfUnlockModal from '@app/components/shared/EncryptedPdfUnlockModal';
import { useTranslation } from 'react-i18next';
import { alert } from '@app/components/toast';
import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData';
import type { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
import apiClient from '@app/services/apiClient';
import { processResponse } from '@app/utils/toolResponseProcessor';
import { ToolOperation } from '@app/types/file';
import { handlePasswordError } from '@app/utils/toolErrorHandler';
const DEBUG = process.env.NODE_ENV === 'development';
@@ -63,6 +73,98 @@ function FileContextInner({
lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch);
}
const lifecycleManager = lifecycleManagerRef.current;
const { t } = useTranslation();
const [encryptedQueue, setEncryptedQueue] = useState<FileId[]>([]);
const [activeEncryptedFileId, setActiveEncryptedFileId] = useState<FileId | null>(null);
const [unlockPassword, setUnlockPassword] = useState('');
const [unlockError, setUnlockError] = useState<string | null>(null);
const [isUnlocking, setIsUnlocking] = useState(false);
const dismissedEncryptedFilesRef = useRef<Set<FileId>>(new Set());
const observedFileIdsRef = useRef<Set<FileId>>(new Set());
const enqueueEncryptedFiles = useCallback((fileIds: FileId[]) => {
if (fileIds.length === 0) return;
setEncryptedQueue(prevQueue => {
const existing = new Set(prevQueue);
const next = [...prevQueue];
for (const id of fileIds) {
if (dismissedEncryptedFilesRef.current.has(id)) continue;
if (id === activeEncryptedFileId) continue;
if (existing.has(id)) continue;
existing.add(id);
next.push(id);
}
return next;
});
}, [activeEncryptedFileId]);
useEffect(() => {
const previousIds = observedFileIdsRef.current;
const nextIds = new Set(state.files.ids);
const newEncryptedIds: FileId[] = [];
for (const id of state.files.ids) {
if (!previousIds.has(id)) {
const stub = state.files.byId[id];
if ((stub?.versionNumber ?? 1) <= 1 && stub?.processedFile?.isEncrypted) {
newEncryptedIds.push(id);
}
}
}
if (newEncryptedIds.length > 0) {
enqueueEncryptedFiles(newEncryptedIds);
}
observedFileIdsRef.current = nextIds;
}, [state.files.ids, state.files.byId, enqueueEncryptedFiles]);
useEffect(() => {
if (!activeEncryptedFileId && encryptedQueue.length > 0) {
setActiveEncryptedFileId(encryptedQueue[0]);
setEncryptedQueue(prev => prev.slice(1));
}
}, [activeEncryptedFileId, encryptedQueue]);
useEffect(() => {
if (activeEncryptedFileId && !state.files.ids.includes(activeEncryptedFileId)) {
setActiveEncryptedFileId(null);
}
}, [activeEncryptedFileId, state.files.ids]);
useEffect(() => {
setUnlockPassword('');
setUnlockError(null);
}, [activeEncryptedFileId]);
const handleUnlockSkip = useCallback(() => {
if (activeEncryptedFileId) {
dismissedEncryptedFilesRef.current.add(activeEncryptedFileId);
}
setActiveEncryptedFileId(null);
}, [activeEncryptedFileId]);
const promptEncryptedUnlock = useCallback((fileId: FileId) => {
const stub = stateRef.current.files.byId[fileId];
if (!stub?.processedFile?.isEncrypted) {
return;
}
dismissedEncryptedFilesRef.current.delete(fileId);
setEncryptedQueue(prevQueue => prevQueue.filter(id => id !== fileId));
setActiveEncryptedFileId(currentActiveId => {
if (currentActiveId && currentActiveId !== fileId) {
setEncryptedQueue(prevQueue => {
const withoutDuplicates = prevQueue.filter(id => id !== currentActiveId && id !== fileId);
return [currentActiveId, ...withoutDuplicates];
});
}
return fileId;
});
}, []);
// Create stable selectors (memoized once to avoid re-renders)
const selectors = useMemo<FileContextSelectors>(() =>
@@ -131,6 +233,80 @@ function FileContextInner({
return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch);
}, []);
const runAutomaticPasswordRemoval = useCallback(async (fileId: FileId, password: string): Promise<void> => {
const file = filesRef.current.get(fileId);
const parentStub = stateRef.current.files.byId[fileId];
if (!file || !parentStub) {
throw new Error(t('encryptedPdfUnlock.missingFile', 'The selected file is no longer available.'));
}
const params: RemovePasswordParameters = { password };
const formData = buildRemovePasswordFormData(params, file);
const response = await apiClient.post('/api/v1/security/remove-password', formData, {
responseType: 'blob',
suppressErrorToast: true // Handle errors in modal UI instead of toast
});
const responseFiles = await processResponse(response.data, [file]);
const unlockedFile = responseFiles[0];
if (!unlockedFile) {
throw new Error(t('encryptedPdfUnlock.emptyResponse', 'Password removal did not produce a file.'));
}
const processedMetadata = await generateProcessedFileMetadata(unlockedFile);
const thumbnail = processedMetadata?.thumbnailUrl;
const operation: ToolOperation = {
toolId: 'removePassword',
timestamp: Date.now()
};
const childStub = createChildStub(parentStub, operation, unlockedFile, thumbnail, processedMetadata);
const stirlingUnlockedFile = createStirlingFile(unlockedFile, childStub.id);
await consumeFilesWrapper([fileId], [stirlingUnlockedFile], [childStub]);
}, [consumeFilesWrapper, t]);
const handleUnlockSubmit = useCallback(async () => {
if (!activeEncryptedFileId) return;
if (!unlockPassword.trim()) {
setUnlockError(t('encryptedPdfUnlock.required', 'Enter the password to continue.'));
return;
}
setIsUnlocking(true);
setUnlockError(null);
try {
await runAutomaticPasswordRemoval(activeEncryptedFileId, unlockPassword.trim());
const fileName = stateRef.current.files.byId[activeEncryptedFileId]?.name;
alert({
alertType: 'success',
title: t('encryptedPdfUnlock.successTitle', 'Password removed'),
body: fileName
? t('encryptedPdfUnlock.successBodyWithName', {
defaultValue: 'Removed password from {{fileName}}',
fileName,
})
: t('encryptedPdfUnlock.successBody', 'Password removed successfully.'),
expandable: false,
isPersistentPopup: false,
});
dismissedEncryptedFilesRef.current.delete(activeEncryptedFileId);
setActiveEncryptedFileId(null);
} catch (error) {
const errorMessage = await handlePasswordError(
error,
t('encryptedPdfUnlock.incorrectPassword', 'Incorrect password'),
t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.')
);
setUnlockError(errorMessage);
} finally {
setIsUnlocking(false);
}
}, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
}, [indexedDB]);
@@ -199,7 +375,8 @@ function FileContextInner({
trackBlobUrl: lifecycleManager.trackBlobUrl,
cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
scheduleCleanup: (fileId: FileId, delay?: number) =>
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
lifecycleManager.scheduleCleanup(fileId, delay, stateRef),
openEncryptedUnlockPrompt: promptEncryptedUnlock
}), [
baseActions,
addRawFiles,
@@ -211,7 +388,8 @@ function FileContextInner({
pinFileWrapper,
unpinFileWrapper,
indexedDB,
enablePersistence
enablePersistence,
promptEncryptedUnlock
]);
// Split context values to minimize re-renders
@@ -225,6 +403,9 @@ function FileContextInner({
dispatch
}), [actions]);
const activeEncryptedStub = activeEncryptedFileId ? state.files.byId[activeEncryptedFileId] : undefined;
const isUnlockModalOpen = Boolean(activeEncryptedFileId && activeEncryptedStub);
// Persistence loading disabled - files only loaded on explicit user action
// useEffect(() => {
// if (!enablePersistence || !indexedDB) return;
@@ -251,6 +432,16 @@ function FileContextInner({
fileCount={confirmationState.fileCount}
zipFileName={confirmationState.fileName}
/>
<EncryptedPdfUnlockModal
opened={isUnlockModalOpen}
fileName={activeEncryptedStub?.name}
password={unlockPassword}
errorMessage={unlockError}
isProcessing={isUnlocking}
onPasswordChange={setUnlockPassword}
onUnlock={handleUnlockSubmit}
onSkip={handleUnlockSkip}
/>
</FileActionsContext.Provider>
</FileStateContext.Provider>
);
@@ -63,7 +63,7 @@ export function createProcessedFile(
thumbnail?: string,
pageRotations?: number[],
pageDimensions?: Array<{ width: number; height: number }>
) {
): ProcessedFileMetadata {
return {
totalPages: pageCount,
pages: Array.from({ length: pageCount }, (_, index) => ({
@@ -106,6 +106,10 @@ export async function generateProcessedFileMetadata(file: File): Promise<Process
// Use rotated thumbnail for file manager
processedFile.thumbnailUrl = rotatedResult.thumbnail;
if (unrotatedResult.isEncrypted || rotatedResult.isEncrypted) {
processedFile.isEncrypted = true;
}
return processedFile;
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
@@ -188,6 +188,7 @@ export function useFileContext() {
// Active files
activeFiles: selectors.getFiles(),
openEncryptedUnlockPrompt: actions.openEncryptedUnlockPrompt,
// Direct access to actions and selectors (for advanced use cases)
actions,
@@ -9,6 +9,7 @@ import { SPLIT_METHODS } from '@app/constants/splitConstants';
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });
const PrivacyIcon = () => React.createElement(LocalIcon, { icon: 'shield-lock', width: '1.5rem', height: '1.5rem' });
export function useSuggestedAutomations(): SuggestedAutomation[] {
const { t } = useTranslation();
@@ -67,6 +68,63 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
updatedAt: now,
icon: SecurityIcon,
},
{
id: "pre-publish-sanitization",
name: t("automation.suggested.prePublishSanitization", "Pre-publish Sanitization"),
description: t("automation.suggested.prePublishSanitizationDesc", "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."),
operations: [
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: true,
removeFonts: false,
}
},
{
operation: "flatten",
parameters: {
flattenOnlyForms: true,
}
},
{
operation: "removeAnnotations",
parameters: {}
},
{
operation: "changeMetadata",
parameters: {
deleteAll: true,
author: '',
creationDate: '',
creator: '',
keywords: '',
modificationDate: '',
producer: '',
subject: '',
title: '',
trapped: '',
}
},
{
operation: "compress",
parameters: {
compressionLevel: 3,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
}
},
],
createdAt: now,
updatedAt: now,
icon: PrivacyIcon,
},
{
id: "email-preparation",
name: t("automation.suggested.emailPreparation", "Email Preparation"),
@@ -40,13 +40,15 @@ export const buildChangeMetadataFormData = (parameters: ChangeMetadataParameters
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
let keyNumber = 0;
parameters.customMetadata.forEach((entry) => {
if (entry.key.trim() && entry.value.trim()) {
keyNumber += 1;
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
}
});
if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) {
parameters.customMetadata.forEach((entry) => {
if (entry.key.trim() && entry.value.trim()) {
keyNumber += 1;
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
}
});
}
return formData;
};
@@ -0,0 +1,12 @@
import { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
/**
* Builds FormData for remove password API request.
* Separated from operation config to avoid circular dependencies with FileContext.
*/
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
return formData;
};
@@ -2,14 +2,10 @@ import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData';
// Static function that can be used by both the hook and automation executor
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
return formData;
};
// Re-export for backwards compatibility with any other imports
export { buildRemovePasswordFormData };
// Static configuration object
export const removePasswordOperationConfig = {
@@ -8,10 +8,12 @@ import { useAddAttachmentsParameters } from "@app/hooks/tools/addAttachments/use
import { useAddAttachmentsOperation } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation";
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
import AddAttachmentsSettings from "@app/components/tools/addAttachments/AddAttachmentsSettings";
import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachmentsTips";
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const addAttachmentsTips = useAddAttachmentsTips();
const params = useAddAttachmentsParameters();
const operation = useAddAttachmentsOperation();
@@ -64,6 +66,7 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS),
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
isVisible: true,
tooltip: addAttachmentsTips,
content: (
<AddAttachmentsSettings
parameters={params.parameters}
+10 -3
View File
@@ -9,21 +9,28 @@ import { useAutoRenameTips } from "@app/components/tooltips/useAutoRenameTips";
const AutoRename =(props: BaseToolProps) => {
const { t } = useTranslation();
const autoRenameTips = useAutoRenameTips();
const base = useBaseTool(
'"auto-rename-pdf-file',
'auto-rename-pdf-file',
useAutoRenameParameters,
useAutoRenameOperation,
props
);
return createToolFlow({
title: { title:t("auto-rename.title", "Auto Rename PDF"), description: t("auto-rename.description", "Auto Rename PDF"), tooltip: useAutoRenameTips()},
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [],
steps: [
{
title: t("auto-rename.settings.title", "About"),
isCollapsed: false,
tooltip: autoRenameTips,
content: null,
},
],
executeButton: {
text: t("auto-rename.submit", "Auto Rename"),
isVisible: !base.hasResults,
@@ -5,9 +5,11 @@ import { useRemoveAnnotationsParameters } from "@app/hooks/tools/removeAnnotatio
import { useRemoveAnnotationsOperation } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
import { useRemoveAnnotationsTips } from "@app/components/tooltips/useRemoveAnnotationsTips";
const RemoveAnnotations = (props: BaseToolProps) => {
const { t } = useTranslation();
const removeAnnotationsTips = useRemoveAnnotationsTips();
const base = useBaseTool(
'removeAnnotations',
@@ -26,6 +28,7 @@ const RemoveAnnotations = (props: BaseToolProps) => {
title: t("removeAnnotations.settings.title", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: removeAnnotationsTips,
content: <RemoveAnnotationsSettings />,
},
],
+2
View File
@@ -23,6 +23,7 @@ export interface ProcessedFileMetadata {
pages: ProcessedFilePage[];
totalPages?: number;
lastProcessed?: number;
isEncrypted?: boolean;
[key: string]: any;
}
@@ -301,6 +302,7 @@ export interface FileContextActions {
trackBlobUrl: (url: string) => void;
scheduleCleanup: (fileId: FileId, delay?: number) => void;
cleanupFile: (fileId: FileId) => void;
openEncryptedUnlockPrompt: (fileId: FileId) => void;
}
// File selectors (separate from actions to avoid re-renders)
+2 -1
View File
@@ -5,6 +5,7 @@ export interface ThumbnailWithMetadata {
pageCount: number;
pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270)
pageDimensions?: Array<{ width: number; height: number }>;
isEncrypted?: boolean;
}
interface ColorScheme {
@@ -451,7 +452,7 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
if (error instanceof Error && error.name === "PasswordException") {
// Handle encrypted PDFs
const thumbnail = generateEncryptedPDFThumbnail(file);
return { thumbnail, pageCount: 1 };
return { thumbnail, pageCount: 1, isEncrypted: true };
}
const thumbnail = generatePlaceholderThumbnail(file);
@@ -2,6 +2,8 @@
* Standardized error handling utilities for tool operations
*/
import { normalizeAxiosErrorData } from '@app/services/errorUtils';
/**
* Default error extractor that follows the standard pattern
*/
@@ -30,4 +32,36 @@ export const createStandardErrorHandler = (fallbackMessage: string) => {
}
return fallbackMessage;
};
};
/**
* Handles password-related errors with status code checking
* @param error - The error object from axios
* @param incorrectPasswordMessage - Message to show for incorrect password (typically 500 status)
* @param fallbackMessage - Message to show for other errors
* @returns Error message string
*/
export const handlePasswordError = async (
error: any,
incorrectPasswordMessage: string,
fallbackMessage: string
): Promise<string> => {
const status = error?.response?.status;
// Handle specific error cases with user-friendly messages
if (status === 500) {
// 500 typically means incorrect password for encrypted PDFs
return incorrectPasswordMessage;
}
// For other errors, try to extract the message
const normalizedData = await normalizeAxiosErrorData(error?.response?.data);
const errorWithNormalizedData = {
...error,
response: {
...error?.response,
data: normalizedData
}
};
return extractErrorMessage(errorWithNormalizedData) || fallbackMessage;
};