mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-26 17:52:59 +02:00
Merge branch 'V2' into feature/multiPageLayout
This commit is contained in:
commit
1c4c28bc44
@ -229,7 +229,7 @@ public class MergeController {
|
||||
if (!invalidIndexes.isEmpty()) {
|
||||
// Parse client file IDs (always present from frontend)
|
||||
String[] clientIds = parseClientFileIds(request.getClientFileIds());
|
||||
|
||||
|
||||
// Map invalid indexes to client IDs
|
||||
List<String> errorFileIds = new ArrayList<>();
|
||||
for (Integer index : invalidIndexes) {
|
||||
@ -237,12 +237,12 @@ public class MergeController {
|
||||
errorFileIds.add(clientIds[index]);
|
||||
}
|
||||
}
|
||||
|
||||
String payload = String.format(
|
||||
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
|
||||
errorFileIds.toString()
|
||||
);
|
||||
|
||||
|
||||
String payload =
|
||||
String.format(
|
||||
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
|
||||
errorFileIds.toString());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(payload.getBytes(StandardCharsets.UTF_8));
|
||||
|
@ -436,8 +436,8 @@
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"tags": "detect,split,photos",
|
||||
"title": "Detect/Split Scanned photos",
|
||||
"desc": "Splits multiple photos from within a photo/PDF"
|
||||
"title": "Detect & Split Scanned Photos",
|
||||
"desc": "Detect and split scanned photos into separate pages"
|
||||
},
|
||||
"sign": {
|
||||
"tags": "signature,autograph",
|
||||
@ -619,8 +619,7 @@
|
||||
"title": "Auto Split by Size/Count",
|
||||
"desc": "Automatically split PDFs by file size or page count"
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"tags": "color,replace,invert",
|
||||
"replaceColor": {
|
||||
"title": "Replace & Invert Colour",
|
||||
"desc": "Replace or invert colours in PDF documents"
|
||||
},
|
||||
@ -959,7 +958,7 @@
|
||||
"header": "PDF Page Organiser",
|
||||
"submit": "Rearrange Pages",
|
||||
"mode": {
|
||||
"_value": "Mode",
|
||||
"_value": "Organization mode",
|
||||
"1": "Custom Page Order",
|
||||
"2": "Reverse Order",
|
||||
"3": "Duplex Sort",
|
||||
@ -972,6 +971,19 @@
|
||||
"10": "Odd-Even Merge",
|
||||
"11": "Duplicate all pages"
|
||||
},
|
||||
"desc": {
|
||||
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
|
||||
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
|
||||
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
|
||||
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimised for binding on the side).",
|
||||
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
|
||||
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
|
||||
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
|
||||
"REMOVE_FIRST": "Remove the first page from the document.",
|
||||
"REMOVE_LAST": "Remove the last page from the document.",
|
||||
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
|
||||
},
|
||||
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
@ -1596,7 +1608,13 @@
|
||||
"header": "Extract Images",
|
||||
"selectText": "Select image format to convert extracted images to",
|
||||
"allowDuplicates": "Save duplicate images",
|
||||
"submit": "Extract"
|
||||
"submit": "Extract",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting images from the PDF."
|
||||
}
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archive,long-term,standard,conversion,storage,preservation",
|
||||
@ -1656,18 +1674,48 @@
|
||||
"tags": "separate,auto-detect,scans,multi-photo,organize",
|
||||
"selectText": {
|
||||
"1": "Angle Threshold:",
|
||||
"2": "Sets the minimum absolute angle required for the image to be rotated (default: 10).",
|
||||
"2": "Tilt (in degrees) needed before we auto-straighten a photo.",
|
||||
"3": "Tolerance:",
|
||||
"4": "Determines the range of colour variation around the estimated background colour (default: 30).",
|
||||
"4": "How closely a colour must match the page background to count as background. Higher = looser, lower = stricter.",
|
||||
"5": "Minimum Area:",
|
||||
"6": "Sets the minimum area threshold for a photo (default: 10000).",
|
||||
"6": "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments.",
|
||||
"7": "Minimum Contour Area:",
|
||||
"8": "Sets the minimum contour area threshold for a photo",
|
||||
"8": "Smallest edge/shape we consider when finding photos (filters dust and specks).",
|
||||
"9": "Border Size:",
|
||||
"10": "Sets the size of the border added and removed to prevent white borders in the output (default: 1)."
|
||||
"10": "Extra padding (in pixels) around each saved photo so edges aren't cut."
|
||||
},
|
||||
"info": "Python is not installed. It is required to run."
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "Extracted Images",
|
||||
"submit": "Extract Image Scans",
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting image scans."
|
||||
},
|
||||
"tooltip": {
|
||||
"title": "Photo Splitter",
|
||||
"whatThisDoes": "What this does",
|
||||
"whatThisDoesDesc": "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.",
|
||||
"whenToUse": "When to use",
|
||||
"useCase1": "Scan whole album pages in one go",
|
||||
"useCase2": "Split flatbed batches into separate files",
|
||||
"useCase3": "Break collages into individual photos",
|
||||
"useCase4": "Pull photos from documents",
|
||||
"quickFixes": "Quick fixes",
|
||||
"problem1": "Photos not detected → increase Tolerance to 30-50",
|
||||
"problem2": "Too many false detections → increase Minimum Area to 15,000-20,000",
|
||||
"problem3": "Crops are too tight → increase Border Size to 5-10",
|
||||
"problem4": "Tilted photos not straightened → lower Angle Threshold to ~5°",
|
||||
"problem5": "Dust/noise boxes → increase Minimum Contour Area to 1000-2000",
|
||||
"setupTips": "Setup tips",
|
||||
"tip1": "Use a plain, light background",
|
||||
"tip2": "Leave a small gap (≈1 cm) between photos",
|
||||
"tip3": "Scan at 300-600 DPI",
|
||||
"tip4": "Clean the scanner glass",
|
||||
"headsUp": "Heads-up",
|
||||
"headsUpDesc": "Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy-try a lighter or darker background and leave more space."
|
||||
}
|
||||
},
|
||||
"sign": {
|
||||
"title": "Sign",
|
||||
"header": "Sign PDFs",
|
||||
@ -2503,25 +2551,48 @@
|
||||
},
|
||||
"selectCustomCert": "Custom Certificate File X.509 (Optional)"
|
||||
},
|
||||
"replace-color": {
|
||||
"title": "Advanced Colour options",
|
||||
"header": "Replace-Invert Colour PDF",
|
||||
"selectText": {
|
||||
"1": "Replace or Invert colour Options",
|
||||
"2": "Default(Default high contrast colours)",
|
||||
"3": "Custom(Customised colours)",
|
||||
"4": "Full-Invert(Invert all colours)",
|
||||
"5": "High contrast colour options",
|
||||
"6": "white text on black background",
|
||||
"7": "Black text on white background",
|
||||
"8": "Yellow text on black background",
|
||||
"9": "Green text on black background",
|
||||
"10": "Choose text Colour",
|
||||
"11": "Choose background Colour"
|
||||
"replaceColor": {
|
||||
"labels": {
|
||||
"settings": "Settings",
|
||||
"colourOperation": "Colour operation"
|
||||
},
|
||||
"submit": "Replace"
|
||||
"options": {
|
||||
"highContrast": "High contrast",
|
||||
"invertAll": "Invert all colours",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Replace & Invert Colour Settings Overview"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"text": "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes."
|
||||
},
|
||||
"highContrast": {
|
||||
"title": "High Contrast",
|
||||
"text": "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance.",
|
||||
"bullet1": "White text on black background - Classic dark mode",
|
||||
"bullet2": "Black text on white background - Standard high contrast",
|
||||
"bullet3": "Yellow text on black background - High visibility option",
|
||||
"bullet4": "Green text on black background - Alternative high contrast"
|
||||
},
|
||||
"invertAll": {
|
||||
"title": "Invert All Colours",
|
||||
"text": "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
|
||||
},
|
||||
"custom": {
|
||||
"title": "Custom Colours",
|
||||
"text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.",
|
||||
"bullet1": "Text colour - Choose the colour for text elements",
|
||||
"bullet2": "Background colour - Set the background colour for the document"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while processing the colour replacement."
|
||||
}
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"replaceColor": {
|
||||
"tags": "Replace Colour,Page operations,Back end,server side"
|
||||
},
|
||||
"login": {
|
||||
@ -3354,6 +3425,18 @@
|
||||
"generateError": "We couldn't generate your API key."
|
||||
}
|
||||
},
|
||||
"AddAttachmentsRequest": {
|
||||
"attachments": "Select Attachments",
|
||||
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
|
||||
"selectFiles": "Select Files to Attach",
|
||||
"placeholder": "Choose files...",
|
||||
"addMoreFiles": "Add more files...",
|
||||
"selectedFiles": "Selected Files",
|
||||
"submit": "Add Attachments",
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "Terms & Conditions",
|
||||
"logOut": "Log out"
|
||||
}
|
@ -769,7 +769,7 @@
|
||||
"header": "PDF Page Organizer",
|
||||
"submit": "Rearrange Pages",
|
||||
"mode": {
|
||||
"_value": "Mode",
|
||||
"_value": "Organization mode",
|
||||
"1": "Custom Page Order",
|
||||
"2": "Reverse Order",
|
||||
"3": "Duplex Sort",
|
||||
@ -782,6 +782,19 @@
|
||||
"10": "Odd-Even Merge",
|
||||
"11": "Duplicate all pages"
|
||||
},
|
||||
"desc": {
|
||||
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
|
||||
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
|
||||
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
|
||||
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side).",
|
||||
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
|
||||
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
|
||||
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
|
||||
"REMOVE_FIRST": "Remove the first page from the document.",
|
||||
"REMOVE_LAST": "Remove the last page from the document.",
|
||||
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
|
||||
},
|
||||
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
@ -1056,7 +1069,13 @@
|
||||
"header": "Extract Images",
|
||||
"selectText": "Select image format to convert extracted images to",
|
||||
"allowDuplicates": "Save duplicate images",
|
||||
"submit": "Extract"
|
||||
"submit": "Extract",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting images from the PDF."
|
||||
}
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archive,long-term,standard,conversion,storage,preservation",
|
||||
@ -1804,10 +1823,16 @@
|
||||
}
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "Remove image",
|
||||
"header": "Remove image",
|
||||
"removeImage": "Remove image",
|
||||
"submit": "Remove image"
|
||||
"title": "Remove Images",
|
||||
"header": "Remove Images",
|
||||
"removeImage": "Remove Images",
|
||||
"submit": "Remove Images",
|
||||
"results": {
|
||||
"title": "Remove Images Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "Failed to remove images from the PDF."
|
||||
}
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "Split PDF by Chapters",
|
||||
@ -1852,7 +1877,7 @@
|
||||
"title": "How we use Cookies",
|
||||
"description": {
|
||||
"1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.",
|
||||
"2": "If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
|
||||
"2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
|
||||
},
|
||||
"acceptAllBtn": "Okay",
|
||||
"acceptNecessaryBtn": "No Thanks",
|
||||
@ -1876,7 +1901,7 @@
|
||||
"1": "Strictly Necessary Cookies",
|
||||
"2": "Always Enabled"
|
||||
},
|
||||
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off."
|
||||
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
@ -2356,5 +2381,22 @@
|
||||
},
|
||||
"automate": {
|
||||
"copyToSaved": "Copy to Saved"
|
||||
},
|
||||
"AddAttachmentsRequest": {
|
||||
"attachments": "Select Attachments",
|
||||
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
|
||||
"selectFiles": "Select Files to Attach",
|
||||
"placeholder": "Choose files...",
|
||||
"addMoreFiles": "Add more files...",
|
||||
"selectedFiles": "Selected Files",
|
||||
"submit": "Add Attachments",
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
}
|
||||
},
|
||||
"addAttachments": {
|
||||
"error": {
|
||||
"failed": "An error occurred while adding attachments to the PDF."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,46 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Select, Checkbox } from '@mantine/core';
|
||||
import { ExtractImagesParameters } from '../../../hooks/tools/extractImages/useExtractImagesParameters';
|
||||
|
||||
interface ExtractImagesSettingsProps {
|
||||
parameters: ExtractImagesParameters;
|
||||
onParameterChange: <K extends keyof ExtractImagesParameters>(key: K, value: ExtractImagesParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ExtractImagesSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: ExtractImagesSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={t('extractImages.selectText', 'Output Format')}
|
||||
value={parameters.format}
|
||||
onChange={(value) => {
|
||||
const allowedFormats = ['png', 'jpg', 'gif'] as const;
|
||||
const format = allowedFormats.includes(value as any) ? (value as typeof allowedFormats[number]) : 'png';
|
||||
onParameterChange('format', format);
|
||||
}}
|
||||
data={[
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpg', label: 'JPG' },
|
||||
{ value: 'gif', label: 'GIF' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label={t('extractImages.allowDuplicates', 'Allow Duplicate Images')}
|
||||
checked={parameters.allowDuplicates}
|
||||
onChange={(event) => onParameterChange('allowDuplicates', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtractImagesSettings;
|
@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Divider, Select, Stack, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReorganizePagesParameters } from '../../../hooks/tools/reorganizePages/useReorganizePagesParameters';
|
||||
import { getReorganizePagesModeData } from './constants';
|
||||
|
||||
export default function ReorganizePagesSettings({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled,
|
||||
}: {
|
||||
parameters: ReorganizePagesParameters;
|
||||
onParameterChange: <K extends keyof ReorganizePagesParameters>(
|
||||
key: K,
|
||||
value: ReorganizePagesParameters[K]
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const modeData = getReorganizePagesModeData(t);
|
||||
|
||||
const requiresOrder = parameters.customMode === '' || parameters.customMode === 'DUPLICATE';
|
||||
const selectedMode = modeData.find(mode => mode.value === parameters.customMode) || modeData[0];
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t('pdfOrganiser.mode._value', 'Organization mode')}
|
||||
data={modeData}
|
||||
value={parameters.customMode}
|
||||
onChange={(v) => onParameterChange('customMode', v ?? '')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{selectedMode && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--information-text-bg)',
|
||||
color: 'var(--information-text-color)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
marginTop: '4px',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
{selectedMode.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiresOrder && (
|
||||
<>
|
||||
<Divider/>
|
||||
<TextInput
|
||||
label={t('pageOrderPrompt', 'Page order / ranges')}
|
||||
placeholder={t('pdfOrganiser.placeholder', 'e.g. 1,3,2,4-6')}
|
||||
value={parameters.pageNumbers}
|
||||
onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
59
frontend/src/components/tools/reorganizePages/constants.ts
Normal file
59
frontend/src/components/tools/reorganizePages/constants.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
export const getReorganizePagesModeData = (t: TFunction) => [
|
||||
{
|
||||
value: '',
|
||||
label: t('pdfOrganiser.mode.1', 'Custom Page Order'),
|
||||
description: t('pdfOrganiser.mode.desc.CUSTOM', 'Use a custom sequence of page numbers or expressions to define a new order.')
|
||||
},
|
||||
{
|
||||
value: 'REVERSE_ORDER',
|
||||
label: t('pdfOrganiser.mode.2', 'Reverse Order'),
|
||||
description: t('pdfOrganiser.mode.desc.REVERSE_ORDER', 'Flip the document so the last page becomes first and so on.')
|
||||
},
|
||||
{
|
||||
value: 'DUPLEX_SORT',
|
||||
label: t('pdfOrganiser.mode.3', 'Duplex Sort'),
|
||||
description: t('pdfOrganiser.mode.desc.DUPLEX_SORT', 'Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).')
|
||||
},
|
||||
{
|
||||
value: 'BOOKLET_SORT',
|
||||
label: t('pdfOrganiser.mode.4', 'Booklet Sort'),
|
||||
description: t('pdfOrganiser.mode.desc.BOOKLET_SORT', 'Arrange pages for booklet printing (last, first, second, second last, …).')
|
||||
},
|
||||
{
|
||||
value: 'SIDE_STITCH_BOOKLET_SORT',
|
||||
label: t('pdfOrganiser.mode.5', 'Side Stitch Booklet Sort'),
|
||||
description: t('pdfOrganiser.mode.desc.SIDE_STITCH_BOOKLET_SORT', 'Arrange pages for side‑stitch booklet printing (optimized for binding on the side).')
|
||||
},
|
||||
{
|
||||
value: 'ODD_EVEN_SPLIT',
|
||||
label: t('pdfOrganiser.mode.6', 'Odd-Even Split'),
|
||||
description: t('pdfOrganiser.mode.desc.ODD_EVEN_SPLIT', 'Split the document into two outputs: all odd pages and all even pages.')
|
||||
},
|
||||
{
|
||||
value: 'ODD_EVEN_MERGE',
|
||||
label: t('pdfOrganiser.mode.10', 'Odd-Even Merge'),
|
||||
description: t('pdfOrganiser.mode.desc.ODD_EVEN_MERGE', 'Merge two PDFs by alternating pages: odd from the first, even from the second.')
|
||||
},
|
||||
{
|
||||
value: 'DUPLICATE',
|
||||
label: t('pdfOrganiser.mode.11', 'Duplicate all pages'),
|
||||
description: t('pdfOrganiser.mode.desc.DUPLICATE', 'Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).')
|
||||
},
|
||||
{
|
||||
value: 'REMOVE_FIRST',
|
||||
label: t('pdfOrganiser.mode.7', 'Remove First'),
|
||||
description: t('pdfOrganiser.mode.desc.REMOVE_FIRST', 'Remove the first page from the document.')
|
||||
},
|
||||
{
|
||||
value: 'REMOVE_LAST',
|
||||
label: t('pdfOrganiser.mode.8', 'Remove Last'),
|
||||
description: t('pdfOrganiser.mode.desc.REMOVE_LAST', 'Remove the last page from the document.')
|
||||
},
|
||||
{
|
||||
value: 'REMOVE_FIRST_AND_LAST',
|
||||
label: t('pdfOrganiser.mode.9', 'Remove First and Last'),
|
||||
description: t('pdfOrganiser.mode.desc.REMOVE_FIRST_AND_LAST', 'Remove both the first and last pages from the document.')
|
||||
},
|
||||
];
|
@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, Select, ColorInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ReplaceColorParameters } from "../../../hooks/tools/replaceColor/useReplaceColorParameters";
|
||||
|
||||
interface ReplaceColorSettingsProps {
|
||||
parameters: ReplaceColorParameters;
|
||||
onParameterChange: <K extends keyof ReplaceColorParameters>(key: K, value: ReplaceColorParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ReplaceColorSettings = ({ parameters, onParameterChange, disabled = false }: ReplaceColorSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const replaceAndInvertOptions = [
|
||||
{
|
||||
value: 'HIGH_CONTRAST_COLOR',
|
||||
label: t('replaceColor.options.highContrast', 'High contrast')
|
||||
},
|
||||
{
|
||||
value: 'FULL_INVERSION',
|
||||
label: t('replaceColor.options.invertAll', 'Invert all colours')
|
||||
},
|
||||
{
|
||||
value: 'CUSTOM_COLOR',
|
||||
label: t('replaceColor.options.custom', 'Custom')
|
||||
}
|
||||
];
|
||||
|
||||
const highContrastOptions = [
|
||||
{
|
||||
value: 'WHITE_TEXT_ON_BLACK',
|
||||
label: t('replace-color.selectText.6', 'White text on black background')
|
||||
},
|
||||
{
|
||||
value: 'BLACK_TEXT_ON_WHITE',
|
||||
label: t('replace-color.selectText.7', 'Black text on white background')
|
||||
},
|
||||
{
|
||||
value: 'YELLOW_TEXT_ON_BLACK',
|
||||
label: t('replace-color.selectText.8', 'Yellow text on black background')
|
||||
},
|
||||
{
|
||||
value: 'GREEN_TEXT_ON_BLACK',
|
||||
label: t('replace-color.selectText.9', 'Green text on black background')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('replaceColor.labels.colourOperation', 'Colour operation')}
|
||||
</Text>
|
||||
<Select
|
||||
value={parameters.replaceAndInvertOption}
|
||||
onChange={(value) => value && onParameterChange('replaceAndInvertOption', value as ReplaceColorParameters['replaceAndInvertOption'])}
|
||||
data={replaceAndInvertOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{parameters.replaceAndInvertOption === 'HIGH_CONTRAST_COLOR' && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('replace-color.selectText.5', 'High contrast color options')}
|
||||
</Text>
|
||||
<Select
|
||||
value={parameters.highContrastColorCombination}
|
||||
onChange={(value) => value && onParameterChange('highContrastColorCombination', value as ReplaceColorParameters['highContrastColorCombination'])}
|
||||
data={highContrastOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{parameters.replaceAndInvertOption === 'CUSTOM_COLOR' && (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('replace-color.selectText.10', 'Choose text Color')}
|
||||
</Text>
|
||||
<ColorInput
|
||||
value={parameters.textColor}
|
||||
onChange={(value) => onParameterChange('textColor', value)}
|
||||
format="hex"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('replace-color.selectText.11', 'Choose background Color')}
|
||||
</Text>
|
||||
<ColorInput
|
||||
value={parameters.backGroundColor}
|
||||
onChange={(value) => onParameterChange('backGroundColor', value)}
|
||||
format="hex"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplaceColorSettings;
|
@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NumberInput, Stack } from '@mantine/core';
|
||||
import { ScannerImageSplitParameters } from '../../../hooks/tools/scannerImageSplit/useScannerImageSplitParameters';
|
||||
|
||||
interface ScannerImageSplitSettingsProps {
|
||||
parameters: ScannerImageSplitParameters;
|
||||
onParameterChange: <K extends keyof ScannerImageSplitParameters>(key: K, value: ScannerImageSplitParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ScannerImageSplitSettings: React.FC<ScannerImageSplitSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.1', 'Angle Threshold:')}
|
||||
description={t('ScannerImageSplit.selectText.2', 'Sets the minimum absolute angle required for the image to be rotated (default: 10).')}
|
||||
value={parameters.angle_threshold}
|
||||
onChange={(value) => onParameterChange('angle_threshold', Number(value) || 10)}
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.3', 'Tolerance:')}
|
||||
description={t('ScannerImageSplit.selectText.4', 'Determines the range of colour variation around the estimated background colour (default: 30).')}
|
||||
value={parameters.tolerance}
|
||||
onChange={(value) => onParameterChange('tolerance', Number(value) || 30)}
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.5', 'Minimum Area:')}
|
||||
description={t('ScannerImageSplit.selectText.6', 'Sets the minimum area threshold for a photo (default: 10000).')}
|
||||
value={parameters.min_area}
|
||||
onChange={(value) => onParameterChange('min_area', Number(value) || 10000)}
|
||||
min={0}
|
||||
step={100}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.7', 'Minimum Contour Area:')}
|
||||
description={t('ScannerImageSplit.selectText.8', 'Sets the minimum contour area threshold for a photo.')}
|
||||
value={parameters.min_contour_area}
|
||||
onChange={(value) => onParameterChange('min_contour_area', Number(value) || 500)}
|
||||
min={0}
|
||||
step={10}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.9', 'Border Size:')}
|
||||
description={t('ScannerImageSplit.selectText.10', 'Sets the size of the border added and removed to prevent white borders in the output (default: 1).')}
|
||||
value={parameters.border_size}
|
||||
onChange={(value) => onParameterChange('border_size', Number(value) || 1)}
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScannerImageSplitSettings;
|
40
frontend/src/components/tooltips/useReplaceColorTips.ts
Normal file
40
frontend/src/components/tooltips/useReplaceColorTips.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useReplaceColorTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("replaceColor.tooltip.header.title", "Replace & Invert Colour Settings Overview")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("replaceColor.tooltip.description.title", "Description"),
|
||||
description: t("replaceColor.tooltip.description.text", "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes.")
|
||||
},
|
||||
{
|
||||
title: t("replaceColor.tooltip.highContrast.title", "High Contrast"),
|
||||
description: t("replaceColor.tooltip.highContrast.text", "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance."),
|
||||
bullets: [
|
||||
t("replaceColor.tooltip.highContrast.bullet1", "White text on black background - Classic dark mode"),
|
||||
t("replaceColor.tooltip.highContrast.bullet2", "Black text on white background - Standard high contrast"),
|
||||
t("replaceColor.tooltip.highContrast.bullet3", "Yellow text on black background - High visibility option"),
|
||||
t("replaceColor.tooltip.highContrast.bullet4", "Green text on black background - Alternative high contrast")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("replaceColor.tooltip.invertAll.title", "Invert All Colours"),
|
||||
description: t("replaceColor.tooltip.invertAll.text", "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions.")
|
||||
},
|
||||
{
|
||||
title: t("replaceColor.tooltip.custom.title", "Custom Colours"),
|
||||
description: t("replaceColor.tooltip.custom.text", "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements."),
|
||||
bullets: [
|
||||
t("replaceColor.tooltip.custom.bullet1", "Text colour - Choose the colour for text elements"),
|
||||
t("replaceColor.tooltip.custom.bullet2", "Background colour - Set the background colour for the document")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
54
frontend/src/components/tooltips/useScannerImageSplitTips.ts
Normal file
54
frontend/src/components/tooltips/useScannerImageSplitTips.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useScannerImageSplitTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('scannerImageSplit.tooltip.title', 'Photo Splitter')
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.whatThisDoes', 'What this does'),
|
||||
description: t('scannerImageSplit.tooltip.whatThisDoesDesc',
|
||||
'Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.'
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.whenToUse', 'When to use'),
|
||||
bullets: [
|
||||
t('scannerImageSplit.tooltip.useCase1', 'Scan whole album pages in one go'),
|
||||
t('scannerImageSplit.tooltip.useCase2', 'Split flatbed batches into separate files'),
|
||||
t('scannerImageSplit.tooltip.useCase3', 'Break collages into individual photos'),
|
||||
t('scannerImageSplit.tooltip.useCase4', 'Pull photos from documents')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.quickFixes', 'Quick fixes'),
|
||||
bullets: [
|
||||
t('scannerImageSplit.tooltip.problem1', 'Photos not detected → increase Tolerance to 30–50'),
|
||||
t('scannerImageSplit.tooltip.problem2', 'Too many false detections → increase Minimum Area to 15,000–20,000'),
|
||||
t('scannerImageSplit.tooltip.problem3', 'Crops are too tight → increase Border Size to 5–10'),
|
||||
t('scannerImageSplit.tooltip.problem4', 'Tilted photos not straightened → lower Angle Threshold to ~5°'),
|
||||
t('scannerImageSplit.tooltip.problem5', 'Dust/noise boxes → increase Minimum Contour Area to 1000–2000')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.setupTips', 'Setup tips'),
|
||||
bullets: [
|
||||
t('scannerImageSplit.tooltip.tip1', 'Use a plain, light background'),
|
||||
t('scannerImageSplit.tooltip.tip2', 'Leave a small gap (≈1 cm) between photos'),
|
||||
t('scannerImageSplit.tooltip.tip3', 'Scan at 300–600 DPI'),
|
||||
t('scannerImageSplit.tooltip.tip4', 'Clean the scanner glass')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.headsUp', 'Heads-up'),
|
||||
description: t('scannerImageSplit.tooltip.headsUpDesc',
|
||||
'Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy—try a lighter or darker background and leave more space.'
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
@ -10,11 +10,14 @@ import AddPassword from "../tools/AddPassword";
|
||||
import ChangePermissions from "../tools/ChangePermissions";
|
||||
import RemoveBlanks from "../tools/RemoveBlanks";
|
||||
import RemovePages from "../tools/RemovePages";
|
||||
import ReorganizePages from "../tools/ReorganizePages";
|
||||
import { reorganizePagesOperationConfig } from "../hooks/tools/reorganizePages/useReorganizePagesOperation";
|
||||
import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import { getSynonyms } from "../utils/toolSynonyms";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
import AddStamp from "../tools/AddStamp";
|
||||
import AddAttachments from "../tools/AddAttachments";
|
||||
import Merge from '../tools/Merge';
|
||||
import Repair from "../tools/Repair";
|
||||
import AutoRename from "../tools/AutoRename";
|
||||
@ -22,6 +25,7 @@ import SingleLargePage from "../tools/SingleLargePage";
|
||||
import PageLayout from "../tools/PageLayout";
|
||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||
import RemoveImage from "../tools/RemoveImage";
|
||||
import CertSign from "../tools/CertSign";
|
||||
import BookletImposition from "../tools/BookletImposition";
|
||||
import Flatten from "../tools/Flatten";
|
||||
@ -36,6 +40,7 @@ import { sanitizeOperationConfig } from "../hooks/tools/sanitize/useSanitizeOper
|
||||
import { repairOperationConfig } from "../hooks/tools/repair/useRepairOperation";
|
||||
import { addWatermarkOperationConfig } from "../hooks/tools/addWatermark/useAddWatermarkOperation";
|
||||
import { addStampOperationConfig } from "../components/tools/addStamp/useAddStampOperation";
|
||||
import { addAttachmentsOperationConfig } from "../hooks/tools/addAttachments/useAddAttachmentsOperation";
|
||||
import { unlockPdfFormsOperationConfig } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
|
||||
import { singleLargePageOperationConfig } from "../hooks/tools/singleLargePage/useSingleLargePageOperation";
|
||||
import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
|
||||
@ -51,6 +56,8 @@ import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation"
|
||||
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
|
||||
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation";
|
||||
import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useExtractImagesOperation";
|
||||
import { replaceColorOperationConfig } from "../hooks/tools/replaceColor/useReplaceColorOperation";
|
||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||
@ -69,13 +76,20 @@ import RedactSingleStepSettings from "../components/tools/redact/RedactSingleSte
|
||||
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||
import Redact from "../tools/Redact";
|
||||
import AdjustPageScale from "../tools/AdjustPageScale";
|
||||
import ReplaceColor from "../tools/ReplaceColor";
|
||||
import ScannerImageSplit from "../tools/ScannerImageSplit";
|
||||
import { ToolId } from "../types/toolId";
|
||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
||||
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
|
||||
import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
||||
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
import CropSettings from "../components/tools/crop/CropSettings";
|
||||
import PageLayoutSettings from "../components/tools/pageLayout/PageLayoutSettings"
|
||||
import ExtractImages from "../tools/ExtractImages";
|
||||
import ExtractImagesSettings from "../components/tools/extractImages/ExtractImagesSettings";
|
||||
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
|
||||
@ -383,14 +397,15 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
reorganizePages: {
|
||||
icon: <LocalIcon icon="move-down-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.reorganizePages.title", "Reorganize Pages"),
|
||||
component: null,
|
||||
workbench: "pageEditor",
|
||||
component: ReorganizePages,
|
||||
description: t(
|
||||
"home.reorganizePages.desc",
|
||||
"Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: ["rearrange-pages"],
|
||||
operationConfig: reorganizePagesOperationConfig,
|
||||
synonyms: getSynonyms(t, "reorganizePages")
|
||||
},
|
||||
scalePages: {
|
||||
@ -456,12 +471,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
addAttachments: {
|
||||
icon: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.addAttachments.title", "Add Attachments"),
|
||||
component: null,
|
||||
|
||||
component: AddAttachments,
|
||||
description: t("home.addAttachments.desc", "Add or remove embedded files (attachments) to/from a PDF"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
synonyms: getSynonyms(t, "addAttachments")
|
||||
synonyms: getSynonyms(t, "addAttachments"),
|
||||
maxFiles: 1,
|
||||
endpoints: ["add-attachments"],
|
||||
operationConfig: addAttachmentsOperationConfig,
|
||||
},
|
||||
|
||||
// Extraction
|
||||
@ -476,12 +493,16 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
synonyms: getSynonyms(t, "extractPages")
|
||||
},
|
||||
extractImages: {
|
||||
icon: <LocalIcon icon="filter-alt" width="1.5rem" height="1.5rem" />,
|
||||
icon: <LocalIcon icon="photo-library-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.extractImages.title", "Extract Images"),
|
||||
component: null,
|
||||
component: ExtractImages,
|
||||
description: t("home.extractImages.desc", "Extract images from PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EXTRACTION,
|
||||
maxFiles: -1,
|
||||
endpoints: ["extract-images"],
|
||||
operationConfig: extractImagesOperationConfig,
|
||||
settingsComponent: ExtractImagesSettings,
|
||||
synonyms: getSynonyms(t, "extractImages")
|
||||
},
|
||||
|
||||
@ -520,11 +541,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
},
|
||||
removeImage: {
|
||||
icon: <LocalIcon icon="remove-selection-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.removeImage.title", "Remove Image"),
|
||||
component: null,
|
||||
description: t("home.removeImage.desc", "Remove images from PDF documents"),
|
||||
name: t("home.removeImage.title", "Remove Images"),
|
||||
component: RemoveImage,
|
||||
description: t("home.removeImage.desc", "Remove all images from a PDF document"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-image-pdf"],
|
||||
operationConfig: undefined,
|
||||
synonyms: getSynonyms(t, "removeImage"),
|
||||
},
|
||||
removePassword: {
|
||||
@ -628,10 +652,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
scannerImageSplit: {
|
||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.scannerImageSplit.title", "Detect & Split Scanned Photos"),
|
||||
component: null,
|
||||
component: ScannerImageSplit,
|
||||
description: t("home.scannerImageSplit.desc", "Detect and split scanned photos into separate pages"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["extract-image-scans"],
|
||||
operationConfig: scannerImageSplitOperationConfig,
|
||||
settingsComponent: ScannerImageSplitSettings,
|
||||
synonyms: getSynonyms(t, "ScannerImageSplit"),
|
||||
},
|
||||
overlayPdfs: {
|
||||
@ -643,14 +671,18 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "overlayPdfs"),
|
||||
},
|
||||
replaceColorPdf: {
|
||||
replaceColor: {
|
||||
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.replaceColorPdf.title", "Replace & Invert Color"),
|
||||
component: null,
|
||||
description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"),
|
||||
name: t("home.replaceColor.title", "Replace & Invert Color"),
|
||||
component: ReplaceColor,
|
||||
description: t("home.replaceColor.desc", "Replace or invert colors in PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "replaceColorPdf"),
|
||||
maxFiles: -1,
|
||||
endpoints: ["replace-invert-pdf"],
|
||||
operationConfig: replaceColorOperationConfig,
|
||||
settingsComponent: ReplaceColorSettings,
|
||||
synonyms: getSynonyms(t, "replaceColor"),
|
||||
},
|
||||
addImage: {
|
||||
icon: <LocalIcon icon="image-rounded" width="1.5rem" height="1.5rem" />,
|
||||
|
@ -0,0 +1,37 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { AddAttachmentsParameters } from './useAddAttachmentsParameters';
|
||||
|
||||
const buildFormData = (parameters: AddAttachmentsParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add the main PDF file (single file per request in singleFile mode)
|
||||
if (file) {
|
||||
formData.append("fileInput", file);
|
||||
}
|
||||
|
||||
// Add attachment files
|
||||
(parameters.attachments || []).forEach((attachment) => {
|
||||
if (attachment) formData.append("attachments", attachment);
|
||||
});
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Operation configuration for automation
|
||||
export const addAttachmentsOperationConfig: ToolOperationConfig<AddAttachmentsParameters> = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData,
|
||||
operationType: 'addAttachments',
|
||||
endpoint: '/api/v1/misc/add-attachments',
|
||||
};
|
||||
|
||||
export const useAddAttachmentsOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<AddAttachmentsParameters>({
|
||||
...addAttachmentsOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('addAttachments.error.failed', 'An error occurred while adding attachments to the PDF.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface AddAttachmentsParameters {
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
const defaultParameters: AddAttachmentsParameters = {
|
||||
attachments: []
|
||||
};
|
||||
|
||||
export const useAddAttachmentsParameters = () => {
|
||||
const [parameters, setParameters] = useState<AddAttachmentsParameters>(defaultParameters);
|
||||
|
||||
const updateParameter = <K extends keyof AddAttachmentsParameters>(
|
||||
key: K,
|
||||
value: AddAttachmentsParameters[K]
|
||||
) => {
|
||||
setParameters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(defaultParameters);
|
||||
};
|
||||
|
||||
const validateParameters = (): boolean => {
|
||||
return parameters.attachments.length > 0;
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters
|
||||
};
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import apiClient from '../../../services/apiClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters, defaultParameters } from './useConvertParameters';
|
||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||
@ -108,7 +108,7 @@ export const convertProcessor = async (
|
||||
for (const file of selectedFiles) {
|
||||
try {
|
||||
const formData = buildConvertFormData(parameters, [file]);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
|
||||
|
||||
@ -120,7 +120,7 @@ export const convertProcessor = async (
|
||||
} else {
|
||||
// Batch processing for simple cases (image→PDF combine)
|
||||
const formData = buildConvertFormData(parameters, selectedFiles);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
const baseFilename = selectedFiles.length === 1
|
||||
? selectedFiles[0].name
|
||||
|
@ -0,0 +1,51 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ExtractImagesParameters, defaultParameters } from './useExtractImagesParameters';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
// Static configuration that can be used by both the hook and automation executor
|
||||
export const buildExtractImagesFormData = (parameters: ExtractImagesParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("format", parameters.format);
|
||||
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Response handler for extract-images which returns a ZIP file
|
||||
const extractImagesResponseHandler = async (responseData: Blob, _originalFiles: File[]): Promise<File[]> => {
|
||||
const zip = new JSZip();
|
||||
const zipContent = await zip.loadAsync(responseData);
|
||||
const extractedFiles: File[] = [];
|
||||
|
||||
for (const [filename, file] of Object.entries(zipContent.files)) {
|
||||
if (!file.dir) {
|
||||
const blob = await file.async('blob');
|
||||
const extractedFile = new File([blob], filename, { type: blob.type });
|
||||
extractedFiles.push(extractedFile);
|
||||
}
|
||||
}
|
||||
|
||||
return extractedFiles;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const extractImagesOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildExtractImagesFormData,
|
||||
operationType: 'extractImages',
|
||||
endpoint: '/api/v1/misc/extract-images',
|
||||
defaultParameters,
|
||||
// Extract-images returns a ZIP file containing multiple image files
|
||||
responseHandler: extractImagesResponseHandler,
|
||||
} as const;
|
||||
|
||||
export const useExtractImagesOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<ExtractImagesParameters>({
|
||||
...extractImagesOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('extractImages.error.failed', 'An error occurred while extracting images from the PDF.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
import { useBaseParameters } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ExtractImagesParameters {
|
||||
format: 'png' | 'jpg' | 'gif';
|
||||
allowDuplicates: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: ExtractImagesParameters = {
|
||||
format: 'png',
|
||||
allowDuplicates: false,
|
||||
};
|
||||
|
||||
export const useExtractImagesParameters = () => {
|
||||
return useBaseParameters<ExtractImagesParameters>({
|
||||
defaultParameters,
|
||||
endpointName: 'extract-images',
|
||||
validateFn: () => true, // All parameters have valid defaults
|
||||
});
|
||||
};
|
@ -0,0 +1,30 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import type { RemoveImageParameters } from './useRemoveImageParameters';
|
||||
|
||||
export const buildRemoveImageFormData = (_params: RemoveImageParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const removeImageOperationConfig: ToolOperationConfig<RemoveImageParameters> = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildRemoveImageFormData,
|
||||
operationType: 'removeImage',
|
||||
endpoint: '/api/v1/general/remove-image-pdf',
|
||||
};
|
||||
|
||||
export const useRemoveImageOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<RemoveImageParameters>({
|
||||
...removeImageOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('removeImage.error.failed', 'Failed to remove images from the PDF.')
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,17 @@
|
||||
import { useBaseParameters } from '../shared/useBaseParameters';
|
||||
import type { BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export type RemoveImageParameters = Record<string, never>;
|
||||
|
||||
export const defaultParameters: RemoveImageParameters = {};
|
||||
|
||||
export type RemoveImageParametersHook = BaseParametersHook<RemoveImageParameters>;
|
||||
|
||||
export const useRemoveImageParameters = (): RemoveImageParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-image-pdf',
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,36 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolOperationConfig, ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ReorganizePagesParameters } from './useReorganizePagesParameters';
|
||||
|
||||
const buildFormData = (parameters: ReorganizePagesParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
if (parameters.customMode) {
|
||||
formData.append('customMode', parameters.customMode);
|
||||
}
|
||||
if (parameters.pageNumbers) {
|
||||
const cleaned = parameters.pageNumbers.replace(/\s+/g, '');
|
||||
formData.append('pageNumbers', cleaned);
|
||||
}
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const reorganizePagesOperationConfig: ToolOperationConfig<ReorganizePagesParameters> = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData,
|
||||
operationType: 'reorganizePages',
|
||||
endpoint: '/api/v1/general/rearrange-pages',
|
||||
};
|
||||
|
||||
export const useReorganizePagesOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
return useToolOperation<ReorganizePagesParameters>({
|
||||
...reorganizePagesOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('reorganizePages.error.failed', 'Failed to reorganize pages')
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ReorganizePagesParameters {
|
||||
customMode: string; // empty string means custom order using pageNumbers
|
||||
pageNumbers: string; // e.g. "1,3,2,4-6"
|
||||
}
|
||||
|
||||
export const defaultReorganizePagesParameters: ReorganizePagesParameters = {
|
||||
customMode: '',
|
||||
pageNumbers: '',
|
||||
};
|
||||
|
||||
export const useReorganizePagesParameters = () => {
|
||||
const [parameters, setParameters] = useState<ReorganizePagesParameters>(defaultReorganizePagesParameters);
|
||||
|
||||
const updateParameter = <K extends keyof ReorganizePagesParameters>(
|
||||
key: K,
|
||||
value: ReorganizePagesParameters[K]
|
||||
) => {
|
||||
setParameters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const resetParameters = () => setParameters(defaultReorganizePagesParameters);
|
||||
|
||||
// If customMode is '' (custom) or 'DUPLICATE', a page order is required; otherwise it's optional/ignored
|
||||
const validateParameters = (): boolean => {
|
||||
const requiresOrder = parameters.customMode === '' || parameters.customMode === 'DUPLICATE';
|
||||
return requiresOrder ? parameters.pageNumbers.trim().length > 0 : true;
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ReplaceColorParameters, defaultParameters } from './useReplaceColorParameters';
|
||||
|
||||
export const buildReplaceColorFormData = (parameters: ReplaceColorParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
|
||||
formData.append('replaceAndInvertOption', parameters.replaceAndInvertOption);
|
||||
|
||||
if (parameters.replaceAndInvertOption === 'HIGH_CONTRAST_COLOR') {
|
||||
formData.append('highContrastColorCombination', parameters.highContrastColorCombination);
|
||||
} else if (parameters.replaceAndInvertOption === 'CUSTOM_COLOR') {
|
||||
formData.append('textColor', parameters.textColor);
|
||||
formData.append('backGroundColor', parameters.backGroundColor);
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const replaceColorOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildReplaceColorFormData,
|
||||
operationType: 'replaceColor',
|
||||
endpoint: '/api/v1/misc/replace-invert-pdf',
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useReplaceColorOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<ReplaceColorParameters>({
|
||||
...replaceColorOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the color replacement.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,29 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ReplaceColorParameters extends BaseParameters {
|
||||
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR' | 'CUSTOM_COLOR' | 'FULL_INVERSION';
|
||||
highContrastColorCombination: 'WHITE_TEXT_ON_BLACK' | 'BLACK_TEXT_ON_WHITE' | 'YELLOW_TEXT_ON_BLACK' | 'GREEN_TEXT_ON_BLACK';
|
||||
textColor: string;
|
||||
backGroundColor: string;
|
||||
}
|
||||
|
||||
export const defaultParameters: ReplaceColorParameters = {
|
||||
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR',
|
||||
highContrastColorCombination: 'WHITE_TEXT_ON_BLACK',
|
||||
textColor: '#000000',
|
||||
backGroundColor: '#ffffff',
|
||||
};
|
||||
|
||||
export type ReplaceColorParametersHook = BaseParametersHook<ReplaceColorParameters>;
|
||||
|
||||
export const useReplaceColorParameters = (): ReplaceColorParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'replace-invert-pdf',
|
||||
validateFn: () => {
|
||||
// All parameters are always valid as they have defaults
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters';
|
||||
import { zipFileService } from '../../../services/zipFileService';
|
||||
|
||||
export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
formData.append('angle_threshold', parameters.angle_threshold.toString());
|
||||
formData.append('tolerance', parameters.tolerance.toString());
|
||||
formData.append('min_area', parameters.min_area.toString());
|
||||
formData.append('min_contour_area', parameters.min_contour_area.toString());
|
||||
formData.append('border_size', parameters.border_size.toString());
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Custom response handler to handle ZIP files that might be misidentified
|
||||
const scannerImageSplitResponseHandler = async (responseData: Blob, inputFiles: File[]): Promise<File[]> => {
|
||||
try {
|
||||
// Always try to extract as ZIP first, regardless of content-type
|
||||
const extractionResult = await zipFileService.extractAllFiles(responseData);
|
||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
||||
return extractionResult.extractedFiles;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract as ZIP, treating as single file:', error);
|
||||
}
|
||||
|
||||
// Fallback: treat as single file (PNG image)
|
||||
const inputFileName = inputFiles[0]?.name || 'document';
|
||||
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
|
||||
const singleFile = new File([responseData], `${baseFileName}.png`, { type: 'image/png' });
|
||||
return [singleFile];
|
||||
};
|
||||
|
||||
export const scannerImageSplitOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildScannerImageSplitFormData,
|
||||
operationType: 'scannerImageSplit',
|
||||
endpoint: '/api/v1/misc/extract-image-scans',
|
||||
multiFileEndpoint: false,
|
||||
responseHandler: scannerImageSplitResponseHandler,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useScannerImageSplitOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<ScannerImageSplitParameters>({
|
||||
...scannerImageSplitOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,31 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ScannerImageSplitParameters extends BaseParameters {
|
||||
angle_threshold: number;
|
||||
tolerance: number;
|
||||
min_area: number;
|
||||
min_contour_area: number;
|
||||
border_size: number;
|
||||
}
|
||||
|
||||
export const defaultParameters: ScannerImageSplitParameters = {
|
||||
angle_threshold: 10,
|
||||
tolerance: 30,
|
||||
min_area: 10000,
|
||||
min_contour_area: 500,
|
||||
border_size: 1,
|
||||
};
|
||||
|
||||
export type ScannerImageSplitParametersHook = BaseParametersHook<ScannerImageSplitParameters>;
|
||||
|
||||
export const useScannerImageSplitParameters = (): ScannerImageSplitParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'extract-image-scans',
|
||||
validateFn: () => {
|
||||
// All parameters are numeric with defaults, validation handled by form
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import axios, { CancelTokenSource } from '../../../services/http';
|
||||
import axios, {type CancelTokenSource} from 'axios'; // Real axios for static methods (CancelToken, isCancel)
|
||||
import apiClient from '../../../services/apiClient'; // Our configured instance
|
||||
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { isEmptyOutput } from '../../../services/errorUtils';
|
||||
import type { ProcessingProgress } from './useToolState';
|
||||
@ -42,9 +43,9 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
const formData = config.buildFormData(params, file);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
console.debug('[processFiles] POST', { endpoint, name: file.name });
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
const response = await apiClient.post(endpoint, formData, {
|
||||
responseType: 'blob',
|
||||
cancelToken: cancelTokenRef.current.token,
|
||||
cancelToken: cancelTokenRef.current?.token,
|
||||
});
|
||||
console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status });
|
||||
|
||||
@ -61,10 +62,10 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
if (empty) {
|
||||
console.warn('[processFiles] Empty output treated as failure', { name: file.name });
|
||||
failedFiles.push(file.name);
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@ -80,10 +81,10 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
console.error('[processFiles] Failed', { name: file.name, error });
|
||||
failedFiles.push(file.name);
|
||||
// mark errored file so UI can highlight
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import axios from '../../../services/http';
|
||||
import apiClient from '../../../services/apiClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
@ -177,8 +177,8 @@ export const useToolOperation = <TParams>(
|
||||
for (const f of zeroByteFiles) {
|
||||
(fileActions.markFileError as any)((f as any).fileId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('markFileError', e);
|
||||
} catch (e) {
|
||||
console.log('markFileError', e);
|
||||
}
|
||||
}
|
||||
const validFiles = selectedFiles.filter(file => (file as any)?.size > 0);
|
||||
@ -243,7 +243,7 @@ export const useToolOperation = <TParams>(
|
||||
const formData = config.buildFormData(params, filesForAPI);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||
if (config.responseHandler) {
|
||||
|
22
frontend/src/services/apiClient.ts
Normal file
22
frontend/src/services/apiClient.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// frontend/src/services/http.ts
|
||||
import axios from 'axios';
|
||||
import { handleHttpError } from './httpErrorHandler';
|
||||
|
||||
// Create axios instance with default config
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/', // Use env var or relative path (proxied by Vite in dev)
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
// ---------- Install error interceptor ----------
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// ---------- Exports ----------
|
||||
export default apiClient;
|
@ -1,255 +0,0 @@
|
||||
// frontend/src/services/http.ts
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { alert } from '../components/toast';
|
||||
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils';
|
||||
import { showSpecialErrorToast } from './specialErrorToasts';
|
||||
|
||||
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
|
||||
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
|
||||
|
||||
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
|
||||
return s && s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
|
||||
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
|
||||
const s = (msg || '').trim();
|
||||
if (!s) return true;
|
||||
// Common unhelpful payloads we see
|
||||
if (s === '{}' || s === '[]') return true;
|
||||
if (/^request failed/i.test(s)) return true;
|
||||
if (/^network error/i.test(s)) return true;
|
||||
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
|
||||
return false;
|
||||
}
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const _statusText = error.response?.statusText || '';
|
||||
let parsed: any = undefined;
|
||||
const raw = error.response?.data;
|
||||
if (typeof raw === 'string') {
|
||||
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
|
||||
} else {
|
||||
parsed = raw;
|
||||
}
|
||||
const extractIds = (): string[] | undefined => {
|
||||
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
|
||||
const rawText = typeof raw === 'string' ? raw : '';
|
||||
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
|
||||
};
|
||||
|
||||
const body = ((): string => {
|
||||
const data = parsed;
|
||||
if (!data) return typeof raw === 'string' ? raw : '';
|
||||
const ids = extractIds();
|
||||
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
||||
if (data?.message) return data.message as string;
|
||||
if (typeof raw === 'string') return raw;
|
||||
try { return JSON.stringify(data); } catch { return ''; }
|
||||
})();
|
||||
const ids = extractIds();
|
||||
const title = titleForStatus(status);
|
||||
if (ids && ids.length > 0) {
|
||||
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
|
||||
}
|
||||
if (status === 422) {
|
||||
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
try {
|
||||
const msg = (error?.message || String(error)) as string;
|
||||
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
|
||||
} catch (e) {
|
||||
// ignore extraction errors
|
||||
console.debug('extractAxiosErrorMessage', e);
|
||||
return { title: 'Network error', body: FRIENDLY_FALLBACK };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Axios instance creation ----------
|
||||
const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined);
|
||||
|
||||
type ExtendedAxiosInstance = AxiosInstance & {
|
||||
CancelToken: typeof axios.CancelToken;
|
||||
isCancel: typeof axios.isCancel;
|
||||
};
|
||||
|
||||
const __PREV_CLIENT: ExtendedAxiosInstance | undefined =
|
||||
__globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined;
|
||||
|
||||
let __createdClient: any;
|
||||
if (__PREV_CLIENT) {
|
||||
__createdClient = __PREV_CLIENT;
|
||||
} else if (typeof (axios as any)?.create === 'function') {
|
||||
try {
|
||||
__createdClient = (axios as any).create();
|
||||
} catch (e) {
|
||||
console.debug('createClient', e);
|
||||
__createdClient = axios as any;
|
||||
}
|
||||
} else {
|
||||
__createdClient = axios as any;
|
||||
}
|
||||
|
||||
const apiClient: ExtendedAxiosInstance = (__createdClient || (axios as any)) as ExtendedAxiosInstance;
|
||||
|
||||
// Augment instance with axios static helpers for backwards compatibility
|
||||
if (apiClient) {
|
||||
try { (apiClient as any).CancelToken = (axios as any).CancelToken; } catch (e) { console.debug('setCancelToken', e); }
|
||||
try { (apiClient as any).isCancel = (axios as any).isCancel; } catch (e) { console.debug('setIsCancel', e); }
|
||||
}
|
||||
|
||||
// ---------- Base defaults ----------
|
||||
try {
|
||||
const env = (import.meta as any)?.env || {};
|
||||
apiClient.defaults.baseURL = env?.VITE_API_BASE_URL ?? '/';
|
||||
apiClient.defaults.responseType = 'json';
|
||||
// If OSS relies on cookies, uncomment:
|
||||
// apiClient.defaults.withCredentials = true;
|
||||
// Sensible timeout to avoid “forever hanging”:
|
||||
apiClient.defaults.timeout = 20000;
|
||||
} catch (e) {
|
||||
console.debug('setDefaults', e);
|
||||
apiClient.defaults.baseURL = apiClient.defaults.baseURL || '/';
|
||||
apiClient.defaults.responseType = apiClient.defaults.responseType || 'json';
|
||||
apiClient.defaults.timeout = apiClient.defaults.timeout || 20000;
|
||||
}
|
||||
|
||||
// ---------- Install a single response error interceptor (dedup + UX) ----------
|
||||
if (__globalAny?.__SPDF_HTTP_ERR_INTERCEPTOR_ID !== undefined && __PREV_CLIENT) {
|
||||
try {
|
||||
__PREV_CLIENT.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID);
|
||||
} catch (e) {
|
||||
console.debug('ejectInterceptor', e);
|
||||
}
|
||||
}
|
||||
|
||||
const __recentSpecialByEndpoint: Record<string, number> = (__globalAny?.__SPDF_RECENT_SPECIAL || {});
|
||||
const __SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast
|
||||
|
||||
const __INTERCEPTOR_ID__ = apiClient?.interceptors?.response?.use
|
||||
? apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
// Compute title/body (friendly) from the error object
|
||||
const { title, body } = extractAxiosErrorMessage(error);
|
||||
|
||||
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
|
||||
const raw = (error?.response?.data) as any;
|
||||
let normalized: unknown = raw;
|
||||
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
|
||||
|
||||
// 1) If server sends structured file IDs for failures, also mark them errored in UI
|
||||
try {
|
||||
const ids = extractErrorFileIds(normalized);
|
||||
if (ids && ids.length > 0) {
|
||||
broadcastErroredFiles(ids);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
// 2) Generic-vs-special dedupe by endpoint
|
||||
const url: string | undefined = error?.config?.url;
|
||||
const status: number | undefined = error?.response?.status;
|
||||
const now = Date.now();
|
||||
const isSpecial =
|
||||
status === 422 ||
|
||||
status === 409 || // often actionable conflicts
|
||||
/Failed files:/.test(body) ||
|
||||
/invalid\/corrupted file\(s\)/i.test(body);
|
||||
|
||||
if (isSpecial && url) {
|
||||
__recentSpecialByEndpoint[url] = now;
|
||||
if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint;
|
||||
}
|
||||
if (!isSpecial && url) {
|
||||
const last = __recentSpecialByEndpoint[url] || 0;
|
||||
if (now - last < __SPECIAL_SUPPRESS_MS) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Show specialized friendly toasts if matched; otherwise show the generic one
|
||||
let rawString: string | undefined;
|
||||
try {
|
||||
rawString =
|
||||
typeof normalized === 'string'
|
||||
? normalized
|
||||
: JSON.stringify(normalized);
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
const handled = showSpecialErrorToast(rawString, { status });
|
||||
if (!handled) {
|
||||
const displayBody = clampText(body);
|
||||
alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false });
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
: undefined as any;
|
||||
|
||||
if (__globalAny) {
|
||||
__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID = __INTERCEPTOR_ID__;
|
||||
__globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint;
|
||||
__globalAny.__SPDF_HTTP_CLIENT = apiClient;
|
||||
}
|
||||
|
||||
// ---------- Fetch helper ----------
|
||||
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init });
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await res.json();
|
||||
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
|
||||
} else {
|
||||
detail = await res.text();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
const title = titleForStatus(res.status);
|
||||
const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText);
|
||||
alert({ alertType: 'error', title, body: clampText(body), expandable: true, isPersistentPopup: false });
|
||||
|
||||
// Important: match Axios semantics so callers can try/catch
|
||||
throw new Error(body || res.statusText);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---------- Convenience API surface and exports ----------
|
||||
export const api = {
|
||||
get: apiClient.get,
|
||||
post: apiClient.post,
|
||||
put: apiClient.put,
|
||||
patch: apiClient.patch,
|
||||
delete: apiClient.delete,
|
||||
request: apiClient.request,
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
export type { CancelTokenSource } from 'axios';
|
147
frontend/src/services/httpErrorHandler.ts
Normal file
147
frontend/src/services/httpErrorHandler.ts
Normal file
@ -0,0 +1,147 @@
|
||||
// frontend/src/services/httpErrorHandler.ts
|
||||
import axios from 'axios';
|
||||
import { alert } from '../components/toast';
|
||||
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils';
|
||||
import { showSpecialErrorToast } from './specialErrorToasts';
|
||||
|
||||
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
|
||||
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
|
||||
|
||||
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
|
||||
return s && s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
|
||||
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
|
||||
const s = (msg || '').trim();
|
||||
if (!s) return true;
|
||||
// Common unhelpful payloads we see
|
||||
if (s === '{}' || s === '[]') return true;
|
||||
if (/^request failed/i.test(s)) return true;
|
||||
if (/^network error/i.test(s)) return true;
|
||||
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
|
||||
return false;
|
||||
}
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const _statusText = error.response?.statusText || '';
|
||||
let parsed: any = undefined;
|
||||
const raw = error.response?.data;
|
||||
if (typeof raw === 'string') {
|
||||
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
|
||||
} else {
|
||||
parsed = raw;
|
||||
}
|
||||
const extractIds = (): string[] | undefined => {
|
||||
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
|
||||
const rawText = typeof raw === 'string' ? raw : '';
|
||||
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
|
||||
};
|
||||
|
||||
const body = ((): string => {
|
||||
const data = parsed;
|
||||
if (!data) return typeof raw === 'string' ? raw : '';
|
||||
const ids = extractIds();
|
||||
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
||||
if (data?.message) return data.message as string;
|
||||
if (typeof raw === 'string') return raw;
|
||||
try { return JSON.stringify(data); } catch { return ''; }
|
||||
})();
|
||||
const ids = extractIds();
|
||||
const title = titleForStatus(status);
|
||||
if (ids && ids.length > 0) {
|
||||
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
|
||||
}
|
||||
if (status === 422) {
|
||||
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
try {
|
||||
const msg = (error?.message || String(error)) as string;
|
||||
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
|
||||
} catch (e) {
|
||||
// ignore extraction errors
|
||||
console.debug('extractAxiosErrorMessage', e);
|
||||
return { title: 'Network error', body: FRIENDLY_FALLBACK };
|
||||
}
|
||||
}
|
||||
|
||||
// Module-scoped state to reduce global variable usage
|
||||
const recentSpecialByEndpoint: Record<string, number> = {};
|
||||
const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast
|
||||
|
||||
/**
|
||||
* Handles HTTP errors with toast notifications and file error broadcasting
|
||||
* Returns true if the error should be suppressed (deduplicated), false otherwise
|
||||
*/
|
||||
export async function handleHttpError(error: any): Promise<boolean> {
|
||||
// Compute title/body (friendly) from the error object
|
||||
const { title, body } = extractAxiosErrorMessage(error);
|
||||
|
||||
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
|
||||
const raw = (error?.response?.data) as any;
|
||||
let normalized: unknown = raw;
|
||||
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
|
||||
|
||||
// 1) If server sends structured file IDs for failures, also mark them errored in UI
|
||||
try {
|
||||
const ids = extractErrorFileIds(normalized);
|
||||
if (ids && ids.length > 0) {
|
||||
broadcastErroredFiles(ids);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
// 2) Generic-vs-special dedupe by endpoint
|
||||
const url: string | undefined = error?.config?.url;
|
||||
const status: number | undefined = error?.response?.status;
|
||||
const now = Date.now();
|
||||
const isSpecial =
|
||||
status === 422 ||
|
||||
status === 409 || // often actionable conflicts
|
||||
/Failed files:/.test(body) ||
|
||||
/invalid\/corrupted file\(s\)/i.test(body);
|
||||
|
||||
if (isSpecial && url) {
|
||||
recentSpecialByEndpoint[url] = now;
|
||||
}
|
||||
if (!isSpecial && url) {
|
||||
const last = recentSpecialByEndpoint[url] || 0;
|
||||
if (now - last < SPECIAL_SUPPRESS_MS) {
|
||||
return true; // Suppress this error (deduplicated)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Show specialized friendly toasts if matched; otherwise show the generic one
|
||||
let rawString: string | undefined;
|
||||
try {
|
||||
rawString =
|
||||
typeof normalized === 'string'
|
||||
? normalized
|
||||
: JSON.stringify(normalized);
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
const handled = showSpecialErrorToast(rawString, { status });
|
||||
if (!handled) {
|
||||
const displayBody = clampText(body);
|
||||
alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false });
|
||||
}
|
||||
|
||||
return false; // Error was handled with toast, continue normal rejection
|
||||
}
|
@ -338,6 +338,125 @@ export class ZipFileService {
|
||||
return errorMessage.includes('password') || errorMessage.includes('encrypted');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all files from a ZIP archive (not limited to PDFs)
|
||||
*/
|
||||
async extractAllFiles(
|
||||
file: File | Blob,
|
||||
onProgress?: (progress: ZipExtractionProgress) => void
|
||||
): Promise<ZipExtractionResult> {
|
||||
const result: ZipExtractionResult = {
|
||||
success: false,
|
||||
extractedFiles: [],
|
||||
errors: [],
|
||||
totalFiles: 0,
|
||||
extractedCount: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Load ZIP contents
|
||||
const zip = new JSZip();
|
||||
const zipContents = await zip.loadAsync(file);
|
||||
|
||||
// Get all files (not directories)
|
||||
const allFiles = Object.entries(zipContents.files).filter(([, zipEntry]) =>
|
||||
!zipEntry.dir
|
||||
);
|
||||
|
||||
result.totalFiles = allFiles.length;
|
||||
|
||||
// Extract each file
|
||||
for (let i = 0; i < allFiles.length; i++) {
|
||||
const [filename, zipEntry] = allFiles[i];
|
||||
|
||||
try {
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: filename,
|
||||
extractedCount: i,
|
||||
totalFiles: allFiles.length,
|
||||
progress: (i / allFiles.length) * 100
|
||||
});
|
||||
}
|
||||
|
||||
// Extract file content
|
||||
const content = await zipEntry.async('blob');
|
||||
|
||||
// Create File object with appropriate MIME type
|
||||
const mimeType = this.getMimeTypeFromExtension(filename);
|
||||
const extractedFile = new File([content], filename, { type: mimeType });
|
||||
|
||||
result.extractedFiles.push(extractedFile);
|
||||
result.extractedCount++;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to extract "${filename}": ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress report
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: '',
|
||||
extractedCount: result.extractedCount,
|
||||
totalFiles: result.totalFiles,
|
||||
progress: 100
|
||||
});
|
||||
}
|
||||
|
||||
result.success = result.extractedFiles.length > 0;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to process ZIP file: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type based on file extension
|
||||
*/
|
||||
private getMimeTypeFromExtension(fileName: string): string {
|
||||
const ext = fileName.toLowerCase().split('.').pop();
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
// Images
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
'svg': 'image/svg+xml',
|
||||
'tiff': 'image/tiff',
|
||||
'tif': 'image/tiff',
|
||||
|
||||
// Documents
|
||||
'pdf': 'application/pdf',
|
||||
'txt': 'text/plain',
|
||||
'html': 'text/html',
|
||||
'css': 'text/css',
|
||||
'js': 'application/javascript',
|
||||
'json': 'application/json',
|
||||
'xml': 'application/xml',
|
||||
|
||||
// Office documents
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
|
||||
// Archives
|
||||
'zip': 'application/zip',
|
||||
'rar': 'application/x-rar-compressed',
|
||||
};
|
||||
|
||||
return mimeTypes[ext || ''] || 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
@ -17,13 +17,40 @@ import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameter
|
||||
import { FileContextProvider } from '../../contexts/FileContext';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n/config';
|
||||
import axios from 'axios';
|
||||
import { createTestStirlingFile } from '../utils/testFileHelpers';
|
||||
import { StirlingFile } from '../../types/fileContext';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
const mockedAxios = vi.mocked(axios);
|
||||
// Mock axios (for static methods like CancelToken, isCancel)
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
CancelToken: {
|
||||
source: vi.fn(() => ({
|
||||
token: 'mock-cancel-token',
|
||||
cancel: vi.fn()
|
||||
}))
|
||||
},
|
||||
isCancel: vi.fn(() => false),
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock our apiClient service
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
interceptors: {
|
||||
response: {
|
||||
use: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Import the mocked apiClient
|
||||
import apiClient from '../../services/apiClient';
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
// Mock only essential services that are actually called by the tests
|
||||
vi.mock('../../services/fileStorage', () => ({
|
||||
@ -71,8 +98,8 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Setup default axios mock
|
||||
mockedAxios.post = vi.fn();
|
||||
// Setup default apiClient mock
|
||||
mockedApiClient.post = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -83,7 +110,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should make correct API call for PDF to PNG conversion', async () => {
|
||||
const mockBlob = new Blob(['fake-image-data'], { type: 'image/png' });
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
(mockedApiClient.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
statusText: 'OK'
|
||||
@ -126,14 +153,14 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Verify axios was called with correct parameters
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/convert/pdf/img',
|
||||
expect.any(FormData),
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
// Verify FormData contains correct parameters
|
||||
const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
const formDataCall = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formDataCall.get('imageFormat')).toBe('png');
|
||||
expect(formDataCall.get('colorType')).toBe('color');
|
||||
expect(formDataCall.get('dpi')).toBe('300');
|
||||
@ -148,7 +175,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should handle API error responses correctly', async () => {
|
||||
const errorMessage = 'Invalid file format';
|
||||
(mockedAxios.post as Mock).mockRejectedValueOnce({
|
||||
(mockedApiClient.post as Mock).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 400,
|
||||
data: errorMessage
|
||||
@ -199,7 +226,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
test('should handle network errors gracefully', async () => {
|
||||
(mockedAxios.post as Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
(mockedApiClient.post as Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
@ -246,7 +273,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should correctly map image conversion parameters to API call', async () => {
|
||||
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
(mockedApiClient.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
headers: {
|
||||
@ -292,7 +319,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Verify integration: hook parameters → FormData → axios call → hook state
|
||||
const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
const formDataCall = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formDataCall.get('imageFormat')).toBe('jpg');
|
||||
expect(formDataCall.get('colorType')).toBe('grayscale');
|
||||
expect(formDataCall.get('dpi')).toBe('150');
|
||||
@ -307,7 +334,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should make correct API call for PDF to CSV conversion with simplified workflow', async () => {
|
||||
const mockBlob = new Blob(['fake-csv-data'], { type: 'text/csv' });
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
(mockedApiClient.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
statusText: 'OK'
|
||||
@ -350,14 +377,14 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Verify correct endpoint is called
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/convert/pdf/csv',
|
||||
expect.any(FormData),
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
// Verify FormData contains correct parameters for simplified CSV conversion
|
||||
const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
const formDataCall = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formDataCall.get('pageNumbers')).toBe('all'); // Always "all" for simplified workflow
|
||||
expect(formDataCall.get('fileInput')).toBe(testFile);
|
||||
|
||||
@ -406,7 +433,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Verify integration: utils validation prevents API call, hook shows error
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
expect(mockedApiClient.post).not.toHaveBeenCalled();
|
||||
expect(result.current.errorMessage).toContain('Unsupported conversion format');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.downloadUrl).toBe(null);
|
||||
@ -417,7 +444,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should handle multiple file uploads correctly', async () => {
|
||||
const mockBlob = new Blob(['zip-content'], { type: 'application/zip' });
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({ data: mockBlob });
|
||||
(mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob });
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
@ -458,7 +485,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Verify both files were uploaded
|
||||
const calls = (mockedAxios.post as Mock).mock.calls;
|
||||
const calls = (mockedApiClient.post as Mock).mock.calls;
|
||||
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const formData = calls[i][1] as FormData;
|
||||
@ -506,7 +533,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
await result.current.executeOperation(parameters, []);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
expect(mockedApiClient.post).not.toHaveBeenCalled();
|
||||
expect(result.current.errorMessage).toContain('noFileSelected');
|
||||
});
|
||||
});
|
||||
@ -514,7 +541,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
describe('Error Boundary Integration', () => {
|
||||
|
||||
test('should handle corrupted file gracefully', async () => {
|
||||
(mockedAxios.post as Mock).mockRejectedValueOnce({
|
||||
(mockedApiClient.post as Mock).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
data: 'Processing failed'
|
||||
@ -562,7 +589,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
test('should handle backend service unavailable', async () => {
|
||||
(mockedAxios.post as Mock).mockRejectedValueOnce({
|
||||
(mockedApiClient.post as Mock).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 503,
|
||||
data: 'Service unavailable'
|
||||
@ -614,7 +641,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should record operation in FileContext', async () => {
|
||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
(mockedApiClient.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
headers: {
|
||||
@ -667,7 +694,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should clean up blob URLs on reset', async () => {
|
||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
(mockedApiClient.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
headers: {
|
||||
|
@ -11,14 +11,41 @@ import { useConvertParameters } from '../../hooks/tools/convert/useConvertParame
|
||||
import { FileContextProvider } from '../../contexts/FileContext';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n/config';
|
||||
import axios from 'axios';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import { FIT_OPTIONS } from '../../constants/convertConstants';
|
||||
import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
const mockedAxios = vi.mocked(axios);
|
||||
// Mock axios (for static methods like CancelToken, isCancel)
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
CancelToken: {
|
||||
source: vi.fn(() => ({
|
||||
token: 'mock-cancel-token',
|
||||
cancel: vi.fn()
|
||||
}))
|
||||
},
|
||||
isCancel: vi.fn(() => false),
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock our apiClient service
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
interceptors: {
|
||||
response: {
|
||||
use: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Import the mocked apiClient
|
||||
import apiClient from '../../services/apiClient';
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
// Mock only essential services that are actually called by the tests
|
||||
vi.mock('../../services/fileStorage', () => ({
|
||||
@ -61,7 +88,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock successful API response
|
||||
(mockedAxios.post as Mock).mockResolvedValue({
|
||||
(mockedApiClient.post as Mock).mockResolvedValue({
|
||||
data: new Blob(['fake converted content'], { type: 'application/pdf' })
|
||||
});
|
||||
});
|
||||
@ -103,7 +130,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
});
|
||||
@ -139,7 +166,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
});
|
||||
@ -183,12 +210,12 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), {
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Should send all files in single request
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
|
||||
const files = formData.getAll('fileInput');
|
||||
expect(files).toHaveLength(3);
|
||||
});
|
||||
@ -229,7 +256,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
});
|
||||
@ -269,12 +296,12 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), {
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Should process files separately for web files
|
||||
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApiClient.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -306,7 +333,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('zoom')).toBe('1.5');
|
||||
});
|
||||
|
||||
@ -340,7 +367,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('includeAttachments')).toBe('false');
|
||||
expect(formData.get('maxAttachmentSizeMB')).toBe('20');
|
||||
expect(formData.get('downloadHtml')).toBe('true');
|
||||
@ -374,9 +401,9 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('outputFormat')).toBe('pdfa');
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), {
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
});
|
||||
@ -418,7 +445,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('fitOption')).toBe(FIT_OPTIONS.FIT_PAGE);
|
||||
expect(formData.get('colorType')).toBe('grayscale');
|
||||
expect(formData.get('autoRotate')).toBe('false');
|
||||
@ -455,7 +482,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Should make separate API calls for each file
|
||||
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApiClient.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -472,7 +499,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Mock one success, one failure
|
||||
(mockedAxios.post as Mock)
|
||||
(mockedApiClient.post as Mock)
|
||||
.mockResolvedValueOnce({
|
||||
data: new Blob(['converted1'], { type: 'application/pdf' })
|
||||
})
|
||||
@ -498,7 +525,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
await waitFor(() => {
|
||||
// Should have processed at least one file successfully
|
||||
expect(operationResult.current.files.length).toBeGreaterThan(0);
|
||||
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApiClient.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
197
frontend/src/tools/AddAttachments.tsx
Normal file
197
frontend/src/tools/AddAttachments.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileSelection } from "../contexts/FileContext";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useAddAttachmentsParameters } from "../hooks/tools/addAttachments/useAddAttachmentsParameters";
|
||||
import { useAddAttachmentsOperation } from "../hooks/tools/addAttachments/useAddAttachmentsOperation";
|
||||
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
|
||||
import LocalIcon from "../components/shared/LocalIcon";
|
||||
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
|
||||
// Removed FitText for two-line wrapping with clamping
|
||||
|
||||
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
|
||||
const params = useAddAttachmentsParameters();
|
||||
const operation = useAddAttachmentsOperation();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-attachments");
|
||||
|
||||
useEffect(() => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [params.parameters]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
await operation.executeOperation(params.parameters, selectedFiles);
|
||||
if (operation.files && onComplete) {
|
||||
onComplete(operation.files);
|
||||
}
|
||||
} catch (error: any) {
|
||||
onError?.(error?.message || t("AddAttachmentsRequest.error.failed", "Add attachments operation failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
|
||||
enum AddAttachmentsStep {
|
||||
NONE = 'none',
|
||||
ATTACHMENTS = 'attachments'
|
||||
}
|
||||
|
||||
const accordion = useAccordionSteps<AddAttachmentsStep>({
|
||||
noneValue: AddAttachmentsStep.NONE,
|
||||
initialStep: AddAttachmentsStep.ATTACHMENTS,
|
||||
stateConditions: {
|
||||
hasFiles,
|
||||
hasResults: false // Don't collapse when there are results for add attachments
|
||||
},
|
||||
afterResults: () => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}
|
||||
});
|
||||
|
||||
const getSteps = () => {
|
||||
const steps: any[] = [];
|
||||
|
||||
// Step 1: Attachments Selection
|
||||
steps.push({
|
||||
title: t("AddAttachmentsRequest.attachments", "Select Attachments"),
|
||||
isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
|
||||
isVisible: true,
|
||||
content: (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
|
||||
</Text>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
// Append to existing attachments instead of replacing
|
||||
const newAttachments = [...params.parameters.attachments, ...files];
|
||||
params.updateParameter('attachments', newAttachments);
|
||||
// Reset the input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
}}
|
||||
disabled={endpointLoading}
|
||||
style={{ display: 'none' }}
|
||||
id="attachments-input"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
color="blue"
|
||||
component="label"
|
||||
htmlFor="attachments-input"
|
||||
disabled={endpointLoading}
|
||||
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
|
||||
>
|
||||
{params.parameters.attachments.length > 0
|
||||
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
|
||||
: t("AddAttachmentsRequest.placeholder", "Choose files...")
|
||||
}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{params.parameters.attachments && params.parameters.attachments.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({params.parameters.attachments.length})
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
|
||||
<Stack gap="xs">
|
||||
{params.parameters.attachments.map((file, index) => (
|
||||
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
|
||||
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
|
||||
{/* Filename (two-line clamp, wraps, no icon on the left) */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.2,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2 as any,
|
||||
WebkitBoxOrient: 'vertical' as any,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => {
|
||||
const newAttachments = params.parameters.attachments.filter((_, i) => i !== index);
|
||||
params.updateParameter('attachments', newAttachments);
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="14" height="14" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: getSteps(),
|
||||
executeButton: {
|
||||
text: t('AddAttachmentsRequest.submit', 'Add Attachments'),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t('loading'),
|
||||
onClick: handleExecute,
|
||||
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: operation,
|
||||
title: t('AddAttachmentsRequest.results.title', 'Attachment Results'),
|
||||
onFileClick: (file) => onPreviewFile?.(file),
|
||||
onUndo: async () => {
|
||||
await operation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
AddAttachments.tool = () => useAddAttachmentsOperation;
|
||||
|
||||
export default AddAttachments as ToolComponent;
|
55
frontend/src/tools/ExtractImages.tsx
Normal file
55
frontend/src/tools/ExtractImages.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import ExtractImagesSettings from "../components/tools/extractImages/ExtractImagesSettings";
|
||||
import { useExtractImagesParameters } from "../hooks/tools/extractImages/useExtractImagesParameters";
|
||||
import { useExtractImagesOperation } from "../hooks/tools/extractImages/useExtractImagesOperation";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const ExtractImages = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
'extractImages',
|
||||
useExtractImagesParameters,
|
||||
useExtractImagesOperation,
|
||||
props
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("extractImages.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
content: (
|
||||
<ExtractImagesSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("extractImages.submit", "Extract Images"),
|
||||
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("extractImages.title", "Extracted Images"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ExtractImages as ToolComponent;
|
45
frontend/src/tools/RemoveImage.tsx
Normal file
45
frontend/src/tools/RemoveImage.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import { useRemoveImageParameters } from "../hooks/tools/removeImage/useRemoveImageParameters";
|
||||
import { useRemoveImageOperation } from "../hooks/tools/removeImage/useRemoveImageOperation";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const RemoveImage = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
'removeImage',
|
||||
useRemoveImageParameters,
|
||||
useRemoveImageOperation,
|
||||
props
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
text: t("removeImage.submit", "Remove Images"),
|
||||
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("removeImage.results.title", "Remove Images Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
RemoveImage.tool = () => useRemoveImageOperation;
|
||||
|
||||
export default RemoveImage as ToolComponent;
|
||||
|
||||
|
104
frontend/src/tools/ReorganizePages.tsx
Normal file
104
frontend/src/tools/ReorganizePages.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileSelection } from "../contexts/FileContext";
|
||||
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
|
||||
import ReorganizePagesSettings from "../components/tools/reorganizePages/ReorganizePagesSettings";
|
||||
import { useReorganizePagesParameters } from "../hooks/tools/reorganizePages/useReorganizePagesParameters";
|
||||
import { useReorganizePagesOperation } from "../hooks/tools/reorganizePages/useReorganizePagesOperation";
|
||||
|
||||
const ReorganizePages = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
|
||||
const params = useReorganizePagesParameters();
|
||||
const operation = useReorganizePagesOperation();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("rearrange-pages");
|
||||
|
||||
useEffect(() => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [params.parameters]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
await operation.executeOperation(params.parameters, selectedFiles);
|
||||
if (operation.files && onComplete) {
|
||||
onComplete(operation.files);
|
||||
}
|
||||
} catch (error: any) {
|
||||
onError?.(error?.message || t("reorganizePages.error.failed", "Failed to reorganize pages"));
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
|
||||
enum Step {
|
||||
NONE = 'none',
|
||||
SETTINGS = 'settings'
|
||||
}
|
||||
|
||||
const accordion = useAccordionSteps<Step>({
|
||||
noneValue: Step.NONE,
|
||||
initialStep: Step.SETTINGS,
|
||||
stateConditions: {
|
||||
hasFiles,
|
||||
hasResults
|
||||
},
|
||||
afterResults: () => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}
|
||||
});
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: t("reorganizePages.settings.title", "Settings"),
|
||||
isCollapsed: accordion.getCollapsedState(Step.SETTINGS),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(Step.SETTINGS),
|
||||
isVisible: true,
|
||||
content: (
|
||||
<ReorganizePagesSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
}
|
||||
];
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps,
|
||||
executeButton: {
|
||||
text: t('reorganizePages.submit', 'Reorganize Pages'),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t('loading'),
|
||||
onClick: handleExecute,
|
||||
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: operation,
|
||||
title: t('reorganizePages.results.title', 'Pages Reorganized'),
|
||||
onFileClick: (file) => onPreviewFile?.(file),
|
||||
onUndo: async () => {
|
||||
await operation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
(ReorganizePages as any).tool = () => useReorganizePagesOperation;
|
||||
|
||||
export default ReorganizePages as ToolComponent;
|
||||
|
||||
|
58
frontend/src/tools/ReplaceColor.tsx
Normal file
58
frontend/src/tools/ReplaceColor.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
|
||||
import { useReplaceColorParameters } from "../hooks/tools/replaceColor/useReplaceColorParameters";
|
||||
import { useReplaceColorOperation } from "../hooks/tools/replaceColor/useReplaceColorOperation";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useReplaceColorTips } from "../components/tooltips/useReplaceColorTips";
|
||||
|
||||
const ReplaceColor = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const replaceColorTips = useReplaceColorTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'replaceColor',
|
||||
useReplaceColorParameters,
|
||||
useReplaceColorOperation,
|
||||
props
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("replaceColor.labels.settings", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: replaceColorTips,
|
||||
content: (
|
||||
<ReplaceColorSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("replace-color.submit", "Replace"),
|
||||
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("replace-color.title", "Replace-Invert-Color"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ReplaceColor as ToolComponent;
|
58
frontend/src/tools/ScannerImageSplit.tsx
Normal file
58
frontend/src/tools/ScannerImageSplit.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
||||
import { useScannerImageSplitParameters } from "../hooks/tools/scannerImageSplit/useScannerImageSplitParameters";
|
||||
import { useScannerImageSplitOperation } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useScannerImageSplitTips } from "../components/tooltips/useScannerImageSplitTips";
|
||||
|
||||
const ScannerImageSplit = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const scannerImageSplitTips = useScannerImageSplitTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'scannerImageSplit',
|
||||
useScannerImageSplitParameters,
|
||||
useScannerImageSplitOperation,
|
||||
props
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: scannerImageSplitTips,
|
||||
content: (
|
||||
<ScannerImageSplitSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("scannerImageSplit.submit", "Extract Image Scans"),
|
||||
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("scannerImageSplit.title", "Extracted Images"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ScannerImageSplit as ToolComponent;
|
@ -49,7 +49,7 @@ export const TOOL_IDS = [
|
||||
'validateSignature',
|
||||
'read',
|
||||
'automate',
|
||||
'replaceColorPdf',
|
||||
'replaceColor',
|
||||
'showJS',
|
||||
'devApi',
|
||||
'devFolderScanning',
|
||||
|
@ -76,7 +76,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
'/extract-images': 'extractImages',
|
||||
'/adjust-contrast': 'adjustContrast',
|
||||
'/fake-scan': 'fakeScan',
|
||||
'/replace-color-pdf': 'replaceColorPdf',
|
||||
'/replace-color-pdf': 'replaceColor',
|
||||
|
||||
// Metadata and info
|
||||
'/change-metadata': 'changeMetadata',
|
||||
@ -116,7 +116,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
'/view-pdf': 'read',
|
||||
'/get-info-on-pdf': 'getPdfInfo',
|
||||
'/remove-image-pdf': 'removeImage',
|
||||
'/replace-and-invert-color-pdf': 'replaceColorPdf',
|
||||
'/replace-and-invert-color-pdf': 'replaceColor',
|
||||
'/pipeline': 'automate',
|
||||
'/extract-image-scans': 'scannerImageSplit',
|
||||
'/show-javascript': 'showJS',
|
||||
|
Loading…
Reference in New Issue
Block a user