Stirling 2.0 (#3928)

# Description of Changes

<!--

File context for managing files between tools and views
Optimisation for large files
Updated Split to work with new file system and match Matts stepped
design closer

-->

---

## Checklist

### General

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

### Documentation

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
Reece Browne
2025-07-16 17:53:50 +01:00
committed by GitHub
parent 584e2ecee7
commit 922bbc9076
66 changed files with 8728 additions and 2519 deletions

View File

@@ -0,0 +1,242 @@
import { useCallback, useState } from 'react';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import { useFileContext } from '../../../contexts/FileContext';
import { FileOperation } from '../../../types/fileContext';
import { zipFileService } from '../../../services/zipFileService';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
import { SPLIT_MODES, ENDPOINTS, type SplitMode } from '../../../constants/splitConstants';
export interface SplitOperationHook {
executeOperation: (
mode: SplitMode | '',
parameters: SplitParameters,
selectedFiles: File[]
) => Promise<void>;
// Flattened result properties for cleaner access
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
status: string;
errorMessage: string | null;
isLoading: boolean;
// Result management functions
resetResults: () => void;
clearError: () => void;
}
export const useSplitOperation = (): SplitOperationHook => {
const { t } = useTranslation();
const {
recordOperation,
markOperationApplied,
markOperationFailed,
addFiles
} = useFileContext();
// Internal state management (replacing useOperationResults)
const [files, setFiles] = useState<File[]>([]);
const [thumbnails, setThumbnails] = useState<string[]>([]);
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const buildFormData = useCallback((
mode: SplitMode | '',
parameters: SplitParameters,
selectedFiles: File[]
) => {
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append("fileInput", file);
});
if (!mode) {
throw new Error('Split mode is required');
}
let endpoint = "";
switch (mode) {
case SPLIT_MODES.BY_PAGES:
formData.append("pageNumbers", parameters.pages);
endpoint = "/api/v1/general/split-pages";
break;
case SPLIT_MODES.BY_SECTIONS:
formData.append("horizontalDivisions", parameters.hDiv);
formData.append("verticalDivisions", parameters.vDiv);
formData.append("merge", parameters.merge.toString());
endpoint = "/api/v1/general/split-pdf-by-sections";
break;
case SPLIT_MODES.BY_SIZE_OR_COUNT:
formData.append(
"splitType",
parameters.splitType === "size" ? "0" : parameters.splitType === "pages" ? "1" : "2"
);
formData.append("splitValue", parameters.splitValue);
endpoint = "/api/v1/general/split-by-size-or-count";
break;
case SPLIT_MODES.BY_CHAPTERS:
formData.append("bookmarkLevel", parameters.bookmarkLevel);
formData.append("includeMetadata", parameters.includeMetadata.toString());
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
endpoint = "/api/v1/general/split-pdf-by-chapters";
break;
default:
throw new Error(`Unknown split mode: ${mode}`);
}
return { formData, endpoint };
}, []);
const createOperation = useCallback((
mode: SplitMode | '',
parameters: SplitParameters,
selectedFiles: File[]
): { operation: FileOperation; operationId: string; fileId: string } => {
const operationId = `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const fileId = selectedFiles[0].name;
const operation: FileOperation = {
id: operationId,
type: 'split',
timestamp: Date.now(),
fileIds: selectedFiles.map(f => f.name),
status: 'pending',
metadata: {
originalFileName: selectedFiles[0].name,
parameters: {
mode,
pages: mode === SPLIT_MODES.BY_PAGES ? parameters.pages : undefined,
hDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.hDiv : undefined,
vDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.vDiv : undefined,
merge: mode === SPLIT_MODES.BY_SECTIONS ? parameters.merge : undefined,
splitType: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitType : undefined,
splitValue: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitValue : undefined,
bookmarkLevel: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.bookmarkLevel : undefined,
includeMetadata: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.includeMetadata : undefined,
allowDuplicates: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.allowDuplicates : undefined,
},
fileSize: selectedFiles[0].size
}
};
return { operation, operationId, fileId };
}, []);
const processResults = useCallback(async (blob: Blob) => {
try {
const zipFile = new File([blob], "split_result.zip", { type: "application/zip" });
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
// Set local state for preview
setFiles(extractionResult.extractedFiles);
setThumbnails([]);
setIsGeneratingThumbnails(true);
// Add extracted files to FileContext for future use
await addFiles(extractionResult.extractedFiles);
const thumbnails = await Promise.all(
extractionResult.extractedFiles.map(async (file) => {
try {
return await generateThumbnailForFile(file);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
return '';
}
})
);
setThumbnails(thumbnails);
setIsGeneratingThumbnails(false);
}
} catch (extractError) {
console.warn('Failed to extract files for preview:', extractError);
}
}, [addFiles]);
const executeOperation = useCallback(async (
mode: SplitMode | '',
parameters: SplitParameters,
selectedFiles: File[]
) => {
if (selectedFiles.length === 0) {
setStatus(t("noFileSelected"));
return;
}
const { operation, operationId, fileId } = createOperation(mode, parameters, selectedFiles);
const { formData, endpoint } = buildFormData(mode, parameters, selectedFiles);
recordOperation(fileId, operation);
setStatus(t("loading"));
setIsLoading(true);
setErrorMessage(null);
try {
const response = await axios.post(endpoint, formData, { responseType: "blob" });
const blob = new Blob([response.data], { type: "application/zip" });
const url = window.URL.createObjectURL(blob);
setDownloadUrl(url);
setStatus(t("downloadComplete"));
await processResults(blob);
markOperationApplied(fileId, operationId);
} catch (error: any) {
console.error(error);
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
if (error.response?.data && typeof error.response.data === 'string') {
errorMsg = error.response.data;
} else if (error.message) {
errorMsg = error.message;
}
setErrorMessage(errorMsg);
setStatus(t("error._value", "Split failed."));
markOperationFailed(fileId, operationId, errorMsg);
} finally {
setIsLoading(false);
}
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]);
const resetResults = useCallback(() => {
setFiles([]);
setThumbnails([]);
setIsGeneratingThumbnails(false);
setDownloadUrl(null);
setStatus('');
setErrorMessage(null);
setIsLoading(false);
}, []);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
return {
executeOperation,
// Flattened result properties for cleaner access
files,
thumbnails,
isGeneratingThumbnails,
downloadUrl,
status,
errorMessage,
isLoading,
// Result management functions
resetResults,
clearError,
};
};

View File

@@ -0,0 +1,71 @@
import { useState } from 'react';
import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode, type SplitType } from '../../../constants/splitConstants';
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
export interface SplitParametersHook {
mode: SplitMode | '';
parameters: SplitParameters;
setMode: (mode: SplitMode | '') => void;
updateParameter: (parameter: keyof SplitParameters, value: string | boolean) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
}
const initialParameters: SplitParameters = {
pages: '',
hDiv: '2',
vDiv: '2',
merge: false,
splitType: SPLIT_TYPES.SIZE,
splitValue: '',
bookmarkLevel: '1',
includeMetadata: false,
allowDuplicates: false,
};
export const useSplitParameters = (): SplitParametersHook => {
const [mode, setMode] = useState<SplitMode | ''>('');
const [parameters, setParameters] = useState<SplitParameters>(initialParameters);
const updateParameter = (parameter: keyof SplitParameters, value: string | boolean) => {
setParameters(prev => ({ ...prev, [parameter]: value }));
};
const resetParameters = () => {
setParameters(initialParameters);
setMode('');
};
const validateParameters = () => {
if (!mode) return false;
switch (mode) {
case SPLIT_MODES.BY_PAGES:
return parameters.pages.trim() !== "";
case SPLIT_MODES.BY_SECTIONS:
return parameters.hDiv !== "" && parameters.vDiv !== "";
case SPLIT_MODES.BY_SIZE_OR_COUNT:
return parameters.splitValue.trim() !== "";
case SPLIT_MODES.BY_CHAPTERS:
return parameters.bookmarkLevel !== "";
default:
return false;
}
};
const getEndpointName = () => {
if (!mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES];
return ENDPOINTS[mode as SplitMode];
};
return {
mode,
parameters,
setMode,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
};
};