diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 669c9f505..5d70efc9c 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1418,6 +1418,26 @@ }, "submit": "Remove Pages" }, + "extractPages": { + "title": "Extract Pages", + "pageNumbers": { + "label": "Pages to Extract", + "placeholder": "e.g., 1,3,5-8 or odd & 1-10" + }, + "settings": { + "title": "Settings" + }, + "tooltip": { + "description": "Extracts the selected pages into a new PDF, preserving order." + }, + "error": { + "failed": "Failed to extract pages" + }, + "results": { + "title": "Pages Extracted" + }, + "submit": "Extract Pages" + }, "pageSelection": { "tooltip": { "header": { @@ -1494,6 +1514,7 @@ } }, "bulkSelection": { + "syntaxError": "There is a syntax issue. See Page Selection tips for help.", "header": { "title": "Page Selection Guide" }, diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index c92c4c56e..f0b241bf9 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -893,6 +893,26 @@ }, "submit": "Remove Pages" }, + "extractPages": { + "title": "Extract Pages", + "pageNumbers": { + "label": "Pages to Extract", + "placeholder": "e.g., 1,3,5-8 or odd & 1-10" + }, + "settings": { + "title": "Settings" + }, + "tooltip": { + "description": "Extracts the selected pages into a new PDF, preserving order." + }, + "error": { + "failed": "Failed to extract pages" + }, + "results": { + "title": "Pages Extracted" + }, + "submit": "Extract Pages" + }, "pageSelection": { "tooltip": { "header": { @@ -958,6 +978,7 @@ } }, "bulkSelection": { + "syntaxError": "There is a syntax issue. See Page Selection tips for help.", "header": { "title": "Page Selection Guide" }, "syntax": { "title": "Syntax Basics", diff --git a/frontend/src/core/components/pageEditor/BulkSelectionPanel.tsx b/frontend/src/core/components/pageEditor/BulkSelectionPanel.tsx index 50b304e0b..81d474333 100644 --- a/frontend/src/core/components/pageEditor/BulkSelectionPanel.tsx +++ b/frontend/src/core/components/pageEditor/BulkSelectionPanel.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css'; -import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection'; import PageSelectionInput from '@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput'; import SelectedPagesDisplay from '@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay'; +import PageSelectionSyntaxHint from '@app/components/shared/PageSelectionSyntaxHint'; import AdvancedSelectionPanel from '@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel'; interface BulkSelectionPanelProps { @@ -20,26 +20,9 @@ const BulkSelectionPanel = ({ displayDocument, onUpdatePagesFromCSV, }: BulkSelectionPanelProps) => { - const [syntaxError, setSyntaxError] = useState(null); const [advancedOpened, setAdvancedOpened] = useState(false); const maxPages = displayDocument?.pages?.length ?? 0; - - // Validate input syntax and show lightweight feedback - useEffect(() => { - const text = (csvInput || '').trim(); - if (!text) { - setSyntaxError(null); - return; - } - try { - const { warning } = parseSelectionWithDiagnostics(text, maxPages); - setSyntaxError(warning ? 'There is a syntax issue. See Page Selection tips for help.' : null); - } catch { - setSyntaxError('There is a syntax issue. See Page Selection tips for help.'); - } - }, [csvInput, maxPages]); - const handleClear = () => { setCsvInput(''); onUpdatePagesFromCSV(''); @@ -56,10 +39,12 @@ const BulkSelectionPanel = ({ onToggleAdvanced={setAdvancedOpened} /> + + { + const [syntaxError, setSyntaxError] = useState(null); + const { t } = useTranslation(); + + useEffect(() => { + const text = (input || '').trim(); + if (!text) { + setSyntaxError(null); + return; + } + + try { + const { warning } = parseSelectionWithDiagnostics(text, maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES); + setSyntaxError(warning ? t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.') : null); + } catch { + setSyntaxError(t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.')); + } + }, [input, maxPages]); + + if (!syntaxError) return null; + + return ( +
+ {syntaxError} +
+ ); +}; + +export default PageSelectionSyntaxHint; + + diff --git a/frontend/src/core/components/tools/extractPages/ExtractPagesSettings.tsx b/frontend/src/core/components/tools/extractPages/ExtractPagesSettings.tsx new file mode 100644 index 000000000..4489458db --- /dev/null +++ b/frontend/src/core/components/tools/extractPages/ExtractPagesSettings.tsx @@ -0,0 +1,36 @@ +import { Stack, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters"; +import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint"; + +interface ExtractPagesSettingsProps { + parameters: ExtractPagesParameters; + onParameterChange: (key: K, value: ExtractPagesParameters[K]) => void; + disabled?: boolean; +} + +const ExtractPagesSettings = ({ parameters, onParameterChange, disabled = false }: ExtractPagesSettingsProps) => { + const { t } = useTranslation(); + + const handleChange = (value: string) => { + onParameterChange('pageNumbers', value); + }; + + return ( + + handleChange(event.currentTarget.value)} + placeholder={t('extractPages.pageNumbers.placeholder', 'e.g., 1,3,5-8 or odd & 1-10')} + disabled={disabled} + required + /> + + + ); +}; + +export default ExtractPagesSettings; + + diff --git a/frontend/src/core/components/tooltips/useExtractPagesTips.ts b/frontend/src/core/components/tooltips/useExtractPagesTips.ts new file mode 100644 index 000000000..7c13b30c2 --- /dev/null +++ b/frontend/src/core/components/tooltips/useExtractPagesTips.ts @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; +import { usePageSelectionTips } from '@app/components/tooltips/usePageSelectionTips'; + +export const useExtractPagesTips = (): TooltipContent => { + const { t } = useTranslation(); + const base = usePageSelectionTips(); + + return { + header: base.header, + tips: [ + { + description: t('extractPages.tooltip.description', 'Extracts the selected pages into a new PDF, preserving order.') + }, + ...(base.tips || []) + ] + }; +}; + +export default useExtractPagesTips; + + diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index 0860b5d96..fca3ab9b0 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -79,6 +79,7 @@ import { overlayPdfsOperationConfig } from "@app/hooks/tools/overlayPdfs/useOver import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleOperation"; import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation"; import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation"; +import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation"; import CompressSettings from "@app/components/tools/compress/CompressSettings"; import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings"; import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings"; @@ -105,7 +106,9 @@ import AddPageNumbers from "@app/tools/AddPageNumbers"; import RemoveAnnotations from "@app/tools/RemoveAnnotations"; import PageLayoutSettings from "@app/components/tools/pageLayout/PageLayoutSettings"; import ExtractImages from "@app/tools/ExtractImages"; +import ExtractPages from "@app/tools/ExtractPages"; import ExtractImagesSettings from "@app/components/tools/extractImages/ExtractImagesSettings"; +import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings"; import ReplaceColorSettings from "@app/components/tools/replaceColor/ReplaceColorSettings"; import AddStampAutomationSettings from "@app/components/tools/addStamp/AddStampAutomationSettings"; import CertSignAutomationSettings from "@app/components/tools/certSign/CertSignAutomationSettings"; @@ -474,12 +477,14 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { extractPages: { icon: , name: t("home.extractPages.title", "Extract Pages"), - component: null, + component: ExtractPages, description: t("home.extractPages.desc", "Extract specific pages from a PDF document"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.EXTRACTION, synonyms: getSynonyms(t, "extractPages"), - automationSettings: null, + automationSettings: ExtractPagesSettings, + operationConfig: extractPagesOperationConfig, + endpoints: ["rearrange-pages"], }, extractImages: { icon: , diff --git a/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts b/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts new file mode 100644 index 000000000..086fd65cc --- /dev/null +++ b/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts @@ -0,0 +1,59 @@ +import apiClient from '@app/services/apiClient'; +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation'; +import { createStandardErrorHandler } from '@app/utils/toolErrorHandler'; +import { ExtractPagesParameters, defaultParameters } from '@app/hooks/tools/extractPages/useExtractPagesParameters'; +import { pdfWorkerManager } from '@app/services/pdfWorkerManager'; +import { parseSelection } from '@app/utils/bulkselection/parseSelection'; + +// Convert advanced page selection expression into CSV of explicit one-based page numbers +async function resolveSelectionToCsv(expression: string, file: File): Promise { + // Load PDF to determine max pages + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { disableAutoFetch: true, disableStream: true }); + try { + const maxPages = pdf.numPages; + const pages = parseSelection(expression || '', maxPages); + return pages.join(','); + } finally { + pdfWorkerManager.destroyDocument(pdf); + } +} + +export const extractPagesOperationConfig = { + toolType: ToolType.custom, + operationType: 'extractPages', + customProcessor: async (parameters: ExtractPagesParameters, files: File[]): Promise => { + const outputs: File[] = []; + + for (const file of files) { + // Resolve selection into CSV acceptable by backend + const csv = await resolveSelectionToCsv(parameters.pageNumbers, file); + + const formData = new FormData(); + formData.append('fileInput', file); + formData.append('pageNumbers', csv); + + const response = await apiClient.post('/api/v1/general/rearrange-pages', formData, { responseType: 'blob' }); + + // Name output file with suffix + const base = (file.name || 'document.pdf').replace(/\.[^.]+$/, ''); + const outName = `${base}_extracted_pages.pdf`; + const outFile = new File([response.data], outName, { type: 'application/pdf' }); + outputs.push(outFile); + } + + return outputs; + }, + defaultParameters, +} as const; + +export const useExtractPagesOperation = () => { + const { t } = useTranslation(); + return useToolOperation({ + ...extractPagesOperationConfig, + getErrorMessage: createStandardErrorHandler(t('extractPages.error.failed', 'Failed to extract pages')) + }); +}; + + diff --git a/frontend/src/core/hooks/tools/extractPages/useExtractPagesParameters.ts b/frontend/src/core/hooks/tools/extractPages/useExtractPagesParameters.ts new file mode 100644 index 000000000..8ff220166 --- /dev/null +++ b/frontend/src/core/hooks/tools/extractPages/useExtractPagesParameters.ts @@ -0,0 +1,22 @@ +import { BaseParameters } from '@app/types/parameters'; +import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters'; + +export interface ExtractPagesParameters extends BaseParameters { + pageNumbers: string; +} + +export const defaultParameters: ExtractPagesParameters = { + pageNumbers: '', +}; + +export type ExtractPagesParametersHook = BaseParametersHook; + +export const useExtractPagesParameters = (): ExtractPagesParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'rearrange-pages', + validateFn: (p) => (p.pageNumbers || '').trim().length > 0, + }); +}; + + diff --git a/frontend/src/core/tools/ExtractPages.tsx b/frontend/src/core/tools/ExtractPages.tsx new file mode 100644 index 000000000..402187aa8 --- /dev/null +++ b/frontend/src/core/tools/ExtractPages.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; +import { BaseToolProps, ToolComponent } from "@app/types/tool"; +import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; +import { useExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters"; +import { useExtractPagesOperation } from "@app/hooks/tools/extractPages/useExtractPagesOperation"; +import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings"; +import useExtractPagesTips from "@app/components/tooltips/useExtractPagesTips"; + +const ExtractPages = (props: BaseToolProps) => { + const { t } = useTranslation(); + const tooltipContent = useExtractPagesTips(); + + const base = useBaseTool( + 'extract-pages', + useExtractPagesParameters, + useExtractPagesOperation, + props + ); + + const settingsContent = ( + + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: t("extractPages.settings.title", "Settings"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + content: settingsContent, + tooltip: tooltipContent, + }, + ], + executeButton: { + text: t("extractPages.submit", "Extract Pages"), + loadingText: t("loading"), + onClick: base.handleExecute, + isVisible: !base.hasResults, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("extractPages.results.title", "Pages Extracted"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default ExtractPages as ToolComponent; + + diff --git a/frontend/src/core/components/pageEditor/bulkSelectionPanel/BulkSelection.ts b/frontend/src/core/utils/bulkselection/selectionBuilders.ts similarity index 98% rename from frontend/src/core/components/pageEditor/bulkSelectionPanel/BulkSelection.ts rename to frontend/src/core/utils/bulkselection/selectionBuilders.ts index d99b03a51..7d8146089 100644 --- a/frontend/src/core/components/pageEditor/bulkSelectionPanel/BulkSelection.ts +++ b/frontend/src/core/utils/bulkselection/selectionBuilders.ts @@ -1,4 +1,4 @@ -// Pure helper utilities for the BulkSelectionPanel UI +// Pure helper utilities for building and manipulating bulk page selection expressions export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd'; @@ -33,7 +33,7 @@ export function insertOperatorSmart(currentInput: string, op: LogicalOperator): } return `${text} or ${op} `; } - + if (text.length === 0) return `${op} `; // Extract up to the last two operator tokens (words or symbols) from the end