mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 09:29:24 +00:00
Automatically populate previous metadata
This commit is contained in:
parent
1e35d64971
commit
68f671f13a
@ -1,12 +1,16 @@
|
||||
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";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChangeMetadataParameters } from "../../../hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import { TrappedStatus } from "../../../types/metadata";
|
||||
import { PDFMetadataService } from "../../../services/pdfMetadataService";
|
||||
import { useSelectedFiles } from "../../../contexts/file/fileHooks";
|
||||
|
||||
interface ChangeMetadataSettingsProps {
|
||||
parameters: ChangeMetadataParameters;
|
||||
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
addCustomMetadata: () => void;
|
||||
addCustomMetadata: (key?: string, value?: string) => void;
|
||||
removeCustomMetadata: (id: string) => void;
|
||||
updateCustomMetadata: (id: string, key: string, value: string) => void;
|
||||
}
|
||||
@ -24,8 +28,57 @@ const ChangeMetadataSettings = ({
|
||||
updateCustomMetadata
|
||||
}: ChangeMetadataSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useSelectedFiles();
|
||||
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
|
||||
const [hasExtractedMetadata, setHasExtractedMetadata] = useState(false);
|
||||
|
||||
const isDeleteAllEnabled = parameters.deleteAll;
|
||||
const fieldsDisabled = disabled || isDeleteAllEnabled;
|
||||
const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata;
|
||||
|
||||
// Extract metadata from first file when files change
|
||||
useEffect(() => {
|
||||
const extractMetadata = async () => {
|
||||
if (selectedFiles.length === 0 || hasExtractedMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstFile = selectedFiles[0];
|
||||
if (!firstFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExtractingMetadata(true);
|
||||
try {
|
||||
const result = await PDFMetadataService.extractMetadata(firstFile);
|
||||
|
||||
if (result.success) {
|
||||
const metadata = result.metadata;
|
||||
|
||||
// Pre-populate all fields with extracted metadata
|
||||
onParameterChange('title', metadata.title);
|
||||
onParameterChange('author', metadata.author);
|
||||
onParameterChange('subject', metadata.subject);
|
||||
onParameterChange('keywords', metadata.keywords);
|
||||
onParameterChange('creator', metadata.creator);
|
||||
onParameterChange('producer', metadata.producer);
|
||||
onParameterChange('creationDate', metadata.creationDate);
|
||||
onParameterChange('modificationDate', metadata.modificationDate);
|
||||
onParameterChange('trapped', metadata.trapped);
|
||||
|
||||
// Set custom metadata entries directly to avoid state update timing issues
|
||||
onParameterChange('customMetadata', metadata.customMetadata);
|
||||
|
||||
setHasExtractedMetadata(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract metadata:', error);
|
||||
} finally {
|
||||
setIsExtractingMetadata(false);
|
||||
}
|
||||
};
|
||||
|
||||
extractMetadata();
|
||||
}, [selectedFiles, hasExtractedMetadata, onParameterChange, addCustomMetadata, updateCustomMetadata, removeCustomMetadata, parameters.customMetadata]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@ -149,7 +202,7 @@ const ChangeMetadataSettings = ({
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={addCustomMetadata}
|
||||
onClick={() => addCustomMetadata()}
|
||||
disabled={fieldsDisabled}
|
||||
>
|
||||
{t('changeMetadata.customFields.add', 'Add Field')}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { buildChangeMetadataFormData } from './useChangeMetadataOperation';
|
||||
import { ChangeMetadataParameters, TrappedStatus } from './useChangeMetadataParameters';
|
||||
import { ChangeMetadataParameters } from './useChangeMetadataParameters';
|
||||
import { TrappedStatus } from '../../../types/metadata';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
describe('buildChangeMetadataFormData', () => {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useChangeMetadataParameters, TrappedStatus } from './useChangeMetadataParameters';
|
||||
import { useChangeMetadataParameters } from './useChangeMetadataParameters';
|
||||
import { TrappedStatus } from '../../../types/metadata';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
describe('useChangeMetadataParameters', () => {
|
||||
|
@ -1,18 +1,7 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { TrappedStatus, CustomMetadataEntry } from '../../../types/metadata';
|
||||
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;
|
||||
@ -86,7 +75,7 @@ const validateParameters = (params: ChangeMetadataParameters): boolean => {
|
||||
};
|
||||
|
||||
export type ChangeMetadataParametersHook = BaseParametersHook<ChangeMetadataParameters> & {
|
||||
addCustomMetadata: () => void;
|
||||
addCustomMetadata: (key?: string, value?: string) => void;
|
||||
removeCustomMetadata: (id: string) => void;
|
||||
updateCustomMetadata: (id: string, key: string, value: string) => void;
|
||||
};
|
||||
@ -98,10 +87,10 @@ export const useChangeMetadataParameters = (): ChangeMetadataParametersHook => {
|
||||
validateFn: validateParameters,
|
||||
});
|
||||
|
||||
const addCustomMetadata = () => {
|
||||
const addCustomMetadata = (key: string = '', value: string = '') => {
|
||||
const newEntry: CustomMetadataEntry = {
|
||||
key: '',
|
||||
value: '',
|
||||
key,
|
||||
value,
|
||||
id: `custom${customMetadataIdCounter++}`,
|
||||
};
|
||||
|
||||
|
175
frontend/src/services/pdfMetadataService.ts
Normal file
175
frontend/src/services/pdfMetadataService.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
import { FileAnalyzer } from './fileAnalyzer';
|
||||
import { TrappedStatus, CustomMetadataEntry, ExtractedPDFMetadata } from '../types/metadata';
|
||||
|
||||
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: unknown): string {
|
||||
if (!dateString || typeof dateString !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
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}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(info: Record<string, unknown>): CustomMetadataEntry[] {
|
||||
const customMetadata: CustomMetadataEntry[] = [];
|
||||
let customIdCounter = 1;
|
||||
|
||||
|
||||
// Check if there's a Custom object containing the custom metadata
|
||||
if (info.Custom && typeof info.Custom === 'object' && info.Custom !== null) {
|
||||
const customObj = info.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to extract metadata from PDF files using PDF.js
|
||||
*/
|
||||
export class PDFMetadataService {
|
||||
/**
|
||||
* Extract all metadata from a PDF file
|
||||
* Returns a result object with success/error state
|
||||
*/
|
||||
static async extractMetadata(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: any = null;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true
|
||||
});
|
||||
|
||||
const metadata = await pdfDoc.getMetadata();
|
||||
const info = metadata.info || {};
|
||||
|
||||
// Safely extract metadata with proper type checking
|
||||
const extractedMetadata: ExtractedPDFMetadata = {
|
||||
title: typeof info.Title === 'string' ? info.Title : '',
|
||||
author: typeof info.Author === 'string' ? info.Author : '',
|
||||
subject: typeof info.Subject === 'string' ? info.Subject : '',
|
||||
keywords: typeof info.Keywords === 'string' ? info.Keywords : '',
|
||||
creator: typeof info.Creator === 'string' ? info.Creator : '',
|
||||
producer: typeof info.Producer === 'string' ? info.Producer : '',
|
||||
creationDate: formatPDFDate(info.CreationDate),
|
||||
modificationDate: formatPDFDate(info.ModDate),
|
||||
trapped: convertTrappedStatus(info.Trapped),
|
||||
customMetadata: extractCustomMetadata(info)
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata: extractedMetadata
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to extract PDF metadata: ${errorMessage}`
|
||||
};
|
||||
|
||||
} finally {
|
||||
// Ensure cleanup even if extraction fails
|
||||
if (pdfDoc) {
|
||||
try {
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to cleanup PDF document:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
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