Initial commit of change metadata

This commit is contained in:
James Brunton 2025-09-11 11:35:49 +01:00
parent 8a367aab54
commit 1e35d64971
10 changed files with 928 additions and 16 deletions

View File

@ -1080,7 +1080,6 @@
},
"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",
@ -1089,15 +1088,103 @@
"4": "Other Metadata:",
"5": "Add Custom Metadata Entry"
},
"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"
"submit": "Change",
"filenamePrefix": "metadata",
"settings": {
"title": "Metadata Settings"
},
"standardFields": {
"title": "Standard Metadata"
},
"deleteAll": {
"label": "Delete all metadata",
"description": "Remove all metadata from the PDF document"
},
"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": "Document Dates",
"format": "Format: yyyy/MM/dd HH:mm:ss"
},
"creationDate": {
"label": "Creation Date"
},
"modificationDate": {
"label": "Modification Date"
},
"trapped": {
"label": "Trapped Status",
"description": "Indicates whether the document has been trapped for high-quality printing",
"unknown": "Unknown",
"true": "True",
"false": "False"
},
"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",
"bullet3": "Format: yyyy/MM/dd HH:mm:ss (e.g., 2025/01/17 14:30:00)"
},
"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"
}
}
},
"fileToPDF": {
"tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint",

View File

@ -0,0 +1,196 @@
import { Stack, TextInput, Select, Checkbox, Button, Group, Divider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangeMetadataParameters, TrappedStatus } from "../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
interface ChangeMetadataSettingsProps {
parameters: ChangeMetadataParameters;
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
disabled?: boolean;
addCustomMetadata: () => void;
removeCustomMetadata: (id: string) => void;
updateCustomMetadata: (id: string, key: string, value: string) => void;
}
// Global date/time fixed at module load time
const currentDateTime = new Date();
const formattedDateTime = `${currentDateTime.getFullYear()}/${String(currentDateTime.getMonth() + 1).padStart(2, '0')}/${String(currentDateTime.getDate()).padStart(2, '0')} ${String(currentDateTime.getHours()).padStart(2, '0')}:${String(currentDateTime.getMinutes()).padStart(2, '0')}:${String(currentDateTime.getSeconds()).padStart(2, '0')}`;
const ChangeMetadataSettings = ({
parameters,
onParameterChange,
disabled = false,
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata
}: ChangeMetadataSettingsProps) => {
const { t } = useTranslation();
const isDeleteAllEnabled = parameters.deleteAll;
const fieldsDisabled = disabled || isDeleteAllEnabled;
return (
<Stack gap="md">
{/* Delete All Option */}
<Checkbox
label={t('changeMetadata.deleteAll.label', 'Delete all metadata')}
description={t('changeMetadata.deleteAll.description', 'Remove all metadata from the PDF document')}
checked={parameters.deleteAll}
onChange={(e) => onParameterChange('deleteAll', e.target.checked)}
disabled={disabled}
/>
<Divider />
{/* Standard Metadata Fields */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t('changeMetadata.standardFields.title', 'Standard Metadata')}
</Text>
<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={fieldsDisabled}
/>
<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={fieldsDisabled}
/>
<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={fieldsDisabled}
/>
<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={fieldsDisabled}
/>
<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={fieldsDisabled}
/>
<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={fieldsDisabled}
/>
<Divider />
{/* Date Fields */}
<Text size="sm" fw={500}>
{t('changeMetadata.dates.title', 'Document Dates')}
</Text>
<Text size="xs" c="dimmed">
{t('changeMetadata.dates.format', 'Format: yyyy/MM/dd HH:mm:ss')}
</Text>
<TextInput
label={t('changeMetadata.creationDate.label', 'Creation Date')}
placeholder={formattedDateTime}
value={parameters.creationDate}
onChange={(e) => onParameterChange('creationDate', e.target.value)}
disabled={fieldsDisabled}
/>
<TextInput
label={t('changeMetadata.modificationDate.label', 'Modification Date')}
placeholder={formattedDateTime}
value={parameters.modificationDate}
onChange={(e) => onParameterChange('modificationDate', e.target.value)}
disabled={fieldsDisabled}
/>
{/* Trapped Status */}
<Select
label={t('changeMetadata.trapped.label', 'Trapped Status')}
description={t('changeMetadata.trapped.description', 'Indicates whether the document has been trapped for high-quality printing')}
value={parameters.trapped}
onChange={(value) => {
if (value) {
onParameterChange('trapped', value as TrappedStatus);
}
}}
disabled={fieldsDisabled}
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 */}
<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={fieldsDisabled}
>
{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={fieldsDisabled}
/>
<TextInput
placeholder={t('changeMetadata.customFields.valuePlaceholder', 'Custom value')}
value={entry.value}
onChange={(e) => updateCustomMetadata(entry.id, entry.key, e.target.value)}
disabled={fieldsDisabled}
/>
<Button
size="xs"
variant="light"
color="red"
onClick={() => removeCustomMetadata(entry.id)}
disabled={fieldsDisabled}
>
{t('changeMetadata.customFields.remove', 'Remove')}
</Button>
</Stack>
))}
</Stack>
</Stack>
</Stack>
);
};
export default ChangeMetadataSettings;

