mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-19 01:49:24 +00:00
V2 change metadata (#4433)
# Description of Changes Add Change Metadata tool
This commit is contained in:
parent
756cbc4780
commit
a5693ee116
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
@ -14,6 +14,7 @@
|
|||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@iconify/react": "^6.0.0",
|
"@iconify/react": "^6.0.0",
|
||||||
"@mantine/core": "^8.0.1",
|
"@mantine/core": "^8.0.1",
|
||||||
|
"@mantine/dates": "^8.0.1",
|
||||||
"@mantine/dropzone": "^8.0.1",
|
"@mantine/dropzone": "^8.0.1",
|
||||||
"@mantine/hooks": "^8.0.1",
|
"@mantine/hooks": "^8.0.1",
|
||||||
"@mui/icons-material": "^7.1.0",
|
"@mui/icons-material": "^7.1.0",
|
||||||
@ -1653,6 +1654,22 @@
|
|||||||
"react-dom": "^18.x || ^19.x"
|
"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": {
|
"node_modules/@mantine/dropzone": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz",
|
||||||
@ -4367,6 +4384,13 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@iconify/react": "^6.0.0",
|
"@iconify/react": "^6.0.0",
|
||||||
"@mantine/core": "^8.0.1",
|
"@mantine/core": "^8.0.1",
|
||||||
|
"@mantine/dates": "^8.0.1",
|
||||||
"@mantine/dropzone": "^8.0.1",
|
"@mantine/dropzone": "^8.0.1",
|
||||||
"@mantine/hooks": "^8.0.1",
|
"@mantine/hooks": "^8.0.1",
|
||||||
"@mui/icons-material": "^7.1.0",
|
"@mui/icons-material": "^7.1.0",
|
||||||
|
@ -1220,24 +1220,127 @@
|
|||||||
},
|
},
|
||||||
"changeMetadata": {
|
"changeMetadata": {
|
||||||
"tags": "Title,author,date,creation,time,publisher,producer,stats",
|
"tags": "Title,author,date,creation,time,publisher,producer,stats",
|
||||||
"title": "Change Metadata",
|
|
||||||
"header": "Change Metadata",
|
"header": "Change Metadata",
|
||||||
"selectText": {
|
"submit": "Change",
|
||||||
"1": "Please edit the variables you wish to change",
|
"filenamePrefix": "metadata",
|
||||||
"2": "Delete all metadata",
|
"settings": {
|
||||||
"3": "Show Custom Metadata:",
|
"title": "Metadata Settings"
|
||||||
"4": "Other Metadata:",
|
|
||||||
"5": "Add Custom Metadata Entry"
|
|
||||||
},
|
},
|
||||||
"author": "Author:",
|
"standardFields": {
|
||||||
"creationDate": "Creation Date (yyyy/MM/dd HH:mm:ss):",
|
"title": "Standard Fields"
|
||||||
"creator": "Creator:",
|
},
|
||||||
"keywords": "Keywords:",
|
"deleteAll": {
|
||||||
"modDate": "Modification Date (yyyy/MM/dd HH:mm:ss):",
|
"label": "Remove Existing Metadata",
|
||||||
"producer": "Producer:",
|
"checkbox": "Delete all metadata"
|
||||||
"subject": "Subject:",
|
},
|
||||||
"trapped": "Trapped:",
|
"title": {
|
||||||
"submit": "Change"
|
"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": {
|
"fileToPDF": {
|
||||||
"tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint",
|
"tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint",
|
||||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
108
frontend/src/components/tooltips/useChangeMetadataTips.ts
Normal file
108
frontend/src/components/tooltips/useChangeMetadataTips.ts
Normal 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")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
@ -20,6 +20,7 @@ import SingleLargePage from "../tools/SingleLargePage";
|
|||||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||||
import Flatten from "../tools/Flatten";
|
import Flatten from "../tools/Flatten";
|
||||||
|
import ChangeMetadata from "../tools/ChangeMetadata";
|
||||||
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
||||||
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
||||||
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
||||||
@ -37,6 +38,7 @@ import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
|
|||||||
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
||||||
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||||
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
||||||
|
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||||
@ -56,6 +58,7 @@ import { ToolId } from "../types/toolId";
|
|||||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
import MergeSettings from '../components/tools/merge/MergeSettings';
|
||||||
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
|
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
|
||||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
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
|
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||||
|
|
||||||
@ -294,10 +297,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
"change-metadata": {
|
"change-metadata": {
|
||||||
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.changeMetadata.title", "Change Metadata"),
|
name: t("home.changeMetadata.title", "Change Metadata"),
|
||||||
component: null,
|
component: ChangeMetadata,
|
||||||
description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"),
|
description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"),
|
||||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
||||||
|
maxFiles: -1,
|
||||||
|
endpoints: ["update-metadata"],
|
||||||
|
operationConfig: changeMetadataOperationConfig,
|
||||||
|
settingsComponent: ChangeMetadataSingleStep,
|
||||||
},
|
},
|
||||||
// Page Formatting
|
// Page Formatting
|
||||||
|
|
||||||
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -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.'))
|
||||||
|
});
|
||||||
|
};
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -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
|
||||||
|
};
|
||||||
|
};
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
@ -1,4 +1,5 @@
|
|||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
|
import '@mantine/dates/styles.css';
|
||||||
import '../vite-env.d.ts';
|
import '../vite-env.d.ts';
|
||||||
import './index.css'; // Import Tailwind CSS
|
import './index.css'; // Import Tailwind CSS
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
181
frontend/src/services/pdfMetadataService.ts
Normal file
181
frontend/src/services/pdfMetadataService.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
@ -6,11 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
|
||||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
|
||||||
class PDFWorkerManager {
|
class PDFWorkerManager {
|
||||||
private static instance: PDFWorkerManager;
|
private static instance: PDFWorkerManager;
|
||||||
private activeDocuments = new Set<any>();
|
private activeDocuments = new Set<PDFDocumentProxy>();
|
||||||
private workerCount = 0;
|
private workerCount = 0;
|
||||||
private maxWorkers = 10; // Limit concurrent workers
|
private maxWorkers = 10; // Limit concurrent workers
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
@ -48,7 +49,7 @@ class PDFWorkerManager {
|
|||||||
stopAtErrors?: boolean;
|
stopAtErrors?: boolean;
|
||||||
verbosity?: number;
|
verbosity?: number;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<any> {
|
): Promise<PDFDocumentProxy> {
|
||||||
// Wait if we've hit the worker limit
|
// Wait if we've hit the worker limit
|
||||||
if (this.activeDocuments.size >= this.maxWorkers) {
|
if (this.activeDocuments.size >= this.maxWorkers) {
|
||||||
await this.waitForAvailableWorker();
|
await this.waitForAvailableWorker();
|
||||||
@ -104,7 +105,7 @@ class PDFWorkerManager {
|
|||||||
/**
|
/**
|
||||||
* Properly destroy a PDF document and clean up resources
|
* Properly destroy a PDF document and clean up resources
|
||||||
*/
|
*/
|
||||||
destroyDocument(pdf: any): void {
|
destroyDocument(pdf: PDFDocumentProxy): void {
|
||||||
if (this.activeDocuments.has(pdf)) {
|
if (this.activeDocuments.has(pdf)) {
|
||||||
try {
|
try {
|
||||||
pdf.destroy();
|
pdf.destroy();
|
||||||
|
164
frontend/src/tools/ChangeMetadata.tsx
Normal file
164
frontend/src/tools/ChangeMetadata.tsx
Normal 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;
|
24
frontend/src/types/metadata.ts
Normal file
24
frontend/src/types/metadata.ts
Normal 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[];
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user