mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
feat(form-fill): add CSV and XLSX extraction for form fields, improve file ID handling (#5776)
This commit is contained in:
@@ -16,7 +16,7 @@ import { useRedaction } from '@app/contexts/RedactionContext';
|
||||
import type { RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
|
||||
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { isStirlingFile, getFormFillFileId } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
||||
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
|
||||
@@ -788,19 +788,7 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Generate a unique identifier for the current file to detect file changes
|
||||
const currentFileId = React.useMemo(() => {
|
||||
if (!currentFile) return null;
|
||||
|
||||
if (isStirlingFile(currentFile)) {
|
||||
return `stirling-${currentFile.fileId}`;
|
||||
}
|
||||
|
||||
// File is also a Blob, but has more specific properties
|
||||
if (currentFile instanceof File) {
|
||||
return `file-${currentFile.name}-${currentFile.size}-${currentFile.lastModified}`;
|
||||
}
|
||||
|
||||
// Fallback for any other object (shouldn't happen in practice)
|
||||
return `unknown-${(currentFile as any).size || 0}`;
|
||||
return getFormFillFileId(currentFile);
|
||||
}, [currentFile]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -79,15 +79,33 @@
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.primaryActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actionBar > *:first-child {
|
||||
.primaryActions > *:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.secondaryActions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.secondaryActions > button {
|
||||
flex: 1;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.fieldList {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useNavigation } from '@app/contexts/NavigationContext';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { Skeleton } from '@mantine/core';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { isStirlingFile, getFormFillFileId } from '@app/types/fileContext';
|
||||
import type { BaseToolProps } from '@app/types/tool';
|
||||
import type { FormField } from '@app/tools/formFill/types';
|
||||
import { FieldInput } from '@app/tools/formFill/FieldInput';
|
||||
@@ -40,6 +40,7 @@ import FileCopyIcon from '@mui/icons-material/FileCopy';
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import { extractFormFieldsCsv, extractFormFieldsXlsx } from '@app/tools/formFill/formApi';
|
||||
import styles from '@app/tools/formFill/FormFill.module.css';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -149,6 +150,44 @@ const FormFill = (_props: BaseToolProps) => {
|
||||
return activeFiles[0];
|
||||
}, [activeFiles, selectedFileIds]);
|
||||
|
||||
const handleExtractCsv = useCallback(async () => {
|
||||
if (!currentFile) return;
|
||||
setExtracting(true);
|
||||
try {
|
||||
const blob = await extractFormFieldsCsv(currentFile, allValues);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `form-data-${new Date().getTime()}.csv`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 250);
|
||||
} catch (err) {
|
||||
console.error('[FormFill] CSV extraction failed:', err);
|
||||
setSaveError('Failed to extract CSV');
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
}, [currentFile, allValues]);
|
||||
|
||||
const handleExtractXlsx = useCallback(async () => {
|
||||
if (!currentFile) return;
|
||||
setExtracting(true);
|
||||
try {
|
||||
const blob = await extractFormFieldsXlsx(currentFile, allValues);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `form-data-${new Date().getTime()}.xlsx`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 250);
|
||||
} catch (err) {
|
||||
console.error('[FormFill] XLSX extraction failed:', err);
|
||||
setSaveError('Failed to extract XLSX');
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
}, [currentFile, allValues]);
|
||||
|
||||
const isActive = selectedTool === 'formFill';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -228,10 +267,8 @@ const FormFill = (_props: BaseToolProps) => {
|
||||
}, [formState.isDirty]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (currentFile && isStirlingFile(currentFile)) {
|
||||
fetchFields(currentFile, currentFile.fileId);
|
||||
} else if (currentFile) {
|
||||
fetchFields(currentFile);
|
||||
if (currentFile) {
|
||||
fetchFields(currentFile, getFormFillFileId(currentFile) ?? undefined);
|
||||
}
|
||||
}, [currentFile, fetchFields]);
|
||||
|
||||
@@ -406,38 +443,63 @@ const FormFill = (_props: BaseToolProps) => {
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className={styles.actionBar}>
|
||||
<Button
|
||||
leftSection={<SaveIcon sx={{ fontSize: 14 }} />}
|
||||
size="xs"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!formState.isDirty && !flattenChanged}
|
||||
flex={1}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractJson}
|
||||
size="xs"
|
||||
>
|
||||
Extract JSON
|
||||
</Button>
|
||||
|
||||
<Tooltip label="Re-scan fields" withArrow position="bottom">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="md"
|
||||
onClick={handleRefresh}
|
||||
aria-label="Re-scan form fields"
|
||||
<div className={styles.primaryActions}>
|
||||
<Button
|
||||
leftSection={<SaveIcon sx={{ fontSize: 14 }} />}
|
||||
size="xs"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!formState.isDirty && !flattenChanged}
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<Tooltip label="Re-scan fields" withArrow position="bottom">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="md"
|
||||
onClick={handleRefresh}
|
||||
aria-label="Re-scan form fields"
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={styles.secondaryActions}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractJson}
|
||||
size="xs"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractCsv}
|
||||
size="xs"
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractXlsx}
|
||||
size="xs"
|
||||
>
|
||||
XLSX
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
|
||||
@@ -44,3 +44,49 @@ export async function fillFormFields(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form fields as CSV.
|
||||
* Calls POST /api/v1/form/extract-csv
|
||||
*/
|
||||
export async function extractFormFieldsCsv(
|
||||
file: File | Blob,
|
||||
values?: Record<string, string>
|
||||
): Promise<Blob> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (values) {
|
||||
formData.append(
|
||||
'data',
|
||||
new Blob([JSON.stringify(values)], { type: 'application/json' })
|
||||
);
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/api/v1/form/extract-csv', formData, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form fields as XLSX.
|
||||
* Calls POST /api/v1/form/extract-xlsx
|
||||
*/
|
||||
export async function extractFormFieldsXlsx(
|
||||
file: File | Blob,
|
||||
values?: Record<string, string>
|
||||
): Promise<Blob> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (values) {
|
||||
formData.append(
|
||||
'data',
|
||||
new Blob([JSON.stringify(values)], { type: 'application/json' })
|
||||
);
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/api/v1/form/extract-xlsx', formData, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function createFileId(): FileId {
|
||||
return window.crypto.randomUUID() as FileId;
|
||||
}
|
||||
// Fallback for environments without randomUUID
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
@@ -85,9 +85,29 @@ export interface StirlingFile extends File {
|
||||
}
|
||||
|
||||
// Type guard to check if a File object has an embedded fileId
|
||||
export function isStirlingFile(file: File): file is StirlingFile {
|
||||
return 'fileId' in file && typeof (file as any).fileId === 'string' &&
|
||||
'quickKey' in file && typeof (file as any).quickKey === 'string';
|
||||
export function isStirlingFile(file: File | Blob): file is StirlingFile {
|
||||
return file instanceof File && 'fileId' in file && typeof (file as any).fileId === 'string' &&
|
||||
'quickKey' in file && typeof (file as any).quickKey === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique identifier for form fill state tracking.
|
||||
* This ensures that form widgets/values are correctly isolated between files
|
||||
* even if they have the same name or are re-scanned.
|
||||
*/
|
||||
export function getFormFillFileId(file: File | Blob | null | undefined): string | null {
|
||||
if (!file) return null;
|
||||
|
||||
if (isStirlingFile(file)) {
|
||||
return `stirling-${file.fileId}`;
|
||||
}
|
||||
|
||||
if (file instanceof File) {
|
||||
return `file-${file.name}-${file.size}-${file.lastModified}`;
|
||||
}
|
||||
|
||||
// Fallback for Blobs or other objects
|
||||
return `blob-${(file as any).size || 0}`;
|
||||
}
|
||||
|
||||
// Create a StirlingFile from a regular File object
|
||||
@@ -141,11 +161,11 @@ export function extractFiles(files: StirlingFile[]): File[] {
|
||||
// Check if an object is a File or StirlingFile (replaces instanceof File checks)
|
||||
export function isFileObject(obj: any): obj is File | StirlingFile {
|
||||
return obj &&
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.size === 'number' &&
|
||||
typeof obj.type === 'string' &&
|
||||
typeof obj.lastModified === 'number' &&
|
||||
typeof obj.arrayBuffer === 'function';
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.size === 'number' &&
|
||||
typeof obj.type === 'string' &&
|
||||
typeof obj.lastModified === 'number' &&
|
||||
typeof obj.arrayBuffer === 'function';
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user