Compare commits

...

5 Commits

Author SHA1 Message Date
EthanHealy01
5f911d600c
Merge branch 'V2' into feature/V2/BulkSelectionPanel 2025-09-18 11:17:11 +01:00
James Brunton
06e5205302
V2 rotate (#4452)
# Description of Changes
Add Rotate tool to V2
2025-09-18 11:04:12 +01:00
EthanHealy01
71c4288ec8
Merge branch 'V2' into feature/V2/BulkSelectionPanel 2025-09-18 11:03:40 +01:00
James Brunton
a5693ee116
V2 change metadata (#4433)
# Description of Changes
Add Change Metadata tool
2025-09-18 09:41:39 +00:00
EthanHealy01
756cbc4780
Feature/v2/remove pages (#4445)
# Description of Changes

- Addition of the remove pages tool
- Addition of the remove blank pages tool

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-18 09:51:55 +01:00
44 changed files with 2818 additions and 55 deletions

View File

@ -14,6 +14,7 @@
"@emotion/styled": "^11.14.0",
"@iconify/react": "^6.0.0",
"@mantine/core": "^8.0.1",
"@mantine/dates": "^8.0.1",
"@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "^8.0.1",
"@mui/icons-material": "^7.1.0",
@ -1653,6 +1654,22 @@
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/dates": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.0.1.tgz",
"integrity": "sha512-YCmV5jiGE9Ts2uhNS217IA1Hd5kAa8oaEtfnU0bS1sL36zKEf2s6elmzY718XdF8tFil0jJWAj0jiCrA3/udMg==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"@mantine/core": "8.0.1",
"@mantine/hooks": "8.0.1",
"dayjs": ">=1.0.0",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/dropzone": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz",
@ -4367,6 +4384,13 @@
"node": ">=18"
}
},
"node_modules/dayjs": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"license": "MIT",
"peer": true
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",

View File

@ -10,6 +10,7 @@
"@emotion/styled": "^11.14.0",
"@iconify/react": "^6.0.0",
"@mantine/core": "^8.0.1",
"@mantine/dates": "^8.0.1",
"@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "^8.0.1",
"@mui/icons-material": "^7.1.0",

View File

@ -797,9 +797,27 @@
"rotate": {
"tags": "server side",
"title": "Rotate PDF",
"header": "Rotate PDF",
"selectAngle": "Select rotation angle (in multiples of 90 degrees):",
"submit": "Rotate"
"submit": "Apply Rotation",
"error": {
"failed": "An error occurred while rotating the PDF."
},
"preview": {
"title": "Rotation Preview"
},
"rotateLeft": "Rotate Anticlockwise",
"rotateRight": "Rotate Clockwise",
"tooltip": {
"header": {
"title": "Rotate Settings Overview"
},
"description": {
"text": "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments. All pages in the PDF will be rotated. The preview shows how your document will look after rotation."
},
"controls": {
"title": "Controls",
"text": "Use the rotation buttons to adjust orientation. Left button rotates anticlockwise, right button rotates clockwise. Each click rotates by 90 degrees."
}
}
},
"convert": {
"title": "Convert",
@ -1125,15 +1143,46 @@
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",
"pageNumbers": "Pages to Remove",
"pageNumbersPlaceholder": "e.g. 1,3,5-7",
"pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7",
"pageNumbers": {
"label": "Pages to Remove",
"placeholder": "e.g., 1,3,5-8,10",
"error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)"
},
"filenamePrefix": "pages_removed",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"settings": {
"title": "Page Selection"
"title": "Settings"
},
"tooltip": {
"header": {
"title": "Remove Pages Settings"
},
"pageNumbers": {
"title": "Page Selection",
"text": "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions.",
"bullet1": "Individual pages: 1,3,5 (removes pages 1, 3, and 5)",
"bullet2": "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)",
"bullet3": "Mathematical: 2n+1 (removes odd pages)",
"bullet4": "Open ranges: 5- (removes from page 5 to end)"
},
"examples": {
"title": "Common Examples",
"text": "Here are some common page selection patterns:",
"bullet1": "Remove first page: 1",
"bullet2": "Remove last 3 pages: -3",
"bullet3": "Remove every other page: 2n",
"bullet4": "Remove specific scattered pages: 1,5,10,15"
},
"safety": {
"title": "Safety Tips",
"text": "Important considerations when removing pages:",
"bullet1": "Always preview your selection before processing",
"bullet2": "Keep a backup of your original file",
"bullet3": "Page numbers start from 1, not 0",
"bullet4": "Invalid page numbers will be ignored"
}
},
"error": {
"failed": "An error occurred whilst removing pages."
@ -1254,24 +1303,127 @@
},
"changeMetadata": {
"tags": "Title,author,date,creation,time,publisher,producer,stats",
"title": "Change Metadata",
"header": "Change Metadata",
"selectText": {
"1": "Please edit the variables you wish to change",
"2": "Delete all metadata",
"3": "Show Custom Metadata:",
"4": "Other Metadata:",
"5": "Add Custom Metadata Entry"
"submit": "Change",
"filenamePrefix": "metadata",
"settings": {
"title": "Metadata Settings"
},
"author": "Author:",
"creationDate": "Creation Date (yyyy/MM/dd HH:mm:ss):",
"creator": "Creator:",
"keywords": "Keywords:",
"modDate": "Modification Date (yyyy/MM/dd HH:mm:ss):",
"producer": "Producer:",
"subject": "Subject:",
"trapped": "Trapped:",
"submit": "Change"
"standardFields": {
"title": "Standard Fields"
},
"deleteAll": {
"label": "Remove Existing Metadata",
"checkbox": "Delete all metadata"
},
"title": {
"label": "Title",
"placeholder": "Document title"
},
"author": {
"label": "Author",
"placeholder": "Document author"
},
"subject": {
"label": "Subject",
"placeholder": "Document subject"
},
"keywords": {
"label": "Keywords",
"placeholder": "Document keywords"
},
"creator": {
"label": "Creator",
"placeholder": "Document creator"
},
"producer": {
"label": "Producer",
"placeholder": "Document producer"
},
"dates": {
"title": "Date Fields"
},
"creationDate": {
"label": "Creation Date",
"placeholder": "Creation date"
},
"modificationDate": {
"label": "Modification Date",
"placeholder": "Modification date"
},
"trapped": {
"label": "Trapped Status",
"unknown": "Unknown",
"true": "True",
"false": "False"
},
"advanced": {
"title": "Advanced Options"
},
"customFields": {
"title": "Custom Metadata",
"description": "Add custom metadata fields to the document",
"add": "Add Field",
"key": "Key",
"keyPlaceholder": "Custom key",
"value": "Value",
"valuePlaceholder": "Custom value",
"remove": "Remove"
},
"results": {
"title": "Updated PDFs"
},
"error": {
"failed": "An error occurred while changing the PDF metadata."
},
"tooltip": {
"header": {
"title": "PDF Metadata Overview"
},
"standardFields": {
"title": "Standard Fields",
"text": "Common PDF metadata fields that describe the document.",
"bullet1": "Title: Document name or heading",
"bullet2": "Author: Person who created the document",
"bullet3": "Subject: Brief description of content",
"bullet4": "Keywords: Search terms for the document",
"bullet5": "Creator/Producer: Software used to create the PDF"
},
"dates": {
"title": "Date Fields",
"text": "When the document was created and modified.",
"bullet1": "Creation Date: When original document was made",
"bullet2": "Modification Date: When last changed"
},
"options": {
"title": "Additional Options",
"text": "Custom fields and privacy controls.",
"bullet1": "Custom Metadata: Add your own key-value pairs",
"bullet2": "Trapped Status: High-quality printing setting",
"bullet3": "Delete All: Remove all metadata for privacy"
},
"deleteAll": {
"title": "Remove Existing Metadata",
"text": "Complete metadata deletion to ensure privacy."
},
"customFields": {
"title": "Custom Metadata",
"text": "Add your own custom key-value metadata pairs.",
"bullet1": "Add any custom fields relevant to your document",
"bullet2": "Examples: Department, Project, Version, Status",
"bullet3": "Both key and value are required for each entry"
},
"advanced": {
"title": "Advanced Options",
"trapped": {
"title": "Trapped Status",
"description": "Indicates if document is prepared for high-quality printing.",
"bullet1": "True: Document has been trapped for printing",
"bullet2": "False: Document has not been trapped",
"bullet3": "Unknown: Trapped status is not specified"
}
}
}
},
"fileToPDF": {
"tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint",
@ -1557,11 +1709,46 @@
"tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks",
"header": "Remove Blank Pages",
"threshold": "Pixel Whiteness Threshold:",
"thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.",
"whitePercent": "White Percent (%):",
"whitePercentDesc": "Percent of page that must be 'white' pixels to be removed",
"submit": "Remove Blanks"
"settings": {
"title": "Settings"
},
"threshold": {
"label": "Pixel Whiteness Threshold"
},
"whitePercent": {
"label": "White Percentage Threshold",
"unit": "%"
},
"includeBlankPages": {
"label": "Include detected blank pages"
},
"tooltip": {
"header": {
"title": "Remove Blank Pages Settings"
},
"threshold": {
"title": "Pixel Whiteness Threshold",
"text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.",
"bullet1": "0 = Pure black (most restrictive)",
"bullet2": "128 = Medium grey",
"bullet3": "255 = Pure white (least restrictive)"
},
"whitePercent": {
"title": "White Percentage Threshold",
"text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.",
"bullet1": "Lower values (e.g., 80%) = More pages removed",
"bullet2": "Higher values (e.g., 95%) = Only very blank pages removed",
"bullet3": "Use higher values for documents with light backgrounds"
},
"includeBlankPages": {
"title": "Include Detected Blank Pages",
"text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.",
"bullet1": "Useful for reviewing what was removed",
"bullet2": "Helps verify the detection accuracy",
"bullet3": "Can be disabled to reduce output file size"
}
},
"submit": "Remove blank pages"
},
"removeAnnotations": {
"tags": "comments,highlight,notes,markup,remove",

View File

@ -745,15 +745,46 @@
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",
"pageNumbers": "Pages to Remove",
"pageNumbersPlaceholder": "e.g. 1,3,5-7",
"pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7",
"pageNumbers": {
"label": "Pages to Remove",
"placeholder": "e.g., 1,3,5-8,10",
"error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)"
},
"filenamePrefix": "pages_removed",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"settings": {
"title": "Page Selection"
"title": "Settings"
},
"tooltip": {
"header": {
"title": "Remove Pages Settings"
},
"pageNumbers": {
"title": "Page Selection",
"text": "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions.",
"bullet1": "Individual pages: 1,3,5 (removes pages 1, 3, and 5)",
"bullet2": "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)",
"bullet3": "Mathematical: 2n+1 (removes odd pages)",
"bullet4": "Open ranges: 5- (removes from page 5 to end)"
},
"examples": {
"title": "Common Examples",
"text": "Here are some common page selection patterns:",
"bullet1": "Remove first page: 1",
"bullet2": "Remove last 3 pages: -3",
"bullet3": "Remove every other page: 2n",
"bullet4": "Remove specific scattered pages: 1,5,10,15"
},
"safety": {
"title": "Safety Tips",
"text": "Important considerations when removing pages:",
"bullet1": "Always preview your selection before processing",
"bullet2": "Keep a backup of your original file",
"bullet3": "Page numbers start from 1, not 0",
"bullet4": "Invalid page numbers will be ignored"
}
},
"error": {
"failed": "An error occurred while removing pages."
@ -1080,11 +1111,46 @@
"tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks",
"header": "Remove Blank Pages",
"threshold": "Pixel Whiteness Threshold:",
"thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.",
"whitePercent": "White Percent (%):",
"whitePercentDesc": "Percent of page that must be 'white' pixels to be removed",
"submit": "Remove Blanks"
"settings": {
"title": "Settings"
},
"threshold": {
"label": "Pixel Whiteness Threshold"
},
"whitePercent": {
"label": "White Percentage Threshold",
"unit": "%"
},
"includeBlankPages": {
"label": "Include detected blank pages"
},
"tooltip": {
"header": {
"title": "Remove Blank Pages Settings"
},
"threshold": {
"title": "Pixel Whiteness Threshold",
"text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.",
"bullet1": "0 = Pure black (most restrictive)",
"bullet2": "128 = Medium gray",
"bullet3": "255 = Pure white (least restrictive)"
},
"whitePercent": {
"title": "White Percentage Threshold",
"text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.",
"bullet1": "Lower values (e.g., 80%) = More pages removed",
"bullet2": "Higher values (e.g., 95%) = Only very blank pages removed",
"bullet3": "Use higher values for documents with light backgrounds"
},
"includeBlankPages": {
"title": "Include Detected Blank Pages",
"text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.",
"bullet1": "Useful for reviewing what was removed",
"bullet2": "Helps verify the detection accuracy",
"bullet3": "Can be disabled to reduce output file size"
}
},
"submit": "Remove blank pages"
},
"removeAnnotations": {
"tags": "comments,highlight,notes,markup,remove",

View File

@ -0,0 +1,99 @@
import { Stack, Divider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangeMetadataParameters, createCustomMetadataFunctions } from "../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
import { useMetadataExtraction } from "../../../hooks/tools/changeMetadata/useMetadataExtraction";
import DeleteAllStep from "./steps/DeleteAllStep";
import StandardMetadataStep from "./steps/StandardMetadataStep";
import DocumentDatesStep from "./steps/DocumentDatesStep";
import AdvancedOptionsStep from "./steps/AdvancedOptionsStep";
interface ChangeMetadataSingleStepProps {
parameters: ChangeMetadataParameters;
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
disabled?: boolean;
}
const ChangeMetadataSingleStep = ({
parameters,
onParameterChange,
disabled = false
}: ChangeMetadataSingleStepProps) => {
const { t } = useTranslation();
// Get custom metadata functions using the utility
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = createCustomMetadataFunctions(
parameters,
onParameterChange
);
// Extract metadata from uploaded files
const { isExtractingMetadata } = useMetadataExtraction({
updateParameter: onParameterChange,
});
const isDeleteAllEnabled = parameters.deleteAll;
const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata;
return (
<Stack gap="md">
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t('changeMetadata.deleteAll.label', 'Delete All Metadata')}
</Text>
<DeleteAllStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
<Divider />
{/* Standard Metadata Fields */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t('changeMetadata.standardFields.title', 'Standard Metadata')}
</Text>
<StandardMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Document Dates */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t('changeMetadata.dates.title', 'Document Dates')}
</Text>
<DocumentDatesStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Advanced Options */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t('changeMetadata.advanced.title', 'Advanced Options')}
</Text>
<AdvancedOptionsStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
</Stack>
);
};
export default ChangeMetadataSingleStep;

View File

@ -0,0 +1,60 @@
import { Stack, Select, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
import { TrappedStatus } from "../../../../types/metadata";
import CustomMetadataStep from "./CustomMetadataStep";
interface AdvancedOptionsStepProps {
parameters: ChangeMetadataParameters;
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
disabled?: boolean;
addCustomMetadata: (key?: string, value?: string) => void;
removeCustomMetadata: (id: string) => void;
updateCustomMetadata: (id: string, key: string, value: string) => void;
}
const AdvancedOptionsStep = ({
parameters,
onParameterChange,
disabled = false,
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata
}: AdvancedOptionsStepProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
{/* Trapped Status */}
<Select
label={t('changeMetadata.trapped.label', 'Trapped Status')}
value={parameters.trapped}
onChange={(value) => {
if (value) {
onParameterChange('trapped', value as TrappedStatus);
}
}}
disabled={disabled || parameters.deleteAll}
data={[
{ value: TrappedStatus.UNKNOWN, label: t('changeMetadata.trapped.unknown', 'Unknown') },
{ value: TrappedStatus.TRUE, label: t('changeMetadata.trapped.true', 'True') },
{ value: TrappedStatus.FALSE, label: t('changeMetadata.trapped.false', 'False') }
]}
/>
<Divider />
{/* Custom Metadata */}
<CustomMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
);
};
export default AdvancedOptionsStep;

View File

@ -0,0 +1,74 @@
import { Stack, TextInput, Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
interface CustomMetadataStepProps {
parameters: ChangeMetadataParameters;
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
disabled?: boolean;
addCustomMetadata: (key?: string, value?: string) => void;
removeCustomMetadata: (id: string) => void;
updateCustomMetadata: (id: string, key: string, value: string) => void;
}
const CustomMetadataStep = ({
parameters,
disabled = false,
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata
}: CustomMetadataStepProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text size="sm" fw={500}>
{t('changeMetadata.customFields.title', 'Custom Metadata')}
</Text>
<Button
size="xs"
variant="light"
onClick={() => addCustomMetadata()}
disabled={disabled}
>
{t('changeMetadata.customFields.add', 'Add Field')}
</Button>
</Group>
{parameters.customMetadata.length > 0 && (
<Text size="xs" c="dimmed">
{t('changeMetadata.customFields.description', 'Add custom metadata fields to the document')}
</Text>
)}
{parameters.customMetadata.map((entry) => (
<Stack key={entry.id} gap="xs">
<TextInput
placeholder={t('changeMetadata.customFields.keyPlaceholder', 'Custom key')}
value={entry.key}
onChange={(e) => updateCustomMetadata(entry.id, e.target.value, entry.value)}
disabled={disabled}
/>
<TextInput
placeholder={t('changeMetadata.customFields.valuePlaceholder', 'Custom value')}
value={entry.value}
onChange={(e) => updateCustomMetadata(entry.id, entry.key, e.target.value)}
disabled={disabled}
/>
<Button
size="xs"
variant="light"
color="red"
onClick={() => removeCustomMetadata(entry.id)}
disabled={disabled}
>
{t('changeMetadata.customFields.remove', 'Remove')}
</Button>
</Stack>
))}
</Stack>
);
};
export default CustomMetadataStep;

View File

@ -0,0 +1,28 @@
import { Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
interface DeleteAllStepProps {
parameters: ChangeMetadataParameters;
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
disabled?: boolean;
}
const DeleteAllStep = ({
parameters,
onParameterChange,
disabled = false
}: DeleteAllStepProps) => {
const { t } = useTranslation();
return (
<Checkbox
label={t('changeMetadata.deleteAll.checkbox', 'Delete all metadata')}
checked={parameters.deleteAll}
onChange={(e) => onParameterChange('deleteAll', e.target.checked)}
disabled={disabled}
/>
);
};
export default DeleteAllStep;

View File

@ -0,0 +1,42 @@
import { Stack } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
interface DocumentDatesStepProps {
parameters: ChangeMetadataParameters;
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
disabled?: boolean;
}
const DocumentDatesStep = ({
parameters,
onParameterChange,
disabled = false
}: DocumentDatesStepProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<DateTimePicker
label={t('changeMetadata.creationDate.label', 'Creation Date')}
placeholder={t('changeMetadata.creationDate.placeholder', 'Creation date')}
value={parameters.creationDate}
onChange={(date) => onParameterChange('creationDate', date ? new Date(date) : null)}
disabled={disabled}
clearable
/>
<DateTimePicker
label={t('changeMetadata.modificationDate.label', 'Modification Date')}
placeholder={t('changeMetadata.modificationDate.placeholder', 'Modification date')}
value={parameters.modificationDate}
onChange={(date) => onParameterChange('modificationDate', date ? new Date(date) : null)}
disabled={disabled}
clearable
/>
</Stack>
);
};
export default DocumentDatesStep;

View File

@ -0,0 +1,71 @@
import { Stack, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
interface StandardMetadataStepProps {
parameters: ChangeMetadataParameters;
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
disabled?: boolean;
}
const StandardMetadataStep = ({
parameters,
onParameterChange,
disabled = false
}: StandardMetadataStepProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<TextInput
label={t('changeMetadata.title.label', 'Title')}
placeholder={t('changeMetadata.title.placeholder', 'Document title')}
value={parameters.title}
onChange={(e) => onParameterChange('title', e.target.value)}
disabled={disabled}
/>
<TextInput
label={t('changeMetadata.author.label', 'Author')}
placeholder={t('changeMetadata.author.placeholder', 'Document author')}
value={parameters.author}
onChange={(e) => onParameterChange('author', e.target.value)}
disabled={disabled}
/>
<TextInput
label={t('changeMetadata.subject.label', 'Subject')}
placeholder={t('changeMetadata.subject.placeholder', 'Document subject')}
value={parameters.subject}
onChange={(e) => onParameterChange('subject', e.target.value)}
disabled={disabled}
/>
<TextInput
label={t('changeMetadata.keywords.label', 'Keywords')}
placeholder={t('changeMetadata.keywords.placeholder', 'Document keywords')}
value={parameters.keywords}
onChange={(e) => onParameterChange('keywords', e.target.value)}
disabled={disabled}
/>
<TextInput
label={t('changeMetadata.creator.label', 'Creator')}
placeholder={t('changeMetadata.creator.placeholder', 'Document creator')}
value={parameters.creator}
onChange={(e) => onParameterChange('creator', e.target.value)}
disabled={disabled}
/>
<TextInput
label={t('changeMetadata.producer.label', 'Producer')}
placeholder={t('changeMetadata.producer.placeholder', 'Document producer')}
value={parameters.producer}
onChange={(e) => onParameterChange('producer', e.target.value)}
disabled={disabled}
/>
</Stack>
);
};
export default StandardMetadataStep;

View File

@ -0,0 +1,75 @@
import { Stack, Text, Checkbox, Slider, NumberInput, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import NumberInputWithUnit from "../shared/NumberInputWithUnit";
import { RemoveBlanksParameters } from "../../../hooks/tools/removeBlanks/useRemoveBlanksParameters";
interface RemoveBlanksSettingsProps {
parameters: RemoveBlanksParameters;
onParameterChange: <K extends keyof RemoveBlanksParameters>(key: K, value: RemoveBlanksParameters[K]) => void;
disabled?: boolean;
}
const RemoveBlanksSettings = ({ parameters, onParameterChange, disabled = false }: RemoveBlanksSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="lg" mt="md">
<Stack gap="xs">
<NumberInputWithUnit
label={t('removeBlanks.threshold.label', 'Pixel Whiteness Threshold')}
value={parameters.threshold}
onChange={(v) => onParameterChange('threshold', typeof v === 'string' ? Number(v) : v)}
unit=''
min={0}
max={255}
disabled={disabled}
/>
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('removeBlanks.whitePercent.label', 'White Percent')}
</Text>
<Group align="center">
<NumberInput
value={parameters.whitePercent}
onChange={(v) => onParameterChange('whitePercent', typeof v === 'number' ? v : 0.1)}
min={0.1}
max={100}
step={0.1}
size="sm"
rightSection="%"
style={{ width: '80px' }}
disabled={disabled}
/>
<Slider
value={parameters.whitePercent}
onChange={(value) => onParameterChange('whitePercent', value)}
min={0.1}
max={100}
step={0.1}
style={{ flex: 1 }}
disabled={disabled}
/>
</Group>
</Stack>
<Stack gap="xs">
<Checkbox
checked={parameters.includeBlankPages}
onChange={(event) => onParameterChange('includeBlankPages', event.currentTarget.checked)}
disabled={disabled}
label={
<div>
<Text size="sm">{t('removeBlanks.includeBlankPages.label', 'Include detected blank pages')}</Text>
</div>
}
/>
</Stack>
</Stack>
);
};
export default RemoveBlanksSettings;

View File

@ -0,0 +1,39 @@
import { Stack, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { RemovePagesParameters } from "../../../hooks/tools/removePages/useRemovePagesParameters";
import { validatePageNumbers } from "../../../utils/pageSelection";
interface RemovePagesSettingsProps {
parameters: RemovePagesParameters;
onParameterChange: <K extends keyof RemovePagesParameters>(key: K, value: RemovePagesParameters[K]) => void;
disabled?: boolean;
}
const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }: RemovePagesSettingsProps) => {
const { t } = useTranslation();
const handlePageNumbersChange = (value: string) => {
// Allow user to type naturally - don't normalize input in real-time
onParameterChange('pageNumbers', value);
};
// Check if current input is valid
const isValid = validatePageNumbers(parameters.pageNumbers);
const hasValue = parameters.pageNumbers.trim().length > 0;
return (
<Stack gap="md">
<TextInput
label={t('removePages.pageNumbers.label', 'Pages to Remove')}
value={parameters.pageNumbers}
onChange={(event) => handlePageNumbersChange(event.currentTarget.value)}
placeholder={t('removePages.pageNumbers.placeholder', 'e.g., 1,3,5-8,10')}
disabled={disabled}
required
error={hasValue && !isValid ? t('removePages.pageNumbers.error', 'Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)') : undefined}
/>
</Stack>
);
};
export default RemovePagesSettings;