View File

@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useChangeMetadataTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("changeMetadata.tooltip.header.title", "PDF Metadata Overview")
},
tips: [
{
title: t("changeMetadata.tooltip.standardFields.title", "Standard Fields"),
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")
]
},
{
title: t("changeMetadata.tooltip.dates.title", "Date Fields"),
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"),
t("changeMetadata.tooltip.dates.bullet3", "Format: yyyy/MM/dd HH:mm:ss (e.g., 2025/01/17 14:30:00)")
]
},
{
title: t("changeMetadata.tooltip.options.title", "Additional Options"),
description: t("changeMetadata.tooltip.options.text", "Custom fields and privacy controls."),
bullets: [
t("changeMetadata.tooltip.options.bullet1", "Custom Metadata: Add your own key-value pairs"),
t("changeMetadata.tooltip.options.bullet2", "Trapped Status: High-quality printing setting"),
t("changeMetadata.tooltip.options.bullet3", "Delete All: Remove all metadata for privacy")
]
}
]
};
};

View File

@ -18,6 +18,7 @@ import SingleLargePage from "../tools/SingleLargePage";
import UnlockPdfForms from "../tools/UnlockPdfForms";
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
import Flatten from "../tools/Flatten";
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 +36,7 @@ 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 { 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";
@ -51,6 +53,7 @@ import RedactSingleStepSettings from "../components/tools/redact/RedactSingleSte
import Redact from "../tools/Redact";
import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings';
import ChangeMetadataSettings from "../components/tools/changeMetadata/ChangeMetadataSettings";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -289,10 +292,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: ChangeMetadataSettings,
},
// Page Formatting

View File

@ -0,0 +1,143 @@
import { buildChangeMetadataFormData } from './useChangeMetadataOperation';
import { ChangeMetadataParameters, TrappedStatus } from './useChangeMetadataParameters';
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: '',
modificationDate: '',
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: '2025/01/17 14:30:00',
modificationDate: '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('customKey1')).toBe('Department');
expect(formData.get('customValue1')).toBe('Engineering');
expect(formData.get('customKey2')).toBe('Project');
expect(formData.get('customValue2')).toBe('Test Project');
expect(formData.get('customKey3')).toBe('Status');
expect(formData.get('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('customKey1')).toBe('Department');
expect(formData.get('customValue1')).toBe('Engineering');
expect(formData.get('customKey2')).toBe('Valid');
expect(formData.get('customValue2')).toBe('Valid Value');
expect(formData.get('customKey3')).toBeNull();
expect(formData.get('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('customKey1')).toBe('Department');
expect(formData.get('customValue1')).toBe('Engineering');
});
});

View File