View File

@ -0,0 +1,104 @@
import { useMemo, useState, useEffect } from "react";
import { Stack, Text, Box, ActionIcon, Group, Center } from "@mantine/core";
import { useTranslation } from "react-i18next";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import { RotateParametersHook } from "../../../hooks/tools/rotate/useRotateParameters";
import { useSelectedFiles } from "../../../contexts/file/fileHooks";
import DocumentThumbnail from "../../shared/filePreview/DocumentThumbnail";
interface RotateSettingsProps {
parameters: RotateParametersHook;
disabled?: boolean;
}
const RotateSettings = ({ parameters, disabled = false }: RotateSettingsProps) => {
const { t } = useTranslation();
const { selectedFileStubs } = useSelectedFiles();
// Get the first selected file for preview
const selectedStub = useMemo(() => {
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
}, [selectedFileStubs]);
// Get thumbnail for the selected file
const [thumbnail, setThumbnail] = useState<string | null>(null);
useEffect(() => {
setThumbnail(selectedStub?.thumbnailUrl || null);
}, [selectedStub]);
// Calculate current angle display
const currentAngle = parameters.parameters.angle;
return (
<Stack gap="md">
{/* Thumbnail Preview Section */}
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("rotate.preview.title", "Rotation Preview")}
</Text>
<Center>
<Box
style={{
width: '280px',
height: '280px',
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--mantine-color-gray-0)',
overflow: 'hidden',
}}
>
<Box
style={{
width: '100%',
height: '100%',
transform: `rotate(${currentAngle}deg)`,
transition: 'transform 0.3s ease-in-out',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DocumentThumbnail
file={selectedStub}
thumbnail={thumbnail}
/>
</Box>
</Box>
</Center>
</Stack>
{/* Rotation Controls */}
<Group justify="center" gap="lg">
<ActionIcon
size="xl"
variant="outline"
onClick={parameters.rotateAnticlockwise}
disabled={disabled}
aria-label={t("rotate.rotateLeft", "Rotate Anticlockwise")}
title={t("rotate.rotateLeft", "Rotate Anticlockwise")}
>
<RotateLeftIcon style={{ fontSize: '1.5rem' }} />
</ActionIcon>
<ActionIcon
size="xl"
variant="outline"
onClick={parameters.rotateClockwise}
disabled={disabled}
aria-label={t("rotate.rotateRight", "Rotate Clockwise")}
title={t("rotate.rotateRight", "Rotate Clockwise")}
>
<RotateRightIcon style={{ fontSize: '1.5rem' }} />
</ActionIcon>
</Group>
</Stack>
);
};
export default RotateSettings;

View File

@ -0,0 +1,108 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useDeleteAllTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("changeMetadata.tooltip.deleteAll.title", "Remove Existing Metadata")
},
tips: [
{
description: t("changeMetadata.tooltip.deleteAll.text", "Complete metadata deletion to ensure privacy."),
}
]
};
};
export const useStandardMetadataTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("changeMetadata.tooltip.standardFields.title", "Standard Fields")
},
tips: [
{
description: t("changeMetadata.tooltip.standardFields.text", "Common PDF metadata fields that describe the document."),
bullets: [
t("changeMetadata.tooltip.standardFields.bullet1", "Title: Document name or heading"),
t("changeMetadata.tooltip.standardFields.bullet2", "Author: Person who created the document"),
t("changeMetadata.tooltip.standardFields.bullet3", "Subject: Brief description of content"),
t("changeMetadata.tooltip.standardFields.bullet4", "Keywords: Search terms for the document"),
t("changeMetadata.tooltip.standardFields.bullet5", "Creator/Producer: Software used to create the PDF")
]
}
]
};
};
export const useDocumentDatesTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("changeMetadata.tooltip.dates.title", "Date Fields")
},
tips: [
{
description: t("changeMetadata.tooltip.dates.text", "When the document was created and modified."),
bullets: [
t("changeMetadata.tooltip.dates.bullet1", "Creation Date: When original document was made"),
t("changeMetadata.tooltip.dates.bullet2", "Modification Date: When last changed"),
]
}
]
};
};
export const useCustomMetadataTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("changeMetadata.tooltip.customFields.title", "Custom Metadata")
},
tips: [
{
description: t("changeMetadata.tooltip.customFields.text", "Add your own custom key-value metadata pairs."),
bullets: [
t("changeMetadata.tooltip.customFields.bullet1", "Add any custom fields relevant to your document"),
t("changeMetadata.tooltip.customFields.bullet2", "Examples: Department, Project, Version, Status"),
t("changeMetadata.tooltip.customFields.bullet3", "Both key and value are required for each entry")
]
}
]
};
};
export const useAdvancedOptionsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("changeMetadata.tooltip.advanced.title", "Advanced Options")
},
tips: [
{
title: t("changeMetadata.tooltip.advanced.trapped.title", "Trapped Status"),
description: t("changeMetadata.tooltip.advanced.trapped.description", "Indicates if document is prepared for high-quality printing."),
bullets: [
t("changeMetadata.tooltip.advanced.trapped.bullet1", "True: Document has been trapped for printing"),
t("changeMetadata.tooltip.advanced.trapped.bullet2", "False: Document has not been trapped"),
t("changeMetadata.tooltip.advanced.trapped.bullet3", "Unknown: Trapped status is not specified")
]
},
{
title: t("changeMetadata.tooltip.customFields.title", "Custom Metadata"),
description: t("changeMetadata.tooltip.customFields.text", "Add your own custom key-value metadata pairs."),
bullets: [
t("changeMetadata.tooltip.customFields.bullet1", "Add any custom fields relevant to your document"),
t("changeMetadata.tooltip.customFields.bullet2", "Examples: Department, Project, Version, Status"),
t("changeMetadata.tooltip.customFields.bullet3", "Both key and value are required for each entry")
]
}
]
};
};

View File

@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useRemoveBlanksTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("removeBlanks.tooltip.header.title", "Remove Blank Pages Settings"),
},
tips: [
{
title: t("removeBlanks.tooltip.threshold.title", "Pixel Whiteness Threshold"),
description: t("removeBlanks.tooltip.threshold.text", "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page."),
bullets: [
t("removeBlanks.tooltip.threshold.bullet1", "0 = Pure black (most restrictive)"),
t("removeBlanks.tooltip.threshold.bullet2", "128 = Medium gray"),
t("removeBlanks.tooltip.threshold.bullet3", "255 = Pure white (least restrictive)")
]
},
{
title: t("removeBlanks.tooltip.whitePercent.title", "White Percentage Threshold"),
description: t("removeBlanks.tooltip.whitePercent.text", "Sets the minimum percentage of white pixels required for a page to be considered blank and removed."),
bullets: [
t("removeBlanks.tooltip.whitePercent.bullet1", "Lower values (e.g., 80%) = More pages removed"),
t("removeBlanks.tooltip.whitePercent.bullet2", "Higher values (e.g., 95%) = Only very blank pages removed"),
t("removeBlanks.tooltip.whitePercent.bullet3", "Use higher values for documents with light backgrounds")
]
},
{
title: t("removeBlanks.tooltip.includeBlankPages.title", "Include Detected Blank Pages"),
description: t("removeBlanks.tooltip.includeBlankPages.text", "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document."),
bullets: [
t("removeBlanks.tooltip.includeBlankPages.bullet1", "Useful for reviewing what was removed"),
t("removeBlanks.tooltip.includeBlankPages.bullet2", "Helps verify the detection accuracy"),
t("removeBlanks.tooltip.includeBlankPages.bullet3", "Can be disabled to reduce output file size")
]
}
]
};
};

View File

@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useRemovePagesTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("removePages.tooltip.header.title", "Remove Pages Settings"),
},
tips: [
{
title: t("removePages.tooltip.pageNumbers.title", "Page Selection"),
description: t("removePages.tooltip.pageNumbers.text", "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions."),
bullets: [
t("removePages.tooltip.pageNumbers.bullet1", "Individual pages: 1,3,5 (removes pages 1, 3, and 5)"),
t("removePages.tooltip.pageNumbers.bullet2", "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)"),
t("removePages.tooltip.pageNumbers.bullet3", "Mathematical: 2n+1 (removes odd pages)"),
t("removePages.tooltip.pageNumbers.bullet4", "Open ranges: 5- (removes from page 5 to end)")
]
},
{
title: t("removePages.tooltip.examples.title", "Common Examples"),
description: t("removePages.tooltip.examples.text", "Here are some common page selection patterns:"),
bullets: [
t("removePages.tooltip.examples.bullet1", "Remove first page: 1"),
t("removePages.tooltip.examples.bullet2", "Remove last 3 pages: -3"),
t("removePages.tooltip.examples.bullet3", "Remove every other page: 2n"),
t("removePages.tooltip.examples.bullet4", "Remove specific scattered pages: 1,5,10,15")
]
}
]
};
};

View File

@ -0,0 +1,21 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useRotateTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("rotate.tooltip.header.title", "Rotate Settings Overview"),
},
tips: [
{
description: t("rotate.tooltip.description.text", "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments. All pages in the PDF will be rotated. The preview shows how your document will look after rotation."),
},
{
title: t("rotate.tooltip.controls.title", "Controls"),
description: t("rotate.tooltip.controls.text", "Use the rotation buttons to adjust orientation. Left button rotates anticlockwise, right button rotates clockwise. Each click rotates by 90 degrees."),
},
],
};
};

View File

@ -123,12 +123,12 @@ export function useStirlingFileStub(fileId: FileId): { file?: File; record?: Sti
/**
* Hook for all files (use sparingly - causes re-renders on file list changes)
*/
export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
export function useAllFiles(): { files: StirlingFile[]; fileStubs: StirlingFileStub[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getFiles(),
records: selectors.getStirlingFileStubs(),
fileStubs: selectors.getStirlingFileStubs(),
fileIds: state.files.ids
}), [state.files.ids, selectors]);
}
@ -136,12 +136,12 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
/**
* Hook for selected files (optimized for selection-based UI)
*/
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } {
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedFileStubs: StirlingFileStub[]; selectedFileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
selectedFiles: selectors.getSelectedFiles(),
selectedRecords: selectors.getSelectedStirlingFileStubs(),
selectedFileStubs: selectors.getSelectedStirlingFileStubs(),
selectedFileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}

View File

@ -8,6 +8,8 @@ import ConvertPanel from "../tools/Convert";
import Sanitize from "../tools/Sanitize";
import AddPassword from "../tools/AddPassword";
import ChangePermissions from "../tools/ChangePermissions";
import RemoveBlanks from "../tools/RemoveBlanks";
import RemovePages from "../tools/RemovePages";
import RemovePassword from "../tools/RemovePassword";
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
import AddWatermark from "../tools/AddWatermark";
@ -18,6 +20,8 @@ import SingleLargePage from "../tools/SingleLargePage";
import UnlockPdfForms from "../tools/UnlockPdfForms";
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
import Flatten from "../tools/Flatten";
import Rotate from "../tools/Rotate";
import ChangeMetadata from "../tools/ChangeMetadata";
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
@ -35,6 +39,8 @@ import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
import CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
@ -48,12 +54,14 @@ import ConvertSettings from "../components/tools/convert/ConvertSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import RotateSettings from "../components/tools/rotate/RotateSettings";
import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale";
import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings';
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -292,10 +300,14 @@ export function useFlatToolRegistry(): ToolRegistry {
"change-metadata": {
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.changeMetadata.title", "Change Metadata"),
component: null,
component: ChangeMetadata,
description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
maxFiles: -1,
endpoints: ["update-metadata"],
operationConfig: changeMetadataOperationConfig,
settingsComponent: ChangeMetadataSingleStep,
},
// Page Formatting
@ -310,10 +322,14 @@ export function useFlatToolRegistry(): ToolRegistry {
rotate: {
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.rotate.title", "Rotate"),
component: null,
component: Rotate,
description: t("home.rotate.desc", "Easily rotate your PDFs."),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["rotate-pdf"],
operationConfig: rotateOperationConfig,
settingsComponent: RotateSettings,
},
split: {
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
@ -414,18 +430,22 @@ export function useFlatToolRegistry(): ToolRegistry {
removePages: {
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removePages.title", "Remove Pages"),
component: null,
component: RemovePages,
description: t("home.removePages.desc", "Remove specific pages from a PDF document"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: 1,
endpoints: ["remove-pages"],
},
"remove-blank-pages": {
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removeBlanks.title", "Remove Blank Pages"),
component: null,
component: RemoveBlanks,
description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: 1,
endpoints: ["remove-blanks"],
},
"remove-annotations": {
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,

View File

@ -0,0 +1,144 @@
import { buildChangeMetadataFormData } from './useChangeMetadataOperation';
import { ChangeMetadataParameters } from './useChangeMetadataParameters';
import { TrappedStatus } from '../../../types/metadata';
import { describe, expect, test } from 'vitest';
describe('buildChangeMetadataFormData', () => {
const mockFile = new File(['test'], 'test.pdf', { type: 'application/pdf' });
const defaultParams: ChangeMetadataParameters = {
title: '',
author: '',
subject: '',
keywords: '',
creator: '',
producer: '',
creationDate: null,
modificationDate: null,
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
};
test.each([
{
name: 'should build FormData with basic parameters',
params: {
...defaultParams,
title: 'Test Document',
author: 'John Doe',
deleteAll: true,
},
expectedFormData: {
fileInput: mockFile,
title: 'Test Document',
author: 'John Doe',
deleteAll: 'true',
},
},
{
name: 'should handle empty string values',
params: defaultParams,
expectedFormData: {
title: '',
author: '',
subject: '',
keywords: '',
creator: '',
producer: '',
creationDate: '',
modificationDate: '',
trapped: TrappedStatus.UNKNOWN,
deleteAll: 'false',
},
},
{
name: 'should include all standard metadata fields',
params: {
...defaultParams,
title: 'Test Title',
author: 'Test Author',
subject: 'Test Subject',
keywords: 'test, keywords',
creator: 'Test Creator',
producer: 'Test Producer',
creationDate: new Date('2025/01/17 14:30:00'),
modificationDate: new Date('2025/01/17 15:30:00'),
trapped: TrappedStatus.TRUE,
},
expectedFormData: {
title: 'Test Title',
author: 'Test Author',
subject: 'Test Subject',
keywords: 'test, keywords',
creator: 'Test Creator',
producer: 'Test Producer',
creationDate: '2025/01/17 14:30:00',
modificationDate: '2025/01/17 15:30:00',
trapped: TrappedStatus.TRUE,
},
},
])('$name', ({ params, expectedFormData }) => {
const formData = buildChangeMetadataFormData(params, mockFile);
Object.entries(expectedFormData).forEach(([key, value]) => {
expect(formData.get(key)).toBe(value);
});
});
test('should handle custom metadata with proper indexing', () => {
const params = {
...defaultParams,
customMetadata: [
{ key: 'Department', value: 'Engineering', id: 'custom1' },
{ key: 'Project', value: 'Test Project', id: 'custom2' },
{ key: 'Status', value: 'Draft', id: 'custom3' },
],
};
const formData = buildChangeMetadataFormData(params, mockFile);
expect(formData.get('allRequestParams[customKey1]')).toBe('Department');
expect(formData.get('allRequestParams[customValue1]')).toBe('Engineering');
expect(formData.get('allRequestParams[customKey2]')).toBe('Project');
expect(formData.get('allRequestParams[customValue2]')).toBe('Test Project');
expect(formData.get('allRequestParams[customKey3]')).toBe('Status');
expect(formData.get('allRequestParams[customValue3]')).toBe('Draft');
});
test('should skip custom metadata with empty keys or values', () => {
const params = {
...defaultParams,
customMetadata: [
{ key: 'Department', value: 'Engineering', id: 'custom1' },
{ key: '', value: 'No Key', id: 'custom2' },
{ key: 'No Value', value: '', id: 'custom3' },
{ key: ' ', value: 'Whitespace Key', id: 'custom4' },
{ key: 'Valid', value: 'Valid Value', id: 'custom5' },
],
};
const formData = buildChangeMetadataFormData(params, mockFile);
expect(formData.get('allRequestParams[customKey1]')).toBe('Department');
expect(formData.get('allRequestParams[customValue1]')).toBe('Engineering');
expect(formData.get('allRequestParams[customKey2]')).toBe('Valid');
expect(formData.get('allRequestParams[customValue2]')).toBe('Valid Value');
expect(formData.get('allRequestParams[customKey3]')).toBeNull();
expect(formData.get('allRequestParams[customKey4]')).toBeNull();
});
test('should trim whitespace from custom metadata', () => {
const params = {
...defaultParams,
customMetadata: [
{ key: ' Department ', value: ' Engineering ', id: 'custom1' },
],
};
const formData = buildChangeMetadataFormData(params, mockFile);
expect(formData.get('allRequestParams[customKey1]')).toBe('Department');
expect(formData.get('allRequestParams[customValue1]')).toBe('Engineering');
});
});

View File

@ -0,0 +1,71 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { ChangeMetadataParameters, defaultParameters } from './useChangeMetadataParameters';
// Helper function to format Date object to string
const formatDateForBackend = (date: Date | null): string => {
if (!date) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
};
// Static function that can be used by both the hook and automation executor
export const buildChangeMetadataFormData = (parameters: ChangeMetadataParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Standard metadata fields
formData.append("title", parameters.title || "");
formData.append("author", parameters.author || "");
formData.append("subject", parameters.subject || "");
formData.append("keywords", parameters.keywords || "");
formData.append("creator", parameters.creator || "");
formData.append("producer", parameters.producer || "");
// Date fields - convert Date objects to strings
formData.append("creationDate", formatDateForBackend(parameters.creationDate));
formData.append("modificationDate", formatDateForBackend(parameters.modificationDate));
// Trapped status
formData.append("trapped", parameters.trapped || "");
// Delete all metadata flag
formData.append("deleteAll", parameters.deleteAll.toString());
// 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());
}
});
return formData;
};
// Static configuration object
export const changeMetadataOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildChangeMetadataFormData,
operationType: 'changeMetadata',
endpoint: '/api/v1/misc/update-metadata',
defaultParameters,
} as const;
export const useChangeMetadataOperation = () => {
const { t } = useTranslation();
return useToolOperation<ChangeMetadataParameters>({
...changeMetadataOperationConfig,
filePrefix: t('changeMetadata.filenamePrefix', 'metadata') + '_',
getErrorMessage: createStandardErrorHandler(t('changeMetadata.error.failed', 'An error occurred while changing the PDF metadata.'))
});
};

View File

@ -0,0 +1,168 @@
import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useChangeMetadataParameters } from './useChangeMetadataParameters';
import { TrappedStatus } from '../../../types/metadata';
import { describe, expect, test } from 'vitest';
describe('useChangeMetadataParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
describe('parameter updates', () => {
test.each([
{ paramName: 'title', value: 'Test Document' },
{ paramName: 'author', value: 'John Doe' },
{ paramName: 'subject', value: 'Test Subject' },
{ paramName: 'keywords', value: 'test, metadata' },
{ paramName: 'creator', value: 'Test Creator' },
{ paramName: 'producer', value: 'Test Producer' },
{ paramName: 'creationDate', value: new Date('2025/01/17 14:30:00') },
{ paramName: 'modificationDate', value: new Date('2025/01/17 15:30:00') },
{ paramName: 'trapped', value: TrappedStatus.TRUE },
{ paramName: 'deleteAll', value: true },
] as const)('should update $paramName parameter', ({ paramName, value }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
});
describe('validation', () => {
test.each([
{ description: 'deleteAll is true', updates: { deleteAll: true }, expected: true },
{ description: 'has title', updates: { title: 'Test Document' }, expected: true },
{ description: 'has author', updates: { author: 'John Doe' }, expected: true },
{ description: 'has subject', updates: { subject: 'Test Subject' }, expected: true },
{ description: 'has keywords', updates: { keywords: 'test' }, expected: true },
{ description: 'has creator', updates: { creator: 'Test Creator' }, expected: true },
{ description: 'has producer', updates: { producer: 'Test Producer' }, expected: true },
{ description: 'has creation date', updates: { creationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'has modification date', updates: { modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'has trapped status', updates: { trapped: TrappedStatus.TRUE }, expected: true },
{ description: 'no meaningful content', updates: {}, expected: false },
{ description: 'whitespace only', updates: { title: ' ', author: ' ' }, expected: false },
])('should validate correctly when $description', ({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
Object.entries(updates).forEach(([key, value]) => {
result.current.updateParameter(key as keyof typeof updates, value);
});
});
expect(result.current.validateParameters()).toBe(expected);
});
test.each([
{ description: 'valid creation date', updates: { title: 'Test', creationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'valid modification date', updates: { title: 'Test', modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'empty dates are valid', updates: { title: 'Test', creationDate: null, modificationDate: null }, expected: true },
])('should validate dates correctly with $description', ({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
Object.entries(updates).forEach(([key, value]) => {
result.current.updateParameter(key as keyof typeof updates, value);
});
});
expect(result.current.validateParameters()).toBe(expected);
});
});
describe('custom metadata', () => {
test('should add custom metadata with sequential IDs', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
expect(result.current.parameters.customMetadata).toHaveLength(1);
expect(result.current.parameters.customMetadata[0]).toEqual({
key: '',
value: '',
id: expect.stringMatching(/^custom\d+$/)
});
});
test('should remove custom metadata by ID', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
const customId = result.current.parameters.customMetadata[0].id;
act(() => {
result.current.removeCustomMetadata(customId);
});
expect(result.current.parameters.customMetadata).toHaveLength(0);
});
test('should update custom metadata by ID', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
const customId = result.current.parameters.customMetadata[0].id;
act(() => {
result.current.updateCustomMetadata(customId, 'Department', 'Engineering');
});
expect(result.current.parameters.customMetadata[0]).toEqual({
key: 'Department',
value: 'Engineering',
id: customId
});
});
test('should validate with custom metadata', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
const customId = result.current.parameters.customMetadata[0].id;
act(() => {
result.current.updateCustomMetadata(customId, 'Department', 'Engineering');
});
expect(result.current.validateParameters()).toBe(true);
});
test('should generate unique IDs for multiple custom entries', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
for (let i = 0; i < 3; i++) {
act(() => {
result.current.addCustomMetadata();
});
}
const ids = result.current.parameters.customMetadata.map(entry => entry.id);
expect(ids).toHaveLength(3);
expect(new Set(ids).size).toBe(3); // All unique
expect(ids.every(id => id.startsWith('custom'))).toBe(true);
});
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
expect(result.current.getEndpointName()).toBe('update-metadata');
});
});

View File

@ -0,0 +1,136 @@
import { BaseParameters } from '../../../types/parameters';
import { TrappedStatus, CustomMetadataEntry } from '../../../types/metadata';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface ChangeMetadataParameters extends BaseParameters {
// Standard PDF metadata fields
title: string;
author: string;
subject: string;
keywords: string;
creator: string;
producer: string;
// Date fields
creationDate: Date | null;
modificationDate: Date | null;
// Trapped status
trapped: TrappedStatus;
// Custom metadata entries
customMetadata: CustomMetadataEntry[];
// Delete all metadata option
deleteAll: boolean;
}
export const defaultParameters: ChangeMetadataParameters = {
title: '',
author: '',
subject: '',
keywords: '',
creator: '',
producer: '',
creationDate: null,
modificationDate: null,
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
};
// Global counter for custom metadata IDs
let customMetadataIdCounter = 1;
// Utility functions that can work with external parameters
export const createCustomMetadataFunctions = (
parameters: ChangeMetadataParameters,
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void
) => {
const addCustomMetadata = (key: string = '', value: string = '') => {
const newEntry: CustomMetadataEntry = {
key,
value,
id: `custom${customMetadataIdCounter++}`,
};
onParameterChange('customMetadata', [
...parameters.customMetadata,
newEntry,
]);
};
const removeCustomMetadata = (id: string) => {
onParameterChange('customMetadata',
parameters.customMetadata.filter(entry => entry.id !== id)
);
};
const updateCustomMetadata = (id: string, key: string, value: string) => {
onParameterChange('customMetadata',
parameters.customMetadata.map(entry =>
entry.id === id ? { ...entry, key, value } : entry
)
);
};
return {
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata
};
};
// Validation function
const validateParameters = (params: ChangeMetadataParameters): boolean => {
// If deleteAll is true, no other validation needed
if (params.deleteAll) {
return true;
}
// At least one field should have content for the operation to be meaningful
const hasStandardMetadata = !!(
params.title.trim()
|| params.author.trim()
|| params.subject.trim()
|| params.keywords.trim()
|| params.creator.trim()
|| params.producer.trim()
|| params.creationDate
|| params.modificationDate
|| params.trapped !== TrappedStatus.UNKNOWN
);
const hasCustomMetadata = params.customMetadata.some(
entry => entry.key.trim() && entry.value.trim()
);
return hasStandardMetadata || hasCustomMetadata;
};
export type ChangeMetadataParametersHook = BaseParametersHook<ChangeMetadataParameters> & {
addCustomMetadata: (key?: string, value?: string) => void;
removeCustomMetadata: (id: string) => void;
updateCustomMetadata: (id: string, key: string, value: string) => void;
};
export const useChangeMetadataParameters = (): ChangeMetadataParametersHook => {
const base = useBaseParameters({
defaultParameters,
endpointName: 'update-metadata',
validateFn: validateParameters,
});
// Use the utility functions with the hook's parameters and updateParameter
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = createCustomMetadataFunctions(
base.parameters,
base.updateParameter,
);
return {
...base,
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata
};
};

View File

@ -0,0 +1,70 @@
import { useState, useEffect, useRef } from "react";
import { extractPDFMetadata } from "../../../services/pdfMetadataService";
import { useSelectedFiles } from "../../../contexts/file/fileHooks";
import { ChangeMetadataParameters } from "./useChangeMetadataParameters";
interface MetadataExtractionParams {
updateParameter: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
}
export const useMetadataExtraction = (params: MetadataExtractionParams) => {
const { selectedFiles } = useSelectedFiles();
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
const [hasExtractedMetadata, setHasExtractedMetadata] = useState(false);
const previousFileCountRef = useRef(0);
// Reset extraction state only when files are cleared (length goes to 0)
useEffect(() => {
if (previousFileCountRef.current > 0 && selectedFiles.length === 0) {
setHasExtractedMetadata(false);
}
previousFileCountRef.current = selectedFiles.length;
}, [selectedFiles]);
// Extract metadata from first file when files change
useEffect(() => {
const extractMetadata = async () => {
if (selectedFiles.length === 0) {
return;
}
const firstFile = selectedFiles[0];
if (hasExtractedMetadata) {
return;
}
setIsExtractingMetadata(true);
const result = await extractPDFMetadata(firstFile);
if (result.success) {
const metadata = result.metadata;
// Pre-populate all fields with extracted metadata
params.updateParameter('title', metadata.title);
params.updateParameter('author', metadata.author);
params.updateParameter('subject', metadata.subject);
params.updateParameter('keywords', metadata.keywords);
params.updateParameter('creator', metadata.creator);
params.updateParameter('producer', metadata.producer);
params.updateParameter('creationDate', metadata.creationDate ? new Date(metadata.creationDate) : null);
params.updateParameter('modificationDate', metadata.modificationDate ? new Date(metadata.modificationDate) : null);
params.updateParameter('trapped', metadata.trapped);
params.updateParameter('customMetadata', metadata.customMetadata);
setHasExtractedMetadata(true);
} else {
console.warn('Failed to extract metadata:', result.error);
}
setIsExtractingMetadata(false);
};
extractMetadata();
}, [selectedFiles, hasExtractedMetadata, params]);
return {
isExtractingMetadata,
hasExtractedMetadata,
};
};

View File

@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemoveBlanksParameters, defaultParameters } from './useRemoveBlanksParameters';
import { useToolResources } from '../shared/useToolResources';
export const buildRemoveBlanksFormData = (parameters: RemoveBlanksParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('threshold', String(parameters.threshold));
formData.append('whitePercent', String(parameters.whitePercent));
// Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP
return formData;
};
export const removeBlanksOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveBlanksFormData,
operationType: 'remove-blanks',
endpoint: '/api/v1/misc/remove-blanks',
defaultParameters,
} as const satisfies ToolOperationConfig<RemoveBlanksParameters>;
export const useRemoveBlanksOperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
const responseHandler = useCallback(async (blob: Blob): Promise<File[]> => {
// Backend always returns a ZIP file containing the processed PDFs
return await extractZipFiles(blob);
}, [extractZipFiles]);
return useToolOperation<RemoveBlanksParameters>({
...removeBlanksOperationConfig,
responseHandler,
getErrorMessage: createStandardErrorHandler(
t('removeBlanks.error.failed', 'Failed to remove blank pages')
)
});
};

View File

@ -0,0 +1,26 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface RemoveBlanksParameters extends BaseParameters {
threshold: number; // 0-255
whitePercent: number; // 0.1-100
includeBlankPages: boolean; // whether to include detected blank pages in output
}
export const defaultParameters: RemoveBlanksParameters = {
threshold: 10,
whitePercent: 99.9,
includeBlankPages: false,
};
export type RemoveBlanksParametersHook = BaseParametersHook<RemoveBlanksParameters>;
export const useRemoveBlanksParameters = (): RemoveBlanksParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-blanks',
validateFn: (p) => p.threshold >= 0 && p.threshold <= 255 && p.whitePercent > 0 && p.whitePercent <= 100,
});
};

View File

@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemovePagesParameters, defaultParameters } from './useRemovePagesParameters';
// import { useToolResources } from '../shared/useToolResources';
export const buildRemovePagesFormData = (parameters: RemovePagesParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
const cleaned = parameters.pageNumbers.replace(/\s+/g, '');
formData.append('pageNumbers', cleaned);
return formData;
};
export const removePagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePagesFormData,
operationType: 'remove-pages',
endpoint: '/api/v1/general/remove-pages',
defaultParameters,
} as const satisfies ToolOperationConfig<RemovePagesParameters>;
export const useRemovePagesOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemovePagesParameters>({
...removePagesOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('removePages.error.failed', 'Failed to remove pages')
)
});
};

View File

@ -0,0 +1,21 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
import { validatePageNumbers } from '../../../utils/pageSelection';
export interface RemovePagesParameters extends BaseParameters {
pageNumbers: string; // comma-separated page numbers or ranges (e.g., "1,3,5-8")
}
export const defaultParameters: RemovePagesParameters = {
pageNumbers: '',
};
export type RemovePagesParametersHook = BaseParametersHook<RemovePagesParameters>;
export const useRemovePagesParameters = (): RemovePagesParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-pages',
validateFn: (p) => validatePageNumbers(p.pageNumbers),
});
};

View File

@ -0,0 +1,101 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useRotateOperation } from './useRotateOperation';
import type { RotateParameters } from './useRotateParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation');
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Mock the error handler
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
}));
// Import the mocked function
import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation';
describe('useRotateOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<RotateParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: '',
isLoading: false,
errorMessage: null,
status: '',
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test.each([
{ angle: 0, expectedNormalized: 0 },
{ angle: 90, expectedNormalized: 90 },
{ angle: 180, expectedNormalized: 180 },
{ angle: 270, expectedNormalized: 270 },
{ angle: 360, expectedNormalized: 0 },
{ angle: -90, expectedNormalized: 270 },
{ angle: -180, expectedNormalized: 180 },
{ angle: -270, expectedNormalized: 90 },
{ angle: 450, expectedNormalized: 90 },
])('should create form data correctly with angle $angle (normalized to $expectedNormalized)', ({ angle, expectedNormalized }) => {
renderHook(() => useRotateOperation());
const callArgs = getToolConfig();
const testParameters: RotateParameters = { angle };
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = callArgs.buildFormData(testParameters, testFile);
// Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile);
// Verify angle parameter is normalized for backend
expect(formData.get('angle')).toBe(expectedNormalized.toString());
});
test('should use correct translation for error messages', () => {
renderHook(() => useRotateOperation());
expect(mockT).toHaveBeenCalledWith(
'rotate.error.failed',
'An error occurred while rotating the PDF.'
);
});
test.each([
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
{ property: 'endpoint' as const, expectedValue: '/api/v1/general/rotate-pdf' },
{ property: 'operationType' as const, expectedValue: 'rotate' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useRotateOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});

View File

@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RotateParameters, defaultParameters, normalizeAngle } from './useRotateParameters';
// Static configuration that can be used by both the hook and automation executor
export const buildRotateFormData = (parameters: RotateParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Normalize angle for backend (0, 90, 180, 270)
formData.append("angle", normalizeAngle(parameters.angle).toString());
return formData;
};
// Static configuration object
export const rotateOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRotateFormData,
operationType: 'rotate',
endpoint: '/api/v1/general/rotate-pdf',
defaultParameters,
} as const;
export const useRotateOperation = () => {
const { t } = useTranslation();
return useToolOperation<RotateParameters>({
...rotateOperationConfig,
getErrorMessage: createStandardErrorHandler(t('rotate.error.failed', 'An error occurred while rotating the PDF.'))
});
};

View File

@ -0,0 +1,160 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRotateParameters, defaultParameters, normalizeAngle } from './useRotateParameters';
describe('useRotateParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.parameters.angle).toBe(0);
expect(result.current.hasRotation).toBe(false);
});
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useRotateParameters());
// Default should be valid
expect(result.current.validateParameters()).toBe(true);
// Set invalid angle
act(() => {
result.current.updateParameter('angle', 45);
});
expect(result.current.validateParameters()).toBe(false);
// Set valid angle
act(() => {
result.current.updateParameter('angle', 90);
});
expect(result.current.validateParameters()).toBe(true);
});
test('should rotate clockwise correctly', () => {
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(90);
expect(result.current.hasRotation).toBe(true);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(180);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(270);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(360);
expect(normalizeAngle(result.current.parameters.angle)).toBe(0);
expect(result.current.hasRotation).toBe(false);
});
test('should rotate anticlockwise correctly', () => {
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-90);
expect(result.current.hasRotation).toBe(true);
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-180);
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-270);
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-360);
expect(normalizeAngle(result.current.parameters.angle)).toBe(0);
expect(result.current.hasRotation).toBe(false);
});
test('should normalize angles correctly', () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.normalizeAngle(360)).toBe(0);
expect(result.current.normalizeAngle(450)).toBe(90);
expect(result.current.normalizeAngle(-90)).toBe(270);
expect(result.current.normalizeAngle(-180)).toBe(180);
});
test('should reset parameters correctly', () => {
const { result } = renderHook(() => useRotateParameters());
// Set some rotation
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(90);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(180);
// Reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.hasRotation).toBe(false);
});
test('should update parameters', () => {
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.updateParameter('angle', 450);
});
expect(result.current.parameters.angle).toBe(450);
expect(normalizeAngle(result.current.parameters.angle)).toBe(90);
act(() => {
result.current.updateParameter('angle', -90);
});
expect(result.current.parameters.angle).toBe(-90);
expect(normalizeAngle(result.current.parameters.angle)).toBe(270);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.getEndpointName()).toBe('rotate-pdf');
});
test('should detect rotation state correctly', () => {
const { result } = renderHook(() => useRotateParameters());
// Initially no rotation
expect(result.current.hasRotation).toBe(false);
// After rotation
act(() => {
result.current.rotateClockwise();
});
expect(result.current.hasRotation).toBe(true);
// After full rotation (360 degrees) - 3 more clicks to complete 360°
for (let i = 0; i < 3; i++) {
act(() => {
result.current.rotateClockwise();
});
}
expect(result.current.hasRotation).toBe(false);
});
});

View File

@ -0,0 +1,67 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
import { useMemo, useCallback } from 'react';
// Normalize angle to number between 0 and 359
export const normalizeAngle = (angle: number): number => {
return ((angle % 360) + 360) % 360;
};
export interface RotateParameters extends BaseParameters {
angle: number; // Current rotation angle (0, 90, 180, 270)
}
export const defaultParameters: RotateParameters = {
angle: 0,
};
export type RotateParametersHook = BaseParametersHook<RotateParameters> & {
rotateClockwise: () => void;
rotateAnticlockwise: () => void;
hasRotation: boolean;
normalizeAngle: (angle: number) => number;
};
export const useRotateParameters = (): RotateParametersHook => {
const baseHook = useBaseParameters({
defaultParameters,
endpointName: 'rotate-pdf',
validateFn: (params) => {
// Angle must be a multiple of 90
return params.angle % 90 === 0;
},
});
// Rotate clockwise by 90 degrees
const rotateClockwise = useCallback(() => {
baseHook.updateParameter('angle', baseHook.parameters.angle + 90);
}, [baseHook]);
// Rotate anticlockwise by 90 degrees
const rotateAnticlockwise = useCallback(() => {
baseHook.updateParameter('angle', baseHook.parameters.angle - 90);
}, [baseHook]);
// Check if rotation will actually change the document
const hasRotation = useMemo(() => {
const normalized = normalizeAngle(baseHook.parameters.angle);
return normalized !== 0;
}, [baseHook.parameters.angle, normalizeAngle]);
// Override updateParameter - no longer normalize angles here
const updateParameter = useCallback(<K extends keyof RotateParameters>(
parameter: K,
value: RotateParameters[K]
) => {
baseHook.updateParameter(parameter, value);
}, [baseHook]);
return {
...baseHook,
updateParameter,
rotateClockwise,
rotateAnticlockwise,
hasRotation,
normalizeAngle,
};
};

View File