@ -0,0 +1,60 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { ChangeMetadataParameters, defaultParameters } from './useChangeMetadataParameters';
// 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
formData.append("creationDate", parameters.creationDate || "");
formData.append("modificationDate", parameters.modificationDate || "");
// Trapped status
formData.append("trapped", parameters.trapped || "");
// Delete all metadata flag
formData.append("deleteAll", parameters.deleteAll.toString());
// Custom metadata - need to match backend's customKey/customValue pattern
let keyNumber = 0;
parameters.customMetadata.forEach((entry) => {
if (entry.key.trim() && entry.value.trim()) {
keyNumber += 1;
formData.append(`customKey${keyNumber}`, entry.key.trim());
formData.append(`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',
filePrefix: '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,181 @@
import { renderHook, act } from '@testing-library/react';
import { useChangeMetadataParameters, TrappedStatus } from './useChangeMetadataParameters';
import { describe, expect, test } from 'vitest';
describe('useChangeMetadataParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
expect(result.current.parameters).toEqual({
title: '',
author: '',
subject: '',
keywords: '',
creator: '',
producer: '',
creationDate: '',
modificationDate: '',
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
});
});
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: '2025/01/17 14:30:00' },
{ paramName: 'modificationDate', value: '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: '2025/01/17 14:30:00' }, expected: true },
{ description: 'has modification date', updates: { modificationDate: '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: 'invalid creation date', updates: { title: 'Test', creationDate: 'invalid-date' }, expected: false },
{ description: 'invalid modification date', updates: { title: 'Test', modificationDate: 'not-a-date' }, expected: false },
{ description: 'valid creation date', updates: { title: 'Test', creationDate: '2025/01/17 14:30:00' }, expected: true },
{ description: 'valid modification date', updates: { title: 'Test', modificationDate: '2025/01/17 14:30:00' }, expected: true },
{ description: 'empty dates are valid', updates: { title: 'Test', creationDate: '', modificationDate: '' }, 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,134 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export enum TrappedStatus {
TRUE = 'True',
FALSE = 'False',
UNKNOWN = 'Unknown'
}
export interface CustomMetadataEntry {
key: string;
value: string;
id: string; // Format: "custom1", "custom2", etc.
}
export interface ChangeMetadataParameters extends BaseParameters {
// Standard PDF metadata fields
title: string;
author: string;
subject: string;
keywords: string;
creator: string;
producer: string;
// Date fields (format: yyyy/MM/dd HH:mm:ss)
creationDate: string;
modificationDate: string;
// 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: '',
modificationDate: '',
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
};
// Global counter for custom metadata IDs
let customMetadataIdCounter = 1;
// 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.trim()
|| params.modificationDate.trim()
|| params.trapped !== TrappedStatus.UNKNOWN
);
const hasCustomMetadata = params.customMetadata.some(
entry => entry.key.trim() && entry.value.trim()
);
// Date validation if provided
const datePattern = /^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/;
const isValidCreationDate = !params.creationDate.trim() || datePattern.test(params.creationDate);
const isValidModificationDate = !params.modificationDate.trim() || datePattern.test(params.modificationDate);
return (hasStandardMetadata || hasCustomMetadata) && isValidCreationDate && isValidModificationDate;
};
export type ChangeMetadataParametersHook = BaseParametersHook<ChangeMetadataParameters> & {
addCustomMetadata: () => 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,
});
const addCustomMetadata = () => {
const newEntry: CustomMetadataEntry = {
key: '',
value: '',
id: `custom${customMetadataIdCounter++}`,
};
base.updateParameter('customMetadata', [
...base.parameters.customMetadata,
newEntry,
]);
};
const removeCustomMetadata = (id: string) => {
base.updateParameter('customMetadata',
base.parameters.customMetadata.filter(entry => entry.id !== id)
);
};
const updateCustomMetadata = (id: string, key: string, value: string) => {
base.updateParameter('customMetadata',
base.parameters.customMetadata.map(entry =>
entry.id === id ? { ...entry, key, value } : entry
)
);
};
return {
...base,
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata
};
};

View File

@ -6,12 +6,12 @@ import { ToolOperationHook } from './useToolOperation';
import { BaseParametersHook } from './useBaseParameters';
import { StirlingFile } from '../../../types/fileContext';
interface BaseToolReturn<TParams> {
interface BaseToolReturn<TParams, TParamsHook extends BaseParametersHook<TParams>> {
// File management
selectedFiles: StirlingFile[];
// Tool-specific hooks
params: BaseParametersHook<TParams>;
params: TParamsHook;
operation: ToolOperationHook<TParams>;
// Endpoint validation
@ -33,13 +33,13 @@ interface BaseToolReturn<TParams> {
/**
* Base tool hook for tool components. Manages standard behaviour for tools.
*/
export function useBaseTool<TParams>(
export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TParams>>(
toolName: string,
useParams: () => BaseParametersHook<TParams>,
useParams: () => TParamsHook,
useOperation: () => ToolOperationHook<TParams>,
props: BaseToolProps,
options?: { minFiles?: number }
): BaseToolReturn<TParams> {
): BaseToolReturn<TParams, TParamsHook> {
const minFiles = options?.minFiles ?? 1;
const { onPreviewFile, onComplete, onError } = props;

View File

@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ChangeMetadataSettings from "../components/tools/changeMetadata/ChangeMetadataSettings";
import { useChangeMetadataParameters } from "../hooks/tools/changeMetadata/useChangeMetadataParameters";
import { useChangeMetadataOperation } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useChangeMetadataTips } from "../components/tooltips/useChangeMetadataTips";
const ChangeMetadata = (props: BaseToolProps) => {
const { t } = useTranslation();
const changeMetadataTips = useChangeMetadataTips();
const base = useBaseTool(
'changeMetadata',
useChangeMetadataParameters,
useChangeMetadataOperation,
props,
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("changeMetadata.settings.title", "Metadata Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: changeMetadataTips,
content: (
<ChangeMetadataSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
addCustomMetadata={base.params.addCustomMetadata}
removeCustomMetadata={base.params.removeCustomMetadata}
updateCustomMetadata={base.params.updateCustomMetadata}
/>
),
},
],
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;