@ -1,4 +1,4 @@
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useRef } from 'react';
import { useFileSelection } from '../../../contexts/FileContext';
import { useEndpointEnabled } from '../../useEndpointConfig';
import { BaseToolProps } from '../../../types/tool';
@ -45,6 +45,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
// File selection
const { selectedFiles } = useFileSelection();
const previousFileCount = useRef(selectedFiles.length);
// Tool-specific hooks
const params = useParams();
@ -67,6 +68,18 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
}
}, [selectedFiles.length]);
// Reset parameters when transitioning from 0 files to at least 1 file
useEffect(() => {
const currentFileCount = selectedFiles.length;
const prevFileCount = previousFileCount.current;
if (prevFileCount === 0 && currentFileCount > 0) {
params.resetParameters();
}
previousFileCount.current = currentFileCount;
}, [selectedFiles.length]);
// Standard handlers
const handleExecute = useCallback(async () => {
try {

View File

@ -1,4 +1,5 @@
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '../vite-env.d.ts';
import './index.css'; // Import Tailwind CSS
import React from 'react';

View File

@ -0,0 +1,181 @@
import { pdfWorkerManager } from './pdfWorkerManager';
import { FileAnalyzer } from './fileAnalyzer';
import { TrappedStatus, CustomMetadataEntry, ExtractedPDFMetadata } from '../types/metadata';
import { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
export interface MetadataExtractionResult {
success: true;
metadata: ExtractedPDFMetadata;
}
export interface MetadataExtractionError {
success: false;
error: string;
}
export type MetadataExtractionResponse = MetadataExtractionResult | MetadataExtractionError;
/**
* Utility to format PDF date strings to required format (yyyy/MM/dd HH:mm:ss)
* Handles PDF date format: "D:YYYYMMDDHHmmSSOHH'mm'" or standard date strings
*/
function formatPDFDate(dateString: string): string {
if (!dateString) {
return '';
}
let date: Date;
// Check if it's a PDF date format (starts with "D:")
if (dateString.startsWith('D:')) {
// Parse PDF date format: D:YYYYMMDDHHmmSSOHH'mm'
const dateStr = dateString.substring(2); // Remove "D:"
// Extract date parts
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6));
const day = parseInt(dateStr.substring(6, 8));
const hour = parseInt(dateStr.substring(8, 10)) || 0;
const minute = parseInt(dateStr.substring(10, 12)) || 0;
const second = parseInt(dateStr.substring(12, 14)) || 0;
// Create date object (month is 0-indexed)
date = new Date(year, month - 1, day, hour, minute, second);
} else {
// Try parsing as regular date string
date = new Date(dateString);
}
if (isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Convert PDF.js trapped value to TrappedStatus enum
* PDF.js returns trapped as { name: "True" | "False" } object
*/
function convertTrappedStatus(trapped: unknown): TrappedStatus {
if (trapped && typeof trapped === 'object' && 'name' in trapped) {
const name = (trapped as Record<string, string>).name?.toLowerCase();
if (name === 'true') return TrappedStatus.TRUE;
if (name === 'false') return TrappedStatus.FALSE;
}
return TrappedStatus.UNKNOWN;
}
/**
* Extract custom metadata fields from PDF.js info object
* Custom metadata is nested under the "Custom" key
*/
function extractCustomMetadata(custom: unknown): CustomMetadataEntry[] {
const customMetadata: CustomMetadataEntry[] = [];
let customIdCounter = 1;
// Check if there's a Custom object containing the custom metadata
if (typeof custom === 'object' && custom !== null) {
const customObj = custom as Record<string, unknown>;
Object.entries(customObj).forEach(([key, value]) => {
if (value != null && value !== '') {
const entry = {
key,
value: String(value),
id: `custom${customIdCounter++}`
};
customMetadata.push(entry);
}
});
}
return customMetadata;
}
/**
* Safely cleanup PDF document with error handling
*/
function cleanupPdfDocument(pdfDoc: PDFDocumentProxy | null): void {
if (pdfDoc) {
try {
pdfWorkerManager.destroyDocument(pdfDoc);
} catch (cleanupError) {
console.warn('Failed to cleanup PDF document:', cleanupError);
}
}
}
function getStringMetadata(info: Record<string, unknown>, key: string): string {
if (typeof info[key] === 'string') {
return info[key];
} else {
return '';
}
}
/**
* Extract all metadata from a PDF file
* Returns a result object with success/error state
*/
export async function extractPDFMetadata(file: File): Promise<MetadataExtractionResponse> {
// Use existing PDF validation
const isValidPDF = await FileAnalyzer.isValidPDF(file);
if (!isValidPDF) {
return {
success: false,
error: 'File is not a valid PDF'
};
}
let pdfDoc: PDFDocumentProxy | null = null;
let arrayBuffer: ArrayBuffer;
let metadata;
try {
arrayBuffer = await file.arrayBuffer();
pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true
});
metadata = await pdfDoc.getMetadata();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
cleanupPdfDocument(pdfDoc);
return {
success: false,
error: `Failed to read PDF: ${errorMessage}`
};
}
const info = metadata.info as Record<string, unknown>;
// Safely extract metadata with proper type checking
const extractedMetadata: ExtractedPDFMetadata = {
title: getStringMetadata(info, 'Title'),
author: getStringMetadata(info, 'Author'),
subject: getStringMetadata(info, 'Subject'),
keywords: getStringMetadata(info, 'Keywords'),
creator: getStringMetadata(info, 'Creator'),
producer: getStringMetadata(info, 'Producer'),
creationDate: formatPDFDate(getStringMetadata(info, 'CreationDate')),
modificationDate: formatPDFDate(getStringMetadata(info, 'ModDate')),
trapped: convertTrappedStatus(info.Trapped),
customMetadata: extractCustomMetadata(info.Custom),
};
cleanupPdfDocument(pdfDoc);
return {
success: true,
metadata: extractedMetadata
};
}

View File

@ -6,11 +6,12 @@
*/
import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
class PDFWorkerManager {
private static instance: PDFWorkerManager;
private activeDocuments = new Set<any>();
private activeDocuments = new Set<PDFDocumentProxy>();
private workerCount = 0;
private maxWorkers = 10; // Limit concurrent workers
private isInitialized = false;
@ -48,7 +49,7 @@ class PDFWorkerManager {
stopAtErrors?: boolean;
verbosity?: number;
} = {}
): Promise<any> {
): Promise<PDFDocumentProxy> {
// Wait if we've hit the worker limit
if (this.activeDocuments.size >= this.maxWorkers) {
await this.waitForAvailableWorker();
@ -104,7 +105,7 @@ class PDFWorkerManager {
/**
* Properly destroy a PDF document and clean up resources
*/
destroyDocument(pdf: any): void {
destroyDocument(pdf: PDFDocumentProxy): void {
if (this.activeDocuments.has(pdf)) {
try {
pdf.destroy();

View File

@ -0,0 +1,164 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import DeleteAllStep from "../components/tools/changeMetadata/steps/DeleteAllStep";
import StandardMetadataStep from "../components/tools/changeMetadata/steps/StandardMetadataStep";
import DocumentDatesStep from "../components/tools/changeMetadata/steps/DocumentDatesStep";
import AdvancedOptionsStep from "../components/tools/changeMetadata/steps/AdvancedOptionsStep";
import { useChangeMetadataParameters } from "../hooks/tools/changeMetadata/useChangeMetadataParameters";
import { useChangeMetadataOperation } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
import { useMetadataExtraction } from "../hooks/tools/changeMetadata/useMetadataExtraction";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import {
useDeleteAllTips,
useStandardMetadataTips,
useDocumentDatesTips,
useAdvancedOptionsTips
} from "../components/tooltips/useChangeMetadataTips";
enum MetadataStep {
NONE = 'none',
DELETE_ALL = 'deleteAll',
STANDARD_METADATA = 'standardMetadata',
DOCUMENT_DATES = 'documentDates',
ADVANCED_OPTIONS = 'advancedOptions'
}
const ChangeMetadata = (props: BaseToolProps) => {
const { t } = useTranslation();
// Individual tooltips for each step
const deleteAllTips = useDeleteAllTips();
const standardMetadataTips = useStandardMetadataTips();
const documentDatesTips = useDocumentDatesTips();
const advancedOptionsTips = useAdvancedOptionsTips();
// Individual step collapse states - only one can be open at a time
const [openStep, setOpenStep] = useState<MetadataStep>(MetadataStep.DELETE_ALL);
const base = useBaseTool(
'changeMetadata',
useChangeMetadataParameters,
useChangeMetadataOperation,
props,
);
// Extract metadata from uploaded files
const { isExtractingMetadata } = useMetadataExtraction(base.params);
// Compute actual collapsed state based on results and accordion behavior
const getActualCollapsedState = (stepName: MetadataStep) => {
return (!base.hasFiles || base.hasResults) ? true : openStep !== stepName;
};
// Handle step toggle for accordion behavior
const handleStepToggle = (stepName: MetadataStep) => {
if (base.hasResults) {
if (base.settingsCollapsed) {
base.handleSettingsReset();
}
return;
}
setOpenStep(openStep === stepName ? MetadataStep.NONE : stepName);
};
// Create step objects
const createStandardMetadataStep = () => ({
title: t("changeMetadata.standardFields.title", "Standard Fields"),
isCollapsed: getActualCollapsedState(MetadataStep.STANDARD_METADATA),
onCollapsedClick: () => handleStepToggle(MetadataStep.STANDARD_METADATA),
tooltip: standardMetadataTips,
content: (
<StandardMetadataStep
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading || isExtractingMetadata}
/>
),
});
const createDocumentDatesStep = () => ({
title: t("changeMetadata.dates.title", "Date Fields"),
isCollapsed: getActualCollapsedState(MetadataStep.DOCUMENT_DATES),
onCollapsedClick: () => handleStepToggle(MetadataStep.DOCUMENT_DATES),
tooltip: documentDatesTips,
content: (
<DocumentDatesStep
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading || isExtractingMetadata}
/>
),
});
const createAdvancedOptionsStep = () => ({
title: t("changeMetadata.advanced.title", "Advanced Options"),
isCollapsed: getActualCollapsedState(MetadataStep.ADVANCED_OPTIONS),
onCollapsedClick: () => handleStepToggle(MetadataStep.ADVANCED_OPTIONS),
tooltip: advancedOptionsTips,
content: (
<AdvancedOptionsStep
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading || isExtractingMetadata}
addCustomMetadata={base.params.addCustomMetadata}
removeCustomMetadata={base.params.removeCustomMetadata}
updateCustomMetadata={base.params.updateCustomMetadata}
/>
),
});
// Build steps array based on deleteAll state
const buildSteps = () => {
const steps = [
{
title: t("changeMetadata.deleteAll.label", "Remove Existing Metadata"),
isCollapsed: getActualCollapsedState(MetadataStep.DELETE_ALL),
onCollapsedClick: () => handleStepToggle(MetadataStep.DELETE_ALL),
tooltip: deleteAllTips,
content: (
<DeleteAllStep
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading || isExtractingMetadata}
/>
),
},
];
if (!base.params.parameters.deleteAll) {
steps.push(
createStandardMetadataStep(),
createDocumentDatesStep(),
createAdvancedOptionsStep()
);
}
return steps;
};
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: buildSteps(),
executeButton: {
text: t("changeMetadata.submit", "Update Metadata"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("changeMetadata.results.title", "Updated PDFs"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ChangeMetadata as ToolComponent;

View File

@ -16,7 +16,7 @@ const Merge = (props: BaseToolProps) => {
// File selection hooks for custom sorting
const { fileIds } = useAllFiles();
const { selectedRecords } = useSelectedFiles();
const { selectedFileStubs } = useSelectedFiles();
const { reorderFiles } = useFileManagement();
const base = useBaseTool(
@ -29,23 +29,23 @@ const Merge = (props: BaseToolProps) => {
// Custom file sorting logic for merge tool
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
const sortedRecords = [...selectedRecords].sort((recordA, recordB) => {
const sortedStubs = [...selectedFileStubs].sort((stubA, stubB) => {
let comparison = 0;
switch (sortType) {
case 'filename':
comparison = recordA.name.localeCompare(recordB.name);
comparison = stubA.name.localeCompare(stubB.name);
break;
case 'dateModified':
comparison = recordA.lastModified - recordB.lastModified;
comparison = stubA.lastModified - stubB.lastModified;
break;
}
return ascending ? comparison : -comparison;
});
const selectedIds = sortedRecords.map(record => record.id);
const selectedIds = sortedStubs.map(record => record.id);
const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
reorderFiles([...selectedIds, ...deselectedIds]);
}, [selectedRecords, fileIds, reorderFiles]);
}, [selectedFileStubs, fileIds, reorderFiles]);
return createToolFlow({
files: {

View File

@ -0,0 +1,70 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { useRemoveBlanksParameters } from "../hooks/tools/removeBlanks/useRemoveBlanksParameters";
import { useRemoveBlanksOperation } from "../hooks/tools/removeBlanks/useRemoveBlanksOperation";
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
import { useRemoveBlanksTips } from "../components/tooltips/useRemoveBlanksTips";
const RemoveBlanks = (props: BaseToolProps) => {
const { t } = useTranslation();
const tooltipContent = useRemoveBlanksTips();
const base = useBaseTool(
'remove-blanks',
useRemoveBlanksParameters,
useRemoveBlanksOperation,
props
);
const settingsContent = (
<RemoveBlanksSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
);
const handleSettingsClick = () => {
if (base.hasResults) {
base.handleSettingsReset();
}
};
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("removeBlanks.settings.title", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: handleSettingsClick,
content: settingsContent,
tooltip: tooltipContent,
},
],
executeButton: {
text: t("removeBlanks.submit", "Remove blank pages"),
loadingText: t("loading"),
onClick: base.handleExecute,
isVisible: !base.hasResults,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("removeBlanks.results.title", "Removed Blank Pages"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
RemoveBlanks.tool = () => useRemoveBlanksOperation;
export default RemoveBlanks as ToolComponent;

View File

@ -0,0 +1,64 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { useRemovePagesParameters } from "../hooks/tools/removePages/useRemovePagesParameters";
import { useRemovePagesOperation } from "../hooks/tools/removePages/useRemovePagesOperation";
import RemovePagesSettings from "../components/tools/removePages/RemovePagesSettings";
import { useRemovePagesTips } from "../components/tooltips/useRemovePagesTips";
const RemovePages = (props: BaseToolProps) => {
const { t } = useTranslation();
const tooltipContent = useRemovePagesTips();
const base = useBaseTool(
'remove-pages',
useRemovePagesParameters,
useRemovePagesOperation,
props
);
const settingsContent = (
<RemovePagesSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("removePages.settings.title", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: settingsContent,
tooltip: tooltipContent,
},
],
executeButton: {
text: t("removePages.submit", "Remove Pages"),
loadingText: t("loading"),
onClick: base.handleExecute,
isVisible: !base.hasResults,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("removePages.results.title", "Pages Removed"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
RemovePages.tool = () => useRemovePagesOperation;
export default RemovePages as ToolComponent;

View File

@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import RotateSettings from "../components/tools/rotate/RotateSettings";
import { useRotateParameters } from "../hooks/tools/rotate/useRotateParameters";
import { useRotateOperation } from "../hooks/tools/rotate/useRotateOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useRotateTips } from "../components/tooltips/useRotateTips";
const Rotate = (props: BaseToolProps) => {
const { t } = useTranslation();
const rotateTips = useRotateTips();
const base = useBaseTool(
'rotate',
useRotateParameters,
useRotateOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: "Settings",
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: rotateTips,
content: (
<RotateSettings
parameters={base.params}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("rotate.submit", "Apply Rotation"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("rotate.title", "Rotation Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default Rotate as ToolComponent;

View File

@ -0,0 +1,24 @@
export enum TrappedStatus {
TRUE = 'True',
FALSE = 'False',
UNKNOWN = 'Unknown'
}
export interface CustomMetadataEntry {
key: string;
value: string;
id: string; // For React uniqueness
}
export interface ExtractedPDFMetadata {
title: string;
author: string;
subject: string;
keywords: string;
creator: string;
producer: string;
creationDate: string;
modificationDate: string;
trapped: TrappedStatus;
customMetadata: CustomMetadataEntry[];
}

View File

@ -0,0 +1,23 @@
export const validatePageNumbers = (pageNumbers: string): boolean => {
if (!pageNumbers.trim()) return false;
// Normalize input for validation: remove spaces around commas and other spaces
const normalized = pageNumbers.replace(/\s*,\s*/g, ',').replace(/\s+/g, '');
const parts = normalized.split(',');
// Regular expressions for different page number formats
const allToken = /^all$/i; // Select all pages
const singlePageRegex = /^[1-9]\d*$/; // Single page: positive integers only (no 0)
const rangeRegex = /^[1-9]\d*-(?:[1-9]\d*)?$/; // Range: 1-5 or open range 10-
const mathRegex = /^(?=.*n)[0-9n+\-*/() ]+$/; // Mathematical expressions with n and allowed chars
return parts.every(part => {
if (!part) return false;
return (
allToken.test(part) ||
singlePageRegex.test(part) ||
rangeRegex.test(part) ||
mathRegex.test(part)
);
});
};

View File

@ -27,6 +27,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/remove-password': 'remove-password',
'/single-large-page': 'single-large-page',
'/repair': 'repair',
'/rotate-pdf': 'rotate',
'/unlock-pdf-forms': 'unlock-pdf-forms',
'/remove-certificate-sign': 'remove-certificate-sign',
'/remove-cert-sign': 'remove-certificate-sign'