Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,96 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { alert } from "@app/components/toast";
import { SignRequestSummary, SessionSummary } from "@app/types/signingSession";
export interface UseSigningSessionsOptions {
enabled?: boolean;
autoRefreshInterval?: number; // milliseconds, 0 to disable
}
export interface UseSigningSessionsResult {
signRequests: SignRequestSummary[];
mySessions: SessionSummary[];
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook to fetch signing sessions data (sign requests and user's sessions).
* Supports auto-refresh for real-time updates.
*/
export const useSigningSessions = (
options: UseSigningSessionsOptions = {},
): UseSigningSessionsResult => {
const { enabled = true, autoRefreshInterval = 0 } = options;
const { t } = useTranslation();
const [signRequests, setSignRequests] = useState<SignRequestSummary[]>([]);
const [mySessions, setMySessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
if (!enabled) return;
setLoading(true);
setError(null);
try {
const [requestsResponse, sessionsResponse] = await Promise.all([
apiClient.get<SignRequestSummary[]>(
"/api/v1/security/cert-sign/sign-requests",
),
apiClient.get<SessionSummary[]>("/api/v1/security/cert-sign/sessions"),
]);
setSignRequests(requestsResponse.data);
setMySessions(sessionsResponse.data);
} catch (err) {
const errorObj =
err instanceof Error ? err : new Error("Failed to fetch signing data");
setError(errorObj);
console.error("Failed to fetch signing data:", err);
alert({
alertType: "warning",
title: t("common.error"),
body: t("certSign.fetchFailed", "Failed to load signing data"),
expandable: false,
durationMs: 2500,
});
} finally {
setLoading(false);
}
}, [enabled, t]);
// Initial fetch
useEffect(() => {
if (enabled) {
fetchData();
}
}, [enabled, fetchData]);
// Auto-refresh
useEffect(() => {
if (!enabled || !autoRefreshInterval || autoRefreshInterval <= 0) {
return;
}
const interval = setInterval(() => {
fetchData();
}, autoRefreshInterval);
return () => clearInterval(interval);
}, [enabled, autoRefreshInterval, fetchData]);
return {
signRequests,
mySessions,
loading,
error,
refetch: fetchData,
};
};
@@ -0,0 +1,79 @@
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import SignRequestWorkbenchView from "@app/components/tools/certSign/SignRequestWorkbenchView";
import SessionDetailWorkbenchView from "@app/components/tools/certSign/SessionDetailWorkbenchView";
export interface WorkbenchRegistration {
id: string;
workbenchId: string;
label: string;
component: React.ComponentType<any>;
}
export interface UseSigningWorkbenchResult {
signRequestWorkbench: {
id: string;
type: string;
};
sessionDetailWorkbench: {
id: string;
type: string;
};
}
/**
* Hook to manage custom workbench registration for signing workflows.
* Automatically registers and unregisters workbenches on mount/unmount.
*/
export const useSigningWorkbench = (): UseSigningWorkbenchResult => {
const { t } = useTranslation();
const { registerCustomWorkbenchView, unregisterCustomWorkbenchView } =
useToolWorkflow();
// Define workbench IDs as constants
const SIGN_REQUEST_WORKBENCH_ID = "signRequestWorkbench";
const SIGN_REQUEST_WORKBENCH_TYPE = "custom:signRequestWorkbench" as const;
const SESSION_DETAIL_WORKBENCH_ID = "sessionDetailWorkbench";
const SESSION_DETAIL_WORKBENCH_TYPE =
"custom:sessionDetailWorkbench" as const;
// Register workbenches on mount
useEffect(() => {
registerCustomWorkbenchView({
id: SIGN_REQUEST_WORKBENCH_ID,
workbenchId: SIGN_REQUEST_WORKBENCH_TYPE,
label: t("certSign.collab.signRequest.workbenchTitle", "Sign Request"),
component: SignRequestWorkbenchView,
});
registerCustomWorkbenchView({
id: SESSION_DETAIL_WORKBENCH_ID,
workbenchId: SESSION_DETAIL_WORKBENCH_TYPE,
label: t(
"certSign.collab.sessionDetail.workbenchTitle",
"Session Management",
),
component: SessionDetailWorkbenchView,
});
return () => {
unregisterCustomWorkbenchView(SIGN_REQUEST_WORKBENCH_ID);
unregisterCustomWorkbenchView(SESSION_DETAIL_WORKBENCH_ID);
};
}, [registerCustomWorkbenchView, unregisterCustomWorkbenchView, t]);
return useMemo(
() => ({
signRequestWorkbench: {
id: SIGN_REQUEST_WORKBENCH_ID,
type: SIGN_REQUEST_WORKBENCH_TYPE,
},
sessionDetailWorkbench: {
id: SESSION_DETAIL_WORKBENCH_ID,
type: SESSION_DETAIL_WORKBENCH_TYPE,
},
}),
[],
);
};
@@ -0,0 +1,52 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/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);
});
formData.append("convertToPdfA3b", String(parameters.convertToPdfA3b));
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,41 @@
import { useState } from "react";
export interface AddAttachmentsParameters {
attachments: File[];
convertToPdfA3b: boolean;
}
const defaultParameters: AddAttachmentsParameters = {
attachments: [],
convertToPdfA3b: false,
};
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,
};
};
export const DEFAULT_ADD_ATTACHMENTS_PARAMETERS: AddAttachmentsParameters =
defaultParameters;
@@ -0,0 +1,143 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useAddPasswordOperation } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
import type { AddPasswordFullParameters } from "@app/hooks/tools/addPassword/useAddPasswordParameters";
// Mock the useToolOperation hook
vi.mock("../shared/useToolOperation", async () => {
const actual = await vi.importActual("../shared/useToolOperation"); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn(),
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: mockT }),
}));
// Mock the error handler
vi.mock("../../../utils/toolErrorHandler", () => ({
createStandardErrorHandler: vi.fn(() => "error-handler-function"),
}));
// Import the mocked function
import {
SingleFileToolOperationConfig,
ToolOperationHook,
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
describe("useAddPasswordOperation", () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = () =>
mockUseToolOperation.mock
.calls[0][0] as SingleFileToolOperationConfig<AddPasswordFullParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: "",
isLoading: false,
errorMessage: null,
status: "",
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test.each([
{
description: "with all parameters filled",
password: "user-password",
ownerPassword: "owner-password",
keyLength: 256,
},
{
description: "with empty passwords",
password: "",
ownerPassword: "",
keyLength: 128,
},
{
description: "with 40-bit key length",
password: "test",
ownerPassword: "",
keyLength: 40,
},
])(
"should create form data correctly $description",
({ password, ownerPassword, keyLength }) => {
renderHook(() => useAddPasswordOperation());
const callArgs = getToolConfig();
const buildFormData = callArgs.buildFormData;
const testParameters: AddPasswordFullParameters = {
password,
ownerPassword,
keyLength,
permissions: {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
},
};
const testFile = new File(["test content"], "test.pdf", {
type: "application/pdf",
});
const formData = buildFormData(testParameters, testFile);
// Verify the form data contains the file
expect(formData.get("fileInput")).toBe(testFile);
// Verify password parameters
expect(formData.get("password")).toBe(password);
expect(formData.get("ownerPassword")).toBe(ownerPassword);
expect(formData.get("keyLength")).toBe(keyLength.toString());
},
);
test("should use correct translation for error messages", () => {
renderHook(() => useAddPasswordOperation());
expect(mockT).toHaveBeenCalledWith(
"addPassword.error.failed",
"An error occurred while encrypting the PDF.",
);
});
test.each([
{ property: "toolType" as const, expectedValue: ToolType.singleFile },
{
property: "endpoint" as const,
expectedValue: "/api/v1/security/add-password",
},
{ property: "operationType" as const, expectedValue: "addPassword" },
])("should configure $property correctly", ({ property, expectedValue }) => {
renderHook(() => useAddPasswordOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddPasswordFullParameters,
defaultParameters,
} from "@app/hooks/tools/addPassword/useAddPasswordParameters";
import { defaultParameters as permissionsDefaults } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
import { getFormData } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
// Static function that can be used by both the hook and automation executor
export const buildAddPasswordFormData = (
parameters: AddPasswordFullParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
formData.append("ownerPassword", parameters.ownerPassword);
formData.append("keyLength", parameters.keyLength.toString());
getFormData(parameters.permissions).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Full default parameters including permissions for automation
const fullDefaultParameters: AddPasswordFullParameters = {
...defaultParameters,
permissions: permissionsDefaults,
};
// Static configuration object
export const addPasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddPasswordFormData,
operationType: "addPassword",
endpoint: "/api/v1/security/add-password",
defaultParameters: fullDefaultParameters,
} as const;
export const useAddPasswordOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddPasswordFullParameters>({
...addPasswordOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"addPassword.error.failed",
"An error occurred while encrypting the PDF.",
),
),
});
};
@@ -0,0 +1,166 @@
import { describe, expect, test } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useAddPasswordParameters,
defaultParameters,
AddPasswordParametersHook,
} from "@app/hooks/tools/addPassword/useAddPasswordParameters";
import {
defaultParameters as defaultChangePermissionsParameters,
ChangePermissionsParameters,
} from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
describe("useAddPasswordParameters", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: "password" as const, value: "test-password" },
{ paramName: "ownerPassword" as const, value: "owner-password" },
{ paramName: "keyLength" as const, value: 256 },
])("should update parameter $paramName", ({ paramName, value }) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test.each([
{ paramName: "preventAssembly" as const },
{ paramName: "preventPrinting" as const },
])(
"should update boolean permission parameter $paramName",
({ paramName }) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.permissions.updateParameter(paramName, true);
});
expect(result.current.permissions.parameters[paramName]).toBe(true);
},
);
test("should reset parameters to defaults", () => {
const { result } = renderHook(() => useAddPasswordParameters());
// First, change some parameters
act(() => {
result.current.updateParameter("password", "test");
result.current.updateParameter("keyLength", 256);
result.current.permissions.updateParameter("preventAssembly", true);
});
expect(result.current.parameters.password).toBe("test");
expect(result.current.parameters.keyLength).toBe(256);
expect(result.current.permissions.parameters.preventAssembly).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test("should return correct endpoint name", () => {
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.getEndpointName()).toBe("add-password");
});
test.each([
{
description: "with user password only",
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter("password", "user-password");
},
},
{
description: "with owner password only",
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter("ownerPassword", "owner-password");
},
},
{
description: "with both passwords",
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter("password", "user-password");
hook.updateParameter("ownerPassword", "owner-password");
},
},
{
description: "with whitespace only password",
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter("password", " \t ");
},
},
{
description: "with whitespace only owner password",
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter("ownerPassword", " \t ");
},
},
{
description: "with restrictions only",
setup: (hook: AddPasswordParametersHook) => {
hook.permissions.updateParameter("preventAssembly", true);
hook.permissions.updateParameter("preventPrinting", true);
},
},
{
description: "with passwords and restrictions",
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter("password", "test-password");
hook.permissions.updateParameter("preventAssembly", true);
},
},
])("should validate parameters correctly $description", ({ setup }) => {
const { result } = renderHook(() => useAddPasswordParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Apply the test scenario setup
act(() => {
setup(result.current);
});
expect(result.current.validateParameters()).toBe(true);
});
test.each(
Object.keys(defaultChangePermissionsParameters) as Array<
keyof ChangePermissionsParameters
>,
)("should handle boolean restriction parameter %s", (param) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.resetParameters();
result.current.permissions.updateParameter(param, true);
});
expect(result.current.validateParameters()).toBe(true);
});
test("should handle mixed parameter types in updateParameter", () => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter("password", "test-string");
result.current.updateParameter("keyLength", 40);
result.current.permissions.updateParameter("preventAssembly", true);
});
expect(result.current.parameters.password).toBe("test-string");
expect(result.current.parameters.keyLength).toBe(40);
expect(result.current.permissions.parameters.preventAssembly).toBe(true);
});
});
@@ -0,0 +1,61 @@
import {
ChangePermissionsParameters,
ChangePermissionsParametersHook,
useChangePermissionsParameters,
} from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface AddPasswordParameters extends BaseParameters {
password: string;
ownerPassword: string;
keyLength: number;
}
export interface AddPasswordFullParameters extends AddPasswordParameters {
permissions: ChangePermissionsParameters;
}
export interface AddPasswordParametersHook extends BaseParametersHook<AddPasswordParameters> {
fullParameters: AddPasswordFullParameters;
permissions: ChangePermissionsParametersHook;
}
export const defaultParameters: AddPasswordParameters = {
password: "",
ownerPassword: "",
keyLength: 128,
};
export const useAddPasswordParameters = (): AddPasswordParametersHook => {
const permissions = useChangePermissionsParameters();
const baseHook = useBaseParameters({
defaultParameters,
endpointName: "add-password",
validateFn: () => {
// No required parameters for Add Password. Defer to permissions validation.
return permissions.validateParameters();
},
});
const fullParameters: AddPasswordFullParameters = {
...baseHook.parameters,
permissions: permissions.parameters,
};
const resetParameters = () => {
baseHook.resetParameters();
permissions.resetParameters();
};
return {
...baseHook,
fullParameters,
permissions,
resetParameters,
};
};
@@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddWatermarkParameters,
defaultParameters,
} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
// Static function that can be used by both the hook and automation executor
export const buildAddWatermarkFormData = (
parameters: AddWatermarkParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Required: watermarkType as string
formData.append("watermarkType", parameters.watermarkType || "text");
// Add watermark content based on type
if (parameters.watermarkType === "text") {
formData.append("watermarkText", parameters.watermarkText);
} else if (
parameters.watermarkType === "image" &&
parameters.watermarkImage
) {
formData.append("watermarkImage", parameters.watermarkImage);
}
// Required parameters with correct formatting (defaults merged in automationExecutor)
formData.append("fontSize", parameters.fontSize.toString());
formData.append("rotation", parameters.rotation.toString());
formData.append("opacity", (parameters.opacity / 100).toString()); // Convert percentage to decimal
formData.append("widthSpacer", parameters.widthSpacer.toString());
formData.append("heightSpacer", parameters.heightSpacer.toString());
// Backend-expected parameters from user input
formData.append("alphabet", parameters.alphabet || "");
formData.append("customColor", parameters.customColor || "");
formData.append(
"convertPDFToImage",
(parameters.convertPDFToImage ?? false).toString(),
);
return formData;
};
// Static configuration object
export const addWatermarkOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddWatermarkFormData,
operationType: "watermark",
endpoint: "/api/v1/security/add-watermark",
defaultParameters,
} as const;
export const useAddWatermarkOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddWatermarkParameters>({
...addWatermarkOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"watermark.error.failed",
"An error occurred while adding watermark to the PDF.",
),
),
});
};
@@ -0,0 +1,52 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface AddWatermarkParameters extends BaseParameters {
watermarkType?: "text" | "image";
watermarkText: string;
watermarkImage?: File;
fontSize: number; // Used for both text size and image size
rotation: number;
opacity: number;
widthSpacer: number;
heightSpacer: number;
alphabet: string;
customColor: string;
convertPDFToImage: boolean;
}
export const defaultParameters: AddWatermarkParameters = {
watermarkType: undefined,
watermarkText: "",
fontSize: 12,
rotation: 0,
opacity: 50,
widthSpacer: 50,
heightSpacer: 50,
alphabet: "roman",
customColor: "#d3d3d3",
convertPDFToImage: false,
};
export type AddWatermarkParametersHook =
BaseParametersHook<AddWatermarkParameters>;
export const useAddWatermarkParameters = (): AddWatermarkParametersHook => {
return useBaseParameters({
defaultParameters: defaultParameters,
endpointName: "add-watermark",
validateFn: (params): boolean => {
if (!params.watermarkType) {
return false;
}
if (params.watermarkType === "text") {
return params.watermarkText.trim().length > 0;
} else {
return params.watermarkImage !== undefined;
}
},
});
};
@@ -0,0 +1,214 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
CustomProcessorResult,
} from "@app/hooks/tools/shared/useToolOperation";
import {
AdjustContrastParameters,
defaultParameters,
} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters";
import { applyAdjustmentsToCanvas } from "@app/components/tools/adjustContrast/utils";
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
import { createFileFromApiResponse } from "@app/utils/fileResponseUtils";
import { getPdfiumModule, saveRawDocument } from "@app/services/pdfiumService";
import { copyRgbaToBgraHeap } from "@app/utils/pdfiumBitmapUtils";
async function renderPdfPageToCanvas(
pdf: any,
pageNumber: number,
scale: number,
): Promise<HTMLCanvasElement> {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D context unavailable");
await page.render({ canvasContext: ctx, viewport }).promise;
return canvas;
}
// Render, adjust, and assemble all pages of a single PDF into a new PDF using PDFium
async function buildAdjustedPdfForFile(
file: File,
params: AdjustContrastParameters,
): Promise<File> {
const m = await getPdfiumModule();
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {});
const pageCount = pdf.numPages;
const docPtr = m.FPDF_CreateNewDocument();
if (!docPtr) throw new Error("PDFium: failed to create document");
try {
for (let p = 1; p <= pageCount; p++) {
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
const ctx = adjusted.getContext("2d");
if (!ctx) {
console.warn(
`[adjustContrast] Skipping page ${p}: failed to get canvas context`,
);
continue;
}
const imageData = ctx.getImageData(0, 0, adjusted.width, adjusted.height);
const imgWidth = imageData.width;
const imgHeight = imageData.height;
// Since we render at scale 2, the actual PDF page size is half
const pdfPageWidth = imgWidth / 2;
const pdfPageHeight = imgHeight / 2;
const pagePtr = m.FPDFPage_New(
docPtr,
p - 1,
pdfPageWidth,
pdfPageHeight,
);
if (!pagePtr) {
console.warn(
`[adjustContrast] Skipping page ${p}: failed to create PDFium page`,
);
continue;
}
let bitmapPtr = 0;
try {
bitmapPtr = m.FPDFBitmap_Create(imgWidth, imgHeight, 1);
if (!bitmapPtr) {
console.warn(
`[adjustContrast] Skipping page ${p}: failed to create bitmap`,
);
continue;
}
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
copyRgbaToBgraHeap(
m,
new Uint8Array(imageData.data.buffer),
bufferPtr,
imgWidth,
imgHeight,
stride,
);
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
if (imageObjPtr) {
const setBitmapOk = m.FPDFImageObj_SetBitmap(
pagePtr,
0,
imageObjPtr,
bitmapPtr,
);
if (setBitmapOk) {
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
try {
m.pdfium.setValue(matrixPtr, pdfPageWidth, "float");
m.pdfium.setValue(matrixPtr + 4, 0, "float");
m.pdfium.setValue(matrixPtr + 8, 0, "float");
m.pdfium.setValue(matrixPtr + 12, pdfPageHeight, "float");
m.pdfium.setValue(matrixPtr + 16, 0, "float");
m.pdfium.setValue(matrixPtr + 20, 0, "float");
if (m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) {
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
} else {
m.FPDFPageObj_Destroy(imageObjPtr);
}
} finally {
m.pdfium.wasmExports.free(matrixPtr);
}
} else {
m.FPDFPageObj_Destroy(imageObjPtr);
}
}
} finally {
if (bitmapPtr) m.FPDFBitmap_Destroy(bitmapPtr);
m.FPDFPage_GenerateContent(pagePtr);
m.FPDF_ClosePage(pagePtr);
}
}
const pdfBytes = await saveRawDocument(docPtr);
const out = createFileFromApiResponse(
pdfBytes,
{ "content-type": "application/pdf" },
file.name,
);
pdfWorkerManager.destroyDocument(pdf);
return out;
} finally {
m.FPDF_CloseDocument(docPtr);
}
}
async function processPdfClientSide(
params: AdjustContrastParameters,
files: File[],
): Promise<CustomProcessorResult> {
let CONCURRENCY_LIMIT = 2;
if (
typeof navigator !== "undefined" &&
typeof navigator.hardwareConcurrency === "number"
) {
if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4;
else if (navigator.hardwareConcurrency >= 4) CONCURRENCY_LIMIT = 3;
}
CONCURRENCY_LIMIT = Math.min(CONCURRENCY_LIMIT, files.length);
const mapWithConcurrency = async <T, R>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> => {
const results: R[] = new Array(items.length);
let nextIndex = 0;
const workers = new Array(Math.min(limit, items.length))
.fill(0)
.map(async () => {
let current = nextIndex++;
while (current < items.length) {
results[current] = await worker(items[current], current);
current = nextIndex++;
}
});
await Promise.all(workers);
return results;
};
const processedFiles = await mapWithConcurrency(
files,
CONCURRENCY_LIMIT,
(file) => buildAdjustedPdfForFile(file, params),
);
return {
files: processedFiles,
consumedAllInputs: false,
};
}
export const adjustContrastOperationConfig = {
toolType: ToolType.custom,
customProcessor: processPdfClientSide,
operationType: "adjustContrast",
defaultParameters,
settingsComponentPath:
"components/tools/adjustContrast/AdjustContrastSingleStepSettings",
} as const;
export const useAdjustContrastOperation = () => {
const { t } = useTranslation();
return useToolOperation<AdjustContrastParameters>({
...adjustContrastOperationConfig,
getErrorMessage: () =>
t("adjustContrast.error.failed", "Failed to adjust colors/contrast"),
});
};
@@ -0,0 +1,32 @@
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface AdjustContrastParameters {
contrast: number; // 0-200 (%), 100 = neutral
brightness: number; // 0-200 (%), 100 = neutral
saturation: number; // 0-200 (%), 100 = neutral
red: number; // 0-200 (%), 100 = neutral
green: number; // 0-200 (%), 100 = neutral
blue: number; // 0-200 (%), 100 = neutral
}
export const defaultParameters: AdjustContrastParameters = {
contrast: 100,
brightness: 100,
saturation: 100,
red: 100,
green: 100,
blue: 100,
};
export type AdjustContrastParametersHook =
BaseParametersHook<AdjustContrastParameters>;
export const useAdjustContrastParameters = (): AdjustContrastParametersHook => {
return useBaseParameters<AdjustContrastParameters>({
defaultParameters,
endpointName: "",
});
};
@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AdjustPageScaleParameters,
defaultParameters,
} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
export const buildAdjustPageScaleFormData = (
parameters: AdjustPageScaleParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("scaleFactor", parameters.scaleFactor.toString());
formData.append("pageSize", parameters.pageSize);
return formData;
};
export const adjustPageScaleOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAdjustPageScaleFormData,
operationType: "scalePages",
endpoint: "/api/v1/general/scale-pages",
defaultParameters,
} as const;
export const useAdjustPageScaleOperation = () => {
const { t } = useTranslation();
return useToolOperation<AdjustPageScaleParameters>({
...adjustPageScaleOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"adjustPageScale.error.failed",
"An error occurred while adjusting the page scale.",
),
),
});
};
@@ -0,0 +1,150 @@
import { describe, expect, test } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useAdjustPageScaleParameters,
defaultParameters,
PageSize,
AdjustPageScaleParametersHook,
} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
describe("useAdjustPageScaleParameters", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
expect(result.current.parameters.scaleFactor).toBe(1.0);
expect(result.current.parameters.pageSize).toBe(PageSize.KEEP);
});
test.each([
{ paramName: "scaleFactor" as const, value: 0.5 },
{ paramName: "scaleFactor" as const, value: 2.0 },
{ paramName: "scaleFactor" as const, value: 10.0 },
{ paramName: "pageSize" as const, value: PageSize.A4 },
{ paramName: "pageSize" as const, value: PageSize.LETTER },
{ paramName: "pageSize" as const, value: PageSize.LEGAL },
])("should update parameter $paramName to $value", ({ paramName, value }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test("should reset parameters to defaults", () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
// First, change some parameters
act(() => {
result.current.updateParameter("scaleFactor", 2.5);
result.current.updateParameter("pageSize", PageSize.A3);
});
expect(result.current.parameters.scaleFactor).toBe(2.5);
expect(result.current.parameters.pageSize).toBe(PageSize.A3);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test("should return correct endpoint name", () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.getEndpointName()).toBe("scale-pages");
});
test.each([
{
description: "with default parameters",
setup: () => {},
expected: true,
},
{
description: "with valid scale factor 0.1",
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter("scaleFactor", 0.1);
},
expected: true,
},
{
description: "with valid scale factor 10.0",
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter("scaleFactor", 10.0);
},
expected: true,
},
{
description: "with A4 page size",
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter("pageSize", PageSize.A4);
},
expected: true,
},
{
description: "with invalid scale factor 0",
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter("scaleFactor", 0);
},
expected: false,
},
{
description: "with negative scale factor",
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter("scaleFactor", -0.5);
},
expected: false,
},
])(
"should validate parameters correctly $description",
({ setup, expected }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
setup(result.current);
});
expect(result.current.validateParameters()).toBe(expected);
},
);
test("should handle all PageSize enum values", () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
Object.values(PageSize).forEach((pageSize) => {
act(() => {
result.current.updateParameter("pageSize", pageSize);
});
expect(result.current.parameters.pageSize).toBe(pageSize);
expect(result.current.validateParameters()).toBe(true);
});
});
test("should handle scale factor edge cases", () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
// Test very small valid scale factor
act(() => {
result.current.updateParameter("scaleFactor", 0.01);
});
expect(result.current.validateParameters()).toBe(true);
// Test scale factor just above zero
act(() => {
result.current.updateParameter("scaleFactor", 0.001);
});
expect(result.current.validateParameters()).toBe(true);
// Test exactly zero (invalid)
act(() => {
result.current.updateParameter("scaleFactor", 0);
});
expect(result.current.validateParameters()).toBe(false);
});
});
@@ -0,0 +1,42 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export enum PageSize {
KEEP = "KEEP",
A0 = "A0",
A1 = "A1",
A2 = "A2",
A3 = "A3",
A4 = "A4",
A5 = "A5",
A6 = "A6",
LETTER = "LETTER",
LEGAL = "LEGAL",
}
export interface AdjustPageScaleParameters extends BaseParameters {
scaleFactor: number;
pageSize: PageSize;
}
export const defaultParameters: AdjustPageScaleParameters = {
scaleFactor: 1.0,
pageSize: PageSize.KEEP,
};
export type AdjustPageScaleParametersHook =
BaseParametersHook<AdjustPageScaleParameters>;
export const useAdjustPageScaleParameters =
(): AdjustPageScaleParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "scale-pages",
validateFn: (params) => {
return params.scaleFactor > 0;
},
});
};
@@ -0,0 +1,56 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AutoRenameParameters,
defaultParameters,
} from "@app/hooks/tools/autoRename/useAutoRenameParameters";
export const getFormData = (parameters: AutoRenameParameters) =>
Object.entries(parameters).map(([key, value]) => [
key,
value.toString(),
]) as string[][];
// Static function that can be used by both the hook and automation executor
export const buildAutoRenameFormData = (
parameters: AutoRenameParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Static configuration object
export const autoRenameOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAutoRenameFormData,
operationType: "autoRename",
endpoint: "/api/v1/misc/auto-rename",
preserveBackendFilename: true, // Use filename from backend response headers
defaultParameters,
} as const;
export const useAutoRenameOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...autoRenameOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"auto-rename.error.failed",
"An error occurred while auto-renaming the PDF.",
),
),
});
};
@@ -0,0 +1,22 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface AutoRenameParameters extends BaseParameters {
useFirstTextAsFallback: boolean;
}
export const defaultParameters: AutoRenameParameters = {
useFirstTextAsFallback: false,
};
export type AutoRenameParametersHook = BaseParametersHook<AutoRenameParameters>;
export const useAutoRenameParameters = (): AutoRenameParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "auto-rename",
});
};
@@ -0,0 +1,64 @@
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { useCallback } from "react";
import { executeAutomationSequence } from "@app/utils/automationExecutor";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { AutomateParameters } from "@app/types/automation";
export function useAutomateOperation() {
const { allTools } = useToolRegistry();
const toolRegistry = allTools;
const customProcessor = useCallback(
async (params: AutomateParameters, files: File[]) => {
console.log("🚀 Starting automation execution via customProcessor", {
params,
files,
});
if (!params.automationConfig) {
throw new Error("No automation configuration provided");
}
// Execute the automation sequence and return the final results
const finalResults = await executeAutomationSequence(
params.automationConfig!,
files,
toolRegistry,
(stepIndex: number, operationName: string) => {
console.log(`Step ${stepIndex + 1} started: ${operationName}`);
params.onStepStart?.(stepIndex, operationName);
},
(stepIndex: number, resultFiles: File[]) => {
console.log(
`Step ${stepIndex + 1} completed with ${resultFiles.length} files`,
);
params.onStepComplete?.(stepIndex, resultFiles);
},
(stepIndex: number, error: string) => {
console.error(`Step ${stepIndex + 1} failed:`, error);
params.onStepError?.(stepIndex, error);
throw new Error(`Automation step ${stepIndex + 1} failed: ${error}`);
},
);
console.log(
`✅ Automation completed, returning ${finalResults.length} files`,
);
return {
files: finalResults,
consumedAllInputs: true,
};
},
[toolRegistry],
);
return useToolOperation<AutomateParameters>({
toolType: ToolType.custom,
operationType: "automate",
customProcessor,
consumesAllInputs: true,
});
}
@@ -0,0 +1,162 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
AutomationTool,
AutomationConfig,
AutomationMode,
} from "@app/types/automation";
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
import { ToolId } from "@app/types/toolId";
interface UseAutomationFormProps {
mode: AutomationMode;
existingAutomation?: AutomationConfig;
toolRegistry: Partial<ToolRegistry>;
}
export function useAutomationForm({
mode,
existingAutomation,
toolRegistry,
}: UseAutomationFormProps) {
const { t } = useTranslation();
const [automationName, setAutomationName] = useState("");
const [automationDescription, setAutomationDescription] = useState("");
// Default to "SettingsIcon" so the saved entry always has a valid icon key
// matching what the IconSelector renders visually. Saving an empty string
// here would round-trip into a fallback cog, making it look like the
// picker silently lost the user's choice.
const [automationIcon, setAutomationIcon] = useState<string>("SettingsIcon");
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = useCallback(
(operation: string) => {
const tool = toolRegistry?.[operation as ToolId] as any;
return tool?.name || t(`tools.${operation}.name`, operation);
},
[toolRegistry, t],
);
const getToolDefaultParameters = useCallback(
(operation: string): Record<string, any> => {
const config = toolRegistry[operation as ToolId]?.operationConfig;
if (config?.defaultParameters) {
return { ...config.defaultParameters };
}
return {};
},
[toolRegistry],
);
// Initialize based on mode and existing automation
useEffect(() => {
if (
(mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) &&
existingAutomation
) {
setAutomationName(existingAutomation.name || "");
setAutomationDescription(existingAutomation.description || "");
setAutomationIcon(existingAutomation.icon || "SettingsIcon");
const operations = existingAutomation.operations || [];
const tools = operations.map((op, index) => {
const operation = typeof op === "string" ? op : op.operation;
const toolEntry = toolRegistry[operation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured =
mode === AutomationMode.EDIT ? true : !toolEntry?.automationSettings;
return {
id: `${operation}-${Date.now()}-${index}`,
operation: operation,
name: getToolName(operation),
configured: isConfigured,
parameters:
typeof op === "object"
? {
...getToolDefaultParameters(operation),
...(op.parameters || {}),
}
: getToolDefaultParameters(operation),
};
});
setSelectedTools(tools);
} else if (mode === AutomationMode.CREATE && selectedTools.length === 0) {
// Initialize with default empty tools for new automation
const defaultTools = Array.from(
{ length: AUTOMATION_CONSTANTS.DEFAULT_TOOL_COUNT },
(_, index) => ({
id: `tool-${index + 1}-${Date.now()}`,
operation: "",
name: t("automate.creation.tools.selectTool", "Select a tool..."),
configured: false,
parameters: {},
}),
);
setSelectedTools(defaultTools);
}
}, [mode, existingAutomation, t, getToolName, getToolDefaultParameters]);
const addTool = (operation: string) => {
const toolEntry = toolRegistry[operation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = !toolEntry?.automationSettings;
const newTool: AutomationTool = {
id: `${operation}-${Date.now()}`,
operation,
name: getToolName(operation),
configured: isConfigured,
parameters: getToolDefaultParameters(operation),
};
setSelectedTools([...selectedTools, newTool]);
};
const removeTool = (index: number) => {
if (selectedTools.length <= AUTOMATION_CONSTANTS.MIN_TOOL_COUNT) return;
setSelectedTools(selectedTools.filter((_, i) => i !== index));
};
const updateTool = (index: number, updates: Partial<AutomationTool>) => {
const updatedTools = [...selectedTools];
updatedTools[index] = { ...updatedTools[index], ...updates };
setSelectedTools(updatedTools);
};
const hasUnsavedChanges = () => {
return (
automationName.trim() !== "" ||
selectedTools.some((tool) => tool.operation !== "" || tool.configured)
);
};
const canSaveAutomation = () => {
return (
automationName.trim() !== "" &&
selectedTools.length > 0 &&
selectedTools.every((tool) => tool.configured && tool.operation !== "")
);
};
return {
automationName,
setAutomationName,
automationDescription,
setAutomationDescription,
automationIcon,
setAutomationIcon,
selectedTools,
setSelectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters,
};
}
@@ -0,0 +1,127 @@
import { useState, useEffect, useCallback } from "react";
import { AutomationConfig } from "@app/services/automationStorage";
import { SuggestedAutomation } from "@app/types/automation";
export interface SavedAutomation extends AutomationConfig {}
export type ImportableAutomation = Omit<
AutomationConfig,
"id" | "createdAt" | "updatedAt"
>;
export function useSavedAutomations() {
const [savedAutomations, setSavedAutomations] = useState<SavedAutomation[]>(
[],
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const loadSavedAutomations = useCallback(async () => {
try {
setLoading(true);
setError(null);
const { automationStorage } =
await import("@app/services/automationStorage");
const automations = await automationStorage.getAllAutomations();
setSavedAutomations(automations);
} catch (err) {
console.error("Error loading saved automations:", err);
setError(err as Error);
setSavedAutomations([]);
} finally {
setLoading(false);
}
}, []);
const refreshAutomations = useCallback(() => {
loadSavedAutomations();
}, [loadSavedAutomations]);
const deleteAutomation = useCallback(
async (id: string) => {
try {
const { automationStorage } =
await import("@app/services/automationStorage");
await automationStorage.deleteAutomation(id);
// Refresh the list after deletion
refreshAutomations();
} catch (err) {
console.error("Error deleting automation:", err);
throw err;
}
},
[refreshAutomations],
);
const copyFromSuggested = useCallback(
async (suggestedAutomation: SuggestedAutomation) => {
try {
const { automationStorage } =
await import("@app/services/automationStorage");
// Map suggested automation icons to MUI icon keys
const getIconKey = (_suggestedIcon: { id: string }): string => {
// Check the automation ID or name to determine the appropriate icon
switch (suggestedAutomation.id) {
case "secure-pdf-ingestion":
case "secure-workflow":
return "SecurityIcon"; // Security icon for security workflows
case "email-preparation":
return "CompressIcon"; // Compression icon
case "process-images":
return "StarIcon"; // Star icon for process images
default:
return "SettingsIcon"; // Default fallback
}
};
// Convert suggested automation to saved automation format
const savedAutomation = {
name: suggestedAutomation.name,
description: suggestedAutomation.description,
icon: getIconKey(suggestedAutomation.icon),
operations: suggestedAutomation.operations,
};
await automationStorage.saveAutomation(savedAutomation);
// Refresh the list after saving
refreshAutomations();
} catch (err) {
console.error("Error copying suggested automation:", err);
throw err;
}
},
[refreshAutomations],
);
const importAutomation = useCallback(
async (automation: ImportableAutomation): Promise<SavedAutomation> => {
try {
const { automationStorage } =
await import("@app/services/automationStorage");
const saved = await automationStorage.saveAutomation(automation);
refreshAutomations();
return saved;
} catch (err) {
console.error("Error importing automation:", err);
throw err;
}
},
[refreshAutomations],
);
// Load automations on mount
useEffect(() => {
loadSavedAutomations();
}, [loadSavedAutomations]);
return {
savedAutomations,
loading,
error,
refreshAutomations,
deleteAutomation,
copyFromSuggested,
importAutomation,
};
}
@@ -0,0 +1,294 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import React from "react";
import LocalIcon from "@app/components/shared/LocalIcon";
import { SuggestedAutomation } from "@app/types/automation";
import { SPLIT_METHODS } from "@app/constants/splitConstants";
// Create icon components
const CompressIcon = () =>
React.createElement(LocalIcon, {
icon: "compress",
width: "1.5rem",
height: "1.5rem",
});
const SecurityIcon = () =>
React.createElement(LocalIcon, {
icon: "security",
width: "1.5rem",
height: "1.5rem",
});
const StarIcon = () =>
React.createElement(LocalIcon, {
icon: "star",
width: "1.5rem",
height: "1.5rem",
});
const PrivacyIcon = () =>
React.createElement(LocalIcon, {
icon: "shield-lock",
width: "1.5rem",
height: "1.5rem",
});
export function useSuggestedAutomations(): SuggestedAutomation[] {
const { t } = useTranslation();
const suggestedAutomations = useMemo<SuggestedAutomation[]>(() => {
const now = new Date().toISOString();
return [
{
id: "secure-pdf-ingestion",
name: t(
"automation.suggested.securePdfIngestion",
"Secure PDF Ingestion",
),
description: t(
"automation.suggested.securePdfIngestionDesc",
"Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size.",
),
operations: [
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: false,
removeFonts: false,
},
},
{
operation: "ocr",
parameters: {
languages: ["eng"],
ocrType: "skip-text",
ocrRenderType: "hocr",
additionalOptions: ["clean", "cleanFinal"],
},
},
{
operation: "convert",
parameters: {
fromExtension: "pdf",
toExtension: "pdfa",
pdfaOptions: {
outputFormat: "pdfa-2b",
strict: false,
},
},
},
{
operation: "compress",
parameters: {
compressionLevel: 5,
grayscale: false,
expectedSize: "",
compressionMethod: "quality",
fileSizeValue: "",
fileSizeUnit: "MB",
},
},
],
createdAt: now,
updatedAt: now,
icon: SecurityIcon,
},
{
id: "pre-publish-sanitization",
name: t(
"automation.suggested.prePublishSanitization",
"Pre-publish Sanitization",
),
description: t(
"automation.suggested.prePublishSanitizationDesc",
"Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online.",
),
operations: [
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: true,
removeFonts: false,
},
},
{
operation: "flatten",
parameters: {
flattenOnlyForms: true,
},
},
{
operation: "removeAnnotations",
parameters: {},
},
{
operation: "changeMetadata",
parameters: {
deleteAll: true,
author: "",
creationDate: "",
creator: "",
keywords: "",
modificationDate: "",
producer: "",
subject: "",
title: "",
trapped: "",
},
},
{
operation: "compress",
parameters: {
compressionLevel: 3,
grayscale: false,
expectedSize: "",
compressionMethod: "quality",
fileSizeValue: "",
fileSizeUnit: "MB",
},
},
],
createdAt: now,
updatedAt: now,
icon: PrivacyIcon,
},
{
id: "email-preparation",
name: t("automation.suggested.emailPreparation", "Email Preparation"),
description: t(
"automation.suggested.emailPreparationDesc",
"Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.",
),
operations: [
{
operation: "compress",
parameters: {
compressionLevel: 5,
grayscale: false,
expectedSize: "",
compressionMethod: "quality",
fileSizeValue: "",
fileSizeUnit: "MB",
},
},
{
operation: "split",
parameters: {
method: SPLIT_METHODS.BY_SIZE,
pages: "",
hDiv: "1",
vDiv: "1",
merge: false,
splitValue: "20MB",
bookmarkLevel: "1",
includeMetadata: false,
allowDuplicates: false,
duplexMode: false,
},
},
{
operation: "sanitize",
parameters: {
removeJavaScript: false,
removeEmbeddedFiles: false,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: false,
removeFonts: false,
},
},
],
createdAt: now,
updatedAt: now,
icon: CompressIcon,
},
{
id: "secure-workflow",
name: t("automation.suggested.secureWorkflow", "Security Workflow"),
description: t(
"automation.suggested.secureWorkflowDesc",
"Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access. Password is set to 'password' by default.",
),
operations: [
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
},
},
{
operation: "addPassword",
parameters: {
password: "password",
ownerPassword: "",
keyLength: 128,
permissions: {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
},
},
},
],
createdAt: now,
updatedAt: now,
icon: SecurityIcon,
},
{
id: "process-images",
name: t("automation.suggested.processImages", "Process Images"),
description: t(
"automation.suggested.processImagesDesc",
"Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images.",
),
operations: [
{
operation: "convert",
parameters: {
fromExtension: "image",
toExtension: "pdf",
imageOptions: {
colorType: "color",
dpi: 300,
singleOrMultiple: "multiple",
fitOption: "maintainAspectRatio",
autoRotate: true,
combineImages: true,
},
},
},
{
operation: "ocr",
parameters: {
languages: ["eng"],
ocrType: "skip-text",
ocrRenderType: "hocr",
additionalOptions: [],
},
},
],
createdAt: now,
updatedAt: now,
icon: StarIcon,
},
];
}, [t]);
return suggestedAutomations;
}
@@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
BookletImpositionParameters,
defaultParameters,
} from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters";
// Static configuration that can be used by both the hook and automation executor
export const buildBookletImpositionFormData = (
parameters: BookletImpositionParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("pagesPerSheet", parameters.pagesPerSheet.toString());
formData.append("addBorder", parameters.addBorder.toString());
formData.append("spineLocation", parameters.spineLocation);
formData.append("addGutter", parameters.addGutter.toString());
formData.append("gutterSize", parameters.gutterSize.toString());
formData.append("doubleSided", parameters.doubleSided.toString());
formData.append("duplexPass", parameters.duplexPass);
formData.append("flipOnShortEdge", parameters.flipOnShortEdge.toString());
return formData;
};
// Static configuration object
export const bookletImpositionOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildBookletImpositionFormData,
operationType: "bookletImposition",
endpoint: "/api/v1/general/booklet-imposition",
defaultParameters,
} as const;
export const useBookletImpositionOperation = () => {
const { t } = useTranslation();
return useToolOperation<BookletImpositionParameters>({
...bookletImpositionOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"bookletImposition.error.failed",
"An error occurred while creating the booklet imposition.",
),
),
});
};
@@ -0,0 +1,41 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface BookletImpositionParameters extends BaseParameters {
pagesPerSheet: 2;
addBorder: boolean;
spineLocation: "LEFT" | "RIGHT";
addGutter: boolean;
gutterSize: number;
doubleSided: boolean;
duplexPass: "BOTH" | "FIRST" | "SECOND";
flipOnShortEdge: boolean;
}
export const defaultParameters: BookletImpositionParameters = {
pagesPerSheet: 2,
addBorder: false,
spineLocation: "LEFT",
addGutter: false,
gutterSize: 12,
doubleSided: true,
duplexPass: "BOTH",
flipOnShortEdge: false,
};
export type BookletImpositionParametersHook =
BaseParametersHook<BookletImpositionParameters>;
export const useBookletImpositionParameters =
(): BookletImpositionParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "booklet-imposition",
validateFn: (params) => {
return params.pagesPerSheet === 2;
},
});
};
@@ -0,0 +1,86 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
CertSignParameters,
defaultParameters,
} from "@app/hooks/tools/certSign/useCertSignParameters";
// Build form data for signing
export const buildCertSignFormData = (
parameters: CertSignParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Handle sign mode
if (parameters.signMode === "AUTO") {
formData.append("certType", "SERVER");
} else {
formData.append("certType", parameters.certType);
formData.append("password", parameters.password);
// Add certificate files based on type (only for manual mode)
switch (parameters.certType) {
case "PEM":
if (parameters.privateKeyFile) {
formData.append("privateKeyFile", parameters.privateKeyFile);
}
if (parameters.certFile) {
formData.append("certFile", parameters.certFile);
}
break;
case "PKCS12":
case "PFX":
if (parameters.p12File) {
formData.append("p12File", parameters.p12File);
}
break;
case "JKS":
if (parameters.jksFile) {
formData.append("jksFile", parameters.jksFile);
}
break;
}
}
// Add signature appearance options if enabled
if (parameters.showSignature) {
formData.append("showSignature", "true");
formData.append("reason", parameters.reason);
formData.append("location", parameters.location);
formData.append("name", parameters.name);
formData.append("pageNumber", parameters.pageNumber.toString());
formData.append("showLogo", parameters.showLogo.toString());
}
return formData;
};
// Static configuration object
export const certSignOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCertSignFormData,
operationType: "certSign",
endpoint: "/api/v1/security/cert-sign",
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useCertSignOperation = () => {
const { t } = useTranslation();
return useToolOperation<CertSignParameters>({
...certSignOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"certSign.error.failed",
"An error occurred while processing signatures.",
),
),
});
};
@@ -0,0 +1,70 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface CertSignParameters extends BaseParameters {
// Sign mode selection
signMode: "MANUAL" | "AUTO";
// Certificate signing options (only for manual mode)
certType: "" | "PEM" | "PKCS12" | "PFX" | "JKS";
privateKeyFile?: File;
certFile?: File;
p12File?: File;
jksFile?: File;
password: string;
// Signature appearance options
showSignature: boolean;
reason: string;
location: string;
name: string;
pageNumber: number;
showLogo: boolean;
}
export const defaultParameters: CertSignParameters = {
signMode: "MANUAL",
certType: "",
password: "",
showSignature: false,
reason: "",
location: "",
name: "",
pageNumber: 1,
showLogo: true,
};
export type CertSignParametersHook = BaseParametersHook<CertSignParameters>;
export const useCertSignParameters = (): CertSignParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "cert-sign",
validateFn: (params) => {
// Auto mode (server certificate) - no additional validation needed
if (params.signMode === "AUTO") {
return true;
}
// Manual mode - requires certificate type and files
if (!params.certType) {
return false;
}
// Check for required files based on cert type
switch (params.certType) {
case "PEM":
return !!(params.privateKeyFile && params.certFile);
case "PKCS12":
case "PFX":
return !!params.p12File;
case "JKS":
return !!params.jksFile;
default:
return false;
}
},
});
};
@@ -0,0 +1,144 @@
import { buildChangeMetadataFormData } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
import { TrappedStatus } from "@app/types/metadata";
import { describe, expect, test } from "vitest";
describe("buildChangeMetadataFormData", () => {
const mockFile = new File(["test"], "test.pdf", { type: "application/pdf" });
const defaultParams: ChangeMetadataParameters = {
title: "",
author: "",
subject: "",
keywords: "",
creator: "",
producer: "",
creationDate: null,
modificationDate: null,
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
};
test.each([
{
name: "should build FormData with basic parameters",
params: {
...defaultParams,
title: "Test Document",
author: "John Doe",
deleteAll: true,
},
expectedFormData: {
fileInput: mockFile,
title: "Test Document",
author: "John Doe",
deleteAll: "true",
},
},
{
name: "should handle empty string values",
params: defaultParams,
expectedFormData: {
title: "",
author: "",
subject: "",
keywords: "",
creator: "",
producer: "",
creationDate: "",
modificationDate: "",
trapped: TrappedStatus.UNKNOWN,
deleteAll: "false",
},
},
{
name: "should include all standard metadata fields",
params: {
...defaultParams,
title: "Test Title",
author: "Test Author",
subject: "Test Subject",
keywords: "test, keywords",
creator: "Test Creator",
producer: "Test Producer",
creationDate: new Date("2025/01/17 14:30:00"),
modificationDate: new Date("2025/01/17 15:30:00"),
trapped: TrappedStatus.TRUE,
},
expectedFormData: {
title: "Test Title",
author: "Test Author",
subject: "Test Subject",
keywords: "test, keywords",
creator: "Test Creator",
producer: "Test Producer",
creationDate: "2025/01/17 14:30:00",
modificationDate: "2025/01/17 15:30:00",
trapped: TrappedStatus.TRUE,
},
},
])("$name", ({ params, expectedFormData }) => {
const formData = buildChangeMetadataFormData(params, mockFile);
Object.entries(expectedFormData).forEach(([key, value]) => {
expect(formData.get(key)).toBe(value);
});
});
test("should handle custom metadata with proper indexing", () => {
const params = {
...defaultParams,
customMetadata: [
{ key: "Department", value: "Engineering", id: "custom1" },
{ key: "Project", value: "Test Project", id: "custom2" },
{ key: "Status", value: "Draft", id: "custom3" },
],
};
const formData = buildChangeMetadataFormData(params, mockFile);
expect(formData.get("allRequestParams[customKey1]")).toBe("Department");
expect(formData.get("allRequestParams[customValue1]")).toBe("Engineering");
expect(formData.get("allRequestParams[customKey2]")).toBe("Project");
expect(formData.get("allRequestParams[customValue2]")).toBe("Test Project");
expect(formData.get("allRequestParams[customKey3]")).toBe("Status");
expect(formData.get("allRequestParams[customValue3]")).toBe("Draft");
});
test("should skip custom metadata with empty keys or values", () => {
const params = {
...defaultParams,
customMetadata: [
{ key: "Department", value: "Engineering", id: "custom1" },
{ key: "", value: "No Key", id: "custom2" },
{ key: "No Value", value: "", id: "custom3" },
{ key: " ", value: "Whitespace Key", id: "custom4" },
{ key: "Valid", value: "Valid Value", id: "custom5" },
],
};
const formData = buildChangeMetadataFormData(params, mockFile);
expect(formData.get("allRequestParams[customKey1]")).toBe("Department");
expect(formData.get("allRequestParams[customValue1]")).toBe("Engineering");
expect(formData.get("allRequestParams[customKey2]")).toBe("Valid");
expect(formData.get("allRequestParams[customValue2]")).toBe("Valid Value");
expect(formData.get("allRequestParams[customKey3]")).toBeNull();
expect(formData.get("allRequestParams[customKey4]")).toBeNull();
});
test("should trim whitespace from custom metadata", () => {
const params = {
...defaultParams,
customMetadata: [
{ key: " Department ", value: " Engineering ", id: "custom1" },
],
};
const formData = buildChangeMetadataFormData(params, mockFile);
expect(formData.get("allRequestParams[customKey1]")).toBe("Department");
expect(formData.get("allRequestParams[customValue1]")).toBe("Engineering");
});
});
@@ -0,0 +1,99 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ChangeMetadataParameters,
defaultParameters,
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
// Helper function to format Date object to string
const formatDateForBackend = (date: Date | null): string => {
if (!date) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
};
// Static function that can be used by both the hook and automation executor
export const buildChangeMetadataFormData = (
parameters: ChangeMetadataParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Standard metadata fields
formData.append("title", parameters.title || "");
formData.append("author", parameters.author || "");
formData.append("subject", parameters.subject || "");
formData.append("keywords", parameters.keywords || "");
formData.append("creator", parameters.creator || "");
formData.append("producer", parameters.producer || "");
// Date fields - convert Date objects to strings
formData.append(
"creationDate",
formatDateForBackend(parameters.creationDate),
);
formData.append(
"modificationDate",
formatDateForBackend(parameters.modificationDate),
);
// Trapped status
formData.append("trapped", parameters.trapped || "");
// Delete all metadata flag
formData.append("deleteAll", parameters.deleteAll.toString());
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
let keyNumber = 0;
if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) {
parameters.customMetadata.forEach((entry) => {
if (entry.key.trim() && entry.value.trim()) {
keyNumber += 1;
formData.append(
`allRequestParams[customKey${keyNumber}]`,
entry.key.trim(),
);
formData.append(
`allRequestParams[customValue${keyNumber}]`,
entry.value.trim(),
);
}
});
}
return formData;
};
// Static configuration object
export const changeMetadataOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildChangeMetadataFormData,
operationType: "changeMetadata",
endpoint: "/api/v1/misc/update-metadata",
defaultParameters,
} as const;
export const useChangeMetadataOperation = () => {
const { t } = useTranslation();
return useToolOperation<ChangeMetadataParameters>({
...changeMetadataOperationConfig,
filePrefix: t("changeMetadata.filenamePrefix", "metadata") + "_",
getErrorMessage: createStandardErrorHandler(
t(
"changeMetadata.error.failed",
"An error occurred while changing the PDF metadata.",
),
),
});
};
@@ -0,0 +1,252 @@
import { renderHook, act } from "@testing-library/react";
import {
defaultParameters,
useChangeMetadataParameters,
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
import { TrappedStatus } from "@app/types/metadata";
import { describe, expect, test } from "vitest";
describe("useChangeMetadataParameters", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useChangeMetadataParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
describe("parameter updates", () => {
test.each([
{ paramName: "title", value: "Test Document" },
{ paramName: "author", value: "John Doe" },
{ paramName: "subject", value: "Test Subject" },
{ paramName: "keywords", value: "test, metadata" },
{ paramName: "creator", value: "Test Creator" },
{ paramName: "producer", value: "Test Producer" },
{ paramName: "creationDate", value: new Date("2025/01/17 14:30:00") },
{ paramName: "modificationDate", value: new Date("2025/01/17 15:30:00") },
{ paramName: "trapped", value: TrappedStatus.TRUE },
{ paramName: "deleteAll", value: true },
] as const)(
"should update $paramName parameter",
({ paramName, value }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
},
);
});
describe("validation", () => {
test.each([
{
description: "deleteAll is true",
updates: { deleteAll: true },
expected: true,
},
{
description: "has title",
updates: { title: "Test Document" },
expected: true,
},
{
description: "has author",
updates: { author: "John Doe" },
expected: true,
},
{
description: "has subject",
updates: { subject: "Test Subject" },
expected: true,
},
{
description: "has keywords",
updates: { keywords: "test" },
expected: true,
},
{
description: "has creator",
updates: { creator: "Test Creator" },
expected: true,
},
{
description: "has producer",
updates: { producer: "Test Producer" },
expected: true,
},
{
description: "has creation date",
updates: { creationDate: new Date("2025/01/17 14:30:00") },
expected: true,
},
{
description: "has modification date",
updates: { modificationDate: new Date("2025/01/17 14:30:00") },
expected: true,
},
{
description: "has trapped status",
updates: { trapped: TrappedStatus.TRUE },
expected: true,
},
{ description: "no meaningful content", updates: {}, expected: false },
{
description: "whitespace only",
updates: { title: " ", author: " " },
expected: false,
},
])(
"should validate correctly when $description",
({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
Object.entries(updates).forEach(([key, value]) => {
result.current.updateParameter(key as keyof typeof updates, value);
});
});
expect(result.current.validateParameters()).toBe(expected);
},
);
test.each([
{
description: "valid creation date",
updates: {
title: "Test",
creationDate: new Date("2025/01/17 14:30:00"),
},
expected: true,
},
{
description: "valid modification date",
updates: {
title: "Test",
modificationDate: new Date("2025/01/17 14:30:00"),
},
expected: true,
},
{
description: "empty dates are valid",
updates: { title: "Test", creationDate: null, modificationDate: null },
expected: true,
},
])(
"should validate dates correctly with $description",
({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
Object.entries(updates).forEach(([key, value]) => {
result.current.updateParameter(key as keyof typeof updates, value);
});
});
expect(result.current.validateParameters()).toBe(expected);
},
);
});
describe("custom metadata", () => {
test("should add custom metadata with sequential IDs", () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
expect(result.current.parameters.customMetadata).toHaveLength(1);
expect(result.current.parameters.customMetadata[0]).toEqual({
key: "",
value: "",
id: expect.stringMatching(/^custom\d+$/),
});
});
test("should remove custom metadata by ID", () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
const customId = result.current.parameters.customMetadata[0].id;
act(() => {
result.current.removeCustomMetadata(customId);
});
expect(result.current.parameters.customMetadata).toHaveLength(0);
});
test("should update custom metadata by ID", () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
const customId = result.current.parameters.customMetadata[0].id;
act(() => {
result.current.updateCustomMetadata(
customId,
"Department",
"Engineering",
);
});
expect(result.current.parameters.customMetadata[0]).toEqual({
key: "Department",
value: "Engineering",
id: customId,
});
});
test("should validate with custom metadata", () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
const customId = result.current.parameters.customMetadata[0].id;
act(() => {
result.current.updateCustomMetadata(
customId,
"Department",
"Engineering",
);
});
expect(result.current.validateParameters()).toBe(true);
});
test("should generate unique IDs for multiple custom entries", () => {
const { result } = renderHook(() => useChangeMetadataParameters());
for (let i = 0; i < 3; i++) {
act(() => {
result.current.addCustomMetadata();
});
}
const ids = result.current.parameters.customMetadata.map(
(entry) => entry.id,
);
expect(ids).toHaveLength(3);
expect(new Set(ids).size).toBe(3); // All unique
expect(ids.every((id) => id.startsWith("custom"))).toBe(true);
});
});
test("should return correct endpoint name", () => {
const { result } = renderHook(() => useChangeMetadataParameters());
expect(result.current.getEndpointName()).toBe("update-metadata");
});
});
@@ -0,0 +1,143 @@
import { BaseParameters } from "@app/types/parameters";
import { TrappedStatus, CustomMetadataEntry } from "@app/types/metadata";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface ChangeMetadataParameters extends BaseParameters {
// Standard PDF metadata fields
title: string;
author: string;
subject: string;
keywords: string;
creator: string;
producer: string;
// Date fields
creationDate: Date | null;
modificationDate: Date | null;
// Trapped status
trapped: TrappedStatus;
// Custom metadata entries
customMetadata: CustomMetadataEntry[];
// Delete all metadata option
deleteAll: boolean;
}
export const defaultParameters: ChangeMetadataParameters = {
title: "",
author: "",
subject: "",
keywords: "",
creator: "",
producer: "",
creationDate: null,
modificationDate: null,
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
};
// Global counter for custom metadata IDs
let customMetadataIdCounter = 1;
// Utility functions that can work with external parameters
export const createCustomMetadataFunctions = (
parameters: ChangeMetadataParameters,
onParameterChange: <K extends keyof ChangeMetadataParameters>(
key: K,
value: ChangeMetadataParameters[K],
) => void,
) => {
const addCustomMetadata = (key: string = "", value: string = "") => {
const newEntry: CustomMetadataEntry = {
key,
value,
id: `custom${customMetadataIdCounter++}`,
};
onParameterChange("customMetadata", [
...parameters.customMetadata,
newEntry,
]);
};
const removeCustomMetadata = (id: string) => {
onParameterChange(
"customMetadata",
parameters.customMetadata.filter((entry) => entry.id !== id),
);
};
const updateCustomMetadata = (id: string, key: string, value: string) => {
onParameterChange(
"customMetadata",
parameters.customMetadata.map((entry) =>
entry.id === id ? { ...entry, key, value } : entry,
),
);
};
return {
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata,
};
};
// Validation function
const validateParameters = (params: ChangeMetadataParameters): boolean => {
// If deleteAll is true, no other validation needed
if (params.deleteAll) {
return true;
}
// At least one field should have content for the operation to be meaningful
const hasStandardMetadata = !!(
params.title.trim() ||
params.author.trim() ||
params.subject.trim() ||
params.keywords.trim() ||
params.creator.trim() ||
params.producer.trim() ||
params.creationDate ||
params.modificationDate ||
params.trapped !== TrappedStatus.UNKNOWN
);
const hasCustomMetadata = params.customMetadata.some(
(entry) => entry.key.trim() && entry.value.trim(),
);
return hasStandardMetadata || hasCustomMetadata;
};
export type ChangeMetadataParametersHook =
BaseParametersHook<ChangeMetadataParameters> & {
addCustomMetadata: (key?: string, value?: string) => void;
removeCustomMetadata: (id: string) => void;
updateCustomMetadata: (id: string, key: string, value: string) => void;
};
export const useChangeMetadataParameters = (): ChangeMetadataParametersHook => {
const base = useBaseParameters({
defaultParameters,
endpointName: "update-metadata",
validateFn: validateParameters,
});
// Use the utility functions with the hook's parameters and updateParameter
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } =
createCustomMetadataFunctions(base.parameters, base.updateParameter);
return {
...base,
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata,
};
};
@@ -0,0 +1,81 @@
import { useState, useEffect, useRef } from "react";
import { extractPDFMetadata } from "@app/services/pdfMetadataService";
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
interface MetadataExtractionParams {
updateParameter: <K extends keyof ChangeMetadataParameters>(
key: K,
value: ChangeMetadataParameters[K],
) => void;
}
export const useMetadataExtraction = (params: MetadataExtractionParams) => {
const selectedFiles = useViewScopedFiles();
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
const [hasExtractedMetadata, setHasExtractedMetadata] = useState(false);
const previousFileCountRef = useRef(0);
// Reset extraction state only when files are cleared (length goes to 0)
useEffect(() => {
if (previousFileCountRef.current > 0 && selectedFiles.length === 0) {
setHasExtractedMetadata(false);
}
previousFileCountRef.current = selectedFiles.length;
}, [selectedFiles]);
// Extract metadata from first file when files change
useEffect(() => {
const extractMetadata = async () => {
if (selectedFiles.length === 0) {
return;
}
const firstFile = selectedFiles[0];
if (hasExtractedMetadata) {
return;
}
setIsExtractingMetadata(true);
const result = await extractPDFMetadata(firstFile);
if (result.success) {
const metadata = result.metadata;
// Pre-populate all fields with extracted metadata
params.updateParameter("title", metadata.title);
params.updateParameter("author", metadata.author);
params.updateParameter("subject", metadata.subject);
params.updateParameter("keywords", metadata.keywords);
params.updateParameter("creator", metadata.creator);
params.updateParameter("producer", metadata.producer);
params.updateParameter(
"creationDate",
metadata.creationDate ? new Date(metadata.creationDate) : null,
);
params.updateParameter(
"modificationDate",
metadata.modificationDate
? new Date(metadata.modificationDate)
: null,
);
params.updateParameter("trapped", metadata.trapped);
params.updateParameter("customMetadata", metadata.customMetadata);
setHasExtractedMetadata(true);
} else {
console.warn("Failed to extract metadata:", result.error);
}
setIsExtractingMetadata(false);
};
extractMetadata();
}, [selectedFiles, hasExtractedMetadata, params]);
return {
isExtractingMetadata,
hasExtractedMetadata,
};
};
@@ -0,0 +1,143 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useChangePermissionsOperation } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
import type { ChangePermissionsParameters } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
// Mock the useToolOperation hook
vi.mock("../shared/useToolOperation", async () => {
const actual = await vi.importActual("../shared/useToolOperation"); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn(),
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: mockT }),
}));
// Mock the error handler
vi.mock("../../../utils/toolErrorHandler", () => ({
createStandardErrorHandler: vi.fn(() => "error-handler-function"),
}));
// Import the mocked function
import {
SingleFileToolOperationConfig,
ToolOperationHook,
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
describe("useChangePermissionsOperation", () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = () =>
mockUseToolOperation.mock
.calls[0][0] as SingleFileToolOperationConfig<ChangePermissionsParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: "",
isLoading: false,
errorMessage: null,
status: "",
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test.each([
{
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
},
{
preventAssembly: true,
preventExtractContent: false,
preventExtractForAccessibility: true,
preventFillInForm: false,
preventModify: true,
preventModifyAnnotations: false,
preventPrinting: true,
preventPrintingFaithful: false,
},
{
preventAssembly: true,
preventExtractContent: true,
preventExtractForAccessibility: true,
preventFillInForm: true,
preventModify: true,
preventModifyAnnotations: true,
preventPrinting: true,
preventPrintingFaithful: true,
},
])(
"should create form data correctly",
(testParameters: ChangePermissionsParameters) => {
renderHook(() => useChangePermissionsOperation());
const callArgs = getToolConfig();
const buildFormData = callArgs.buildFormData;
const testFile = new File(["test content"], "test.pdf", {
type: "application/pdf",
});
const formData = buildFormData(testParameters, testFile);
// Verify the form data contains the file
expect(formData.get("fileInput")).toBe(testFile);
(
Object.keys(testParameters) as Array<keyof ChangePermissionsParameters>
).forEach((key) => {
expect(
formData.get(key),
`Parameter ${key} should be set correctly`,
).toBe(testParameters[key].toString());
});
},
);
test("should use correct translation for error messages", () => {
renderHook(() => useChangePermissionsOperation());
expect(mockT).toHaveBeenCalledWith(
"changePermissions.error.failed",
"An error occurred while changing PDF permissions.",
);
});
test.each([
{ property: "toolType" as const, expectedValue: ToolType.singleFile },
{
property: "endpoint" as const,
expectedValue: "/api/v1/security/add-password",
},
{ property: "operationType" as const, expectedValue: "changePermissions" },
])("should configure $property correctly", ({ property, expectedValue }) => {
renderHook(() => useChangePermissionsOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ChangePermissionsParameters,
defaultParameters,
} from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
export const getFormData = (parameters: ChangePermissionsParameters) => {
if (!parameters) return [];
return Object.entries(parameters).map(([key, value]) => [
key,
(value ?? false).toString(),
]) as string[][];
};
// Static function that can be used by both the hook and automation executor
export const buildChangePermissionsFormData = (
parameters: ChangePermissionsParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Static configuration object
export const changePermissionsOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildChangePermissionsFormData,
operationType: "changePermissions",
endpoint: "/api/v1/security/add-password", // Change Permissions is a fake endpoint for the Add Password tool
defaultParameters,
} as const;
export const useChangePermissionsOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...changePermissionsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"changePermissions.error.failed",
"An error occurred while changing PDF permissions.",
),
),
});
};
@@ -0,0 +1,118 @@
import { describe, expect, test } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useChangePermissionsParameters,
defaultParameters,
ChangePermissionsParameters,
} from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
describe("useChangePermissionsParameters", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useChangePermissionsParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test("should update individual boolean parameters", () => {
const { result } = renderHook(() => useChangePermissionsParameters());
act(() => {
result.current.updateParameter("preventAssembly", true);
});
expect(result.current.parameters.preventAssembly).toBe(true);
expect(result.current.parameters.preventPrinting).toBe(false); // Other parameters should remain unchanged
act(() => {
result.current.updateParameter("preventPrinting", true);
});
expect(result.current.parameters.preventPrinting).toBe(true);
expect(result.current.parameters.preventAssembly).toBe(true);
});
test("should update all permission parameters", () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const permissionKeys = Object.keys(defaultParameters) as Array<
keyof ChangePermissionsParameters
>;
// Set all to true
act(() => {
permissionKeys.forEach((key) => {
result.current.updateParameter(key, true);
});
});
permissionKeys.forEach((key) => {
expect(result.current.parameters[key]).toBe(true);
});
// Set all to false
act(() => {
permissionKeys.forEach((key) => {
result.current.updateParameter(key, false);
});
});
permissionKeys.forEach((key) => {
expect(result.current.parameters[key]).toBe(false);
});
});
test("should reset parameters to defaults", () => {
const { result } = renderHook(() => useChangePermissionsParameters());
// First, change some parameters
act(() => {
result.current.updateParameter("preventAssembly", true);
result.current.updateParameter("preventPrinting", true);
result.current.updateParameter("preventModify", true);
});
expect(result.current.parameters.preventAssembly).toBe(true);
expect(result.current.parameters.preventPrinting).toBe(true);
expect(result.current.parameters.preventModify).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test("should return correct endpoint name", () => {
const { result } = renderHook(() => useChangePermissionsParameters());
expect(result.current.getEndpointName()).toBe("add-password");
});
test("should always validate as true", () => {
const { result } = renderHook(() => useChangePermissionsParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Set some restrictions - should still be valid
act(() => {
result.current.updateParameter("preventAssembly", true);
result.current.updateParameter("preventPrinting", true);
});
expect(result.current.validateParameters()).toBe(true);
// Set all restrictions - should still be valid
act(() => {
const permissionKeys = Object.keys(defaultParameters) as Array<
keyof ChangePermissionsParameters
>;
permissionKeys.forEach((key) => {
result.current.updateParameter(key, true);
});
});
expect(result.current.validateParameters()).toBe(true);
});
});
@@ -0,0 +1,38 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface ChangePermissionsParameters extends BaseParameters {
preventAssembly: boolean;
preventExtractContent: boolean;
preventExtractForAccessibility: boolean;
preventFillInForm: boolean;
preventModify: boolean;
preventModifyAnnotations: boolean;
preventPrinting: boolean;
preventPrintingFaithful: boolean;
}
export const defaultParameters: ChangePermissionsParameters = {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
};
export type ChangePermissionsParametersHook =
BaseParametersHook<ChangePermissionsParameters>;
export const useChangePermissionsParameters =
(): ChangePermissionsParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "add-password", // Change Permissions is a fake endpoint for the Add Password tool
});
};
@@ -0,0 +1,666 @@
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
import { appendWord as sharedAppendWord } from "@app/utils/textDiff";
import { PARAGRAPH_SENTINEL } from "@app/types/compare";
import type { StirlingFile } from "@app/types/fileContext";
import type {
PDFPageProxy,
TextContent,
TextItem,
} from "pdfjs-dist/types/src/display/api";
import type {
CompareChange,
CompareDiffToken,
CompareResultData,
TokenBoundingBox,
CompareParagraph,
} from "@app/types/compare";
export interface TokenMetadata {
page: number;
paragraph: number;
bbox: TokenBoundingBox | null;
}
export interface ExtractedContent {
tokens: string[];
metadata: TokenMetadata[];
pageSizes: { width: number; height: number }[];
paragraphs: CompareParagraph[];
}
const measurementCanvas =
typeof document !== "undefined" ? document.createElement("canvas") : null;
const measurementContext = measurementCanvas
? measurementCanvas.getContext("2d")
: null;
const textMeasurementCache: Map<string, number> | null = measurementContext
? new Map()
: null;
let lastMeasurementFont = "";
const DEFAULT_CHAR_WIDTH = 1;
const DEFAULT_SPACE_WIDTH = 0.33;
export const measureTextWidth = (fontSpec: string, text: string): number => {
if (!measurementContext) {
if (!text) return 0;
if (text === " ") return DEFAULT_SPACE_WIDTH;
return text.length * DEFAULT_CHAR_WIDTH;
}
if (lastMeasurementFont !== fontSpec) {
measurementContext.font = fontSpec;
lastMeasurementFont = fontSpec;
}
const key = `${fontSpec}|${text}`;
const cached = textMeasurementCache?.get(key);
if (cached !== undefined) {
return cached;
}
const width = measurementContext.measureText(text).width || 0;
textMeasurementCache?.set(key, width);
return width;
};
export const appendWord = (existing: string, word: string) => {
if (!existing) {
return sharedAppendWord("", word);
}
return sharedAppendWord(existing, word);
};
export const aggregateTotals = (tokens: CompareDiffToken[]) => {
return tokens.reduce(
(totals, token) => {
if (token.text === "\uE000PARA") {
// PARAGRAPH_SENTINEL safeguard if serialized
return totals;
}
switch (token.type) {
case "added":
totals.added += 1;
break;
case "removed":
totals.removed += 1;
break;
default:
totals.unchanged += 1;
}
return totals;
},
{ added: 0, removed: 0, unchanged: 0 },
);
};
export const buildChanges = (
tokens: CompareDiffToken[],
baseMetadata: TokenMetadata[],
comparisonMetadata: TokenMetadata[],
): CompareChange[] => {
const changes: CompareChange[] = [];
let baseIndex = 0;
let comparisonIndex = 0;
let current: CompareChange | null = null;
let currentBaseParagraph: number | null = null;
let currentComparisonParagraph: number | null = null;
const ensureCurrent = (): CompareChange => {
if (!current) {
current = {
id: `change-${changes.length}`,
base: null,
comparison: null,
};
}
return current;
};
const flush = () => {
if (current) {
if (current.base) {
current.base.text = current.base.text.trim();
}
if (current.comparison) {
current.comparison.text = current.comparison.text.trim();
}
if (
(current.base?.text && current.base.text.length > 0) ||
(current.comparison?.text && current.comparison.text.length > 0)
) {
changes.push(current);
}
}
current = null;
currentBaseParagraph = null;
currentComparisonParagraph = null;
};
for (const token of tokens) {
if (token.type === "removed") {
const meta = baseMetadata[baseIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.base) {
active.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentBaseParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentBaseParagraph !== null &&
paragraph !== currentBaseParagraph &&
active.base.text.trim().length > 0
) {
flush();
const next = ensureCurrent();
next.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.base.text = appendWord(active.base.text, token.text);
}
if (meta && active.base.page === null) {
active.base.page = meta.page;
}
if (meta && active.base.paragraph === null) {
active.base.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentBaseParagraph = paragraph;
}
}
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
continue;
}
if (token.type === "added") {
const meta = comparisonMetadata[comparisonIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.comparison) {
active.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentComparisonParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentComparisonParagraph !== null &&
paragraph !== currentComparisonParagraph &&
active.comparison.text.trim().length > 0
) {
flush();
const next = ensureCurrent();
next.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.comparison.text = appendWord(
active.comparison.text,
token.text,
);
}
if (meta && active.comparison.page === null) {
active.comparison.page = meta.page;
}
if (meta && active.comparison.paragraph === null) {
active.comparison.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentComparisonParagraph = paragraph;
}
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
continue;
}
// unchanged token
flush();
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
}
flush();
return changes;
};
export const createSummaryFile = (result: CompareResultData): File => {
const exportPayload = {
generatedAt: new Date(result.totals.processedAt).toISOString(),
base: {
name: result.base.fileName,
totalWords: result.base.wordCount,
},
comparison: {
name: result.comparison.fileName,
totalWords: result.comparison.wordCount,
},
totals: {
added: result.totals.added,
removed: result.totals.removed,
unchanged: result.totals.unchanged,
durationMs: result.totals.durationMs,
},
changes: result.changes.map((change) => ({
base: change.base,
comparison: change.comparison,
})),
warnings: result.warnings,
};
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, "-")}.json`;
return new File([JSON.stringify(exportPayload, null, 2)], filename, {
type: "application/json",
});
};
export const clamp = (value: number): number => Math.min(1, Math.max(0, value));
export const getWorkerErrorCode = (
value: unknown,
): "EMPTY_TEXT" | "TOO_LARGE" | "TOO_DISSIMILAR" | undefined => {
if (typeof value === "object" && value !== null && "code" in value) {
const potentialCode = (
value as { code?: "EMPTY_TEXT" | "TOO_LARGE" | "TOO_DISSIMILAR" }
).code;
return potentialCode;
}
return undefined;
};
// Produce a filtered view of tokens/metadata that excludes paragraph sentinel markers,
// returning a mapping to original indices for potential future use.
export const filterTokensForDiff = (
tokens: string[],
metadata: TokenMetadata[],
): {
tokens: string[];
metadata: TokenMetadata[];
filteredToOriginal: number[];
} => {
const outTokens: string[] = [];
const outMeta: TokenMetadata[] = [];
const map: number[] = [];
for (let i = 0; i < tokens.length; i += 1) {
const t = tokens[i];
const isPara =
t === PARAGRAPH_SENTINEL || t.startsWith("\uE000") || t.includes("PARA");
if (!isPara) {
outTokens.push(t);
if (metadata[i]) outMeta.push(metadata[i]);
map.push(i);
}
}
return { tokens: outTokens, metadata: outMeta, filteredToOriginal: map };
};
export const extractContentFromPdf = async (
file: StirlingFile,
): Promise<ExtractedContent> => {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
try {
const tokens: string[] = [];
const metadata: TokenMetadata[] = [];
const pageSizes: { width: number; height: number }[] = [];
const paragraphs: CompareParagraph[] = [];
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
const page: PDFPageProxy = await pdfDoc.getPage(pageIndex);
const viewport = page.getViewport({ scale: 1 });
const content: TextContent = await page.getTextContent({
disableCombineTextItems: true,
} as Parameters<PDFPageProxy["getTextContent"]>[0]);
const styles: Record<
string,
{ fontFamily?: string; ascent?: number; descent?: number }
> = content.styles ?? {};
let paragraphIndex = 1;
let paragraphBuffer = "";
let prevItem: TextItem | null = null;
pageSizes.push({ width: viewport.width, height: viewport.height });
const normalizeToken = (s: string) =>
s
.normalize("NFKC")
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, "")
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/[–—]/g, "-")
.replace(/\u00A0/g, " ")
.replace(/\s+/g, " ")
.trim();
const isParagraphBreak = (curr: TextItem, prev: TextItem | null) => {
const hasHardBreak = "hasEOL" in curr && (curr as TextItem).hasEOL;
if (hasHardBreak) return true;
if (!prev) return false;
const prevY = prev.transform[5];
const currY = curr.transform[5];
const dy = Math.abs(currY - prevY);
const currX = curr.transform[4];
const prevX = prev.transform[4];
const approxLine = Math.max(
10,
Math.abs((curr as any).height ?? 0) * 0.9,
);
const looksLikeParagraph = dy > approxLine * 1.8;
const likelySoftWrap = currX < prevX && dy < approxLine * 0.6;
return looksLikeParagraph && !likelySoftWrap;
};
const adjustBoundingBox = (
left: number,
top: number,
width: number,
height: number,
): TokenBoundingBox | null => {
if (width <= 0 || height <= 0) {
return null;
}
const MIN_WIDTH = 0.004;
const MIN_HORIZONTAL_PAD = 0.0012;
const HORIZONTAL_PAD_RATIO = 0.12;
const MIN_VERTICAL_PAD = 0.0008;
const VERTICAL_PAD_RATIO = 0.18;
const horizontalPad = Math.max(
width * HORIZONTAL_PAD_RATIO,
MIN_HORIZONTAL_PAD,
);
const verticalPad = Math.max(
height * VERTICAL_PAD_RATIO,
MIN_VERTICAL_PAD,
);
let expandedLeft = left - horizontalPad;
let expandedRight = left + width + horizontalPad;
let expandedTop = top - verticalPad;
let expandedBottom = top + height + verticalPad;
if (expandedRight - expandedLeft < MIN_WIDTH) {
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
expandedLeft -= deficit / 2;
expandedRight += deficit / 2;
}
expandedLeft = clamp(expandedLeft);
expandedRight = clamp(expandedRight);
expandedTop = clamp(expandedTop);
expandedBottom = clamp(expandedBottom);
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
return null;
}
return {
left: expandedLeft,
top: expandedTop,
width: expandedRight - expandedLeft,
height: expandedBottom - expandedTop,
};
};
for (const item of content.items as TextItem[]) {
if (!item?.str) {
prevItem = null;
continue;
}
const rawText = item.str;
const totalLen = Math.max(rawText.length, 1);
const textStyle = item.fontName ? styles[item.fontName] : undefined;
const fontFamily = textStyle?.fontFamily ?? "sans-serif";
const fontScale = Math.max(
0.5,
Math.hypot(item.transform[0], item.transform[1]) || 0,
);
const fontSpec = `${fontScale}px ${fontFamily}`;
const weights: number[] = new Array(totalLen);
let runningText = "";
let previousAdvance = 0;
for (let i = 0; i < totalLen; i += 1) {
runningText += rawText[i];
const advance = measureTextWidth(fontSpec, runningText);
let width = advance - previousAdvance;
if (!Number.isFinite(width) || width <= 0) {
width =
rawText[i] === " " ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
weights[i] = width;
previousAdvance = advance;
}
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
for (let i = 0; i < totalLen; i += 1) {
weights[i] =
rawText[i] === " " ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
}
const prefix: number[] = new Array(totalLen + 1);
prefix[0] = 0;
for (let i = 0; i < totalLen; i += 1)
prefix[i + 1] = prefix[i] + weights[i];
const totalWeight = prefix[totalLen] || 1;
const rawX = item.transform[4];
const rawY = item.transform[5];
const transformed = [
viewport.convertToViewportPoint(rawX, rawY),
viewport.convertToViewportPoint(rawX + item.width, rawY),
viewport.convertToViewportPoint(rawX, rawY + item.height),
viewport.convertToViewportPoint(
rawX + item.width,
rawY + item.height,
),
];
const xs = transformed.map(([px]) => px);
const ys = transformed.map(([, py]) => py);
const left = Math.min(...xs);
const right = Math.max(...xs);
const top = Math.min(...ys);
const bottom = Math.max(...ys);
if (
!Number.isFinite(left) ||
!Number.isFinite(right) ||
!Number.isFinite(top) ||
!Number.isFinite(bottom)
) {
prevItem = item;
continue;
}
const [baselineStart, baselineEnd, verticalEnd] = transformed;
const baselineVector: [number, number] = [
baselineEnd[0] - baselineStart[0],
baselineEnd[1] - baselineStart[1],
];
const verticalVector: [number, number] = [
verticalEnd[0] - baselineStart[0],
verticalEnd[1] - baselineStart[1],
];
const baselineMagnitude = Math.hypot(
baselineVector[0],
baselineVector[1],
);
const verticalMagnitude = Math.hypot(
verticalVector[0],
verticalVector[1],
);
const hasOrientationVectors =
baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6;
const font = item.fontName ? styles[item.fontName] : undefined;
const ascent =
typeof font?.ascent === "number"
? Math.max(0.7, Math.min(1.1, font.ascent))
: 0.9;
const descent =
typeof font?.descent === "number"
? Math.max(0.0, Math.min(0.5, Math.abs(font.descent)))
: 0.2;
const verticalScale = Math.min(1, Math.max(0.75, ascent + descent));
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(rawText)) !== null) {
const wordRaw = match[0];
const normalizedWord = normalizeToken(wordRaw);
if (!normalizedWord) {
continue;
}
const startIndex = match.index;
const endIndex = startIndex + wordRaw.length;
const relStart = prefix[startIndex] / totalWeight;
const relEnd = prefix[endIndex] / totalWeight;
let wordLeftAbs: number;
let wordRightAbs: number;
let wordTopAbs: number;
let wordBottomAbs: number;
if (hasOrientationVectors) {
const segStart: [number, number] = [
baselineStart[0] + baselineVector[0] * relStart,
baselineStart[1] + baselineVector[1] * relStart,
];
const segEnd: [number, number] = [
baselineStart[0] + baselineVector[0] * relEnd,
baselineStart[1] + baselineVector[1] * relEnd,
];
const cornerPoints: Array<[number, number]> = [
segStart,
[
segStart[0] + verticalVector[0],
segStart[1] + verticalVector[1],
],
[segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]],
segEnd,
];
const cornerXs = cornerPoints.map(([px]) => px);
const cornerYs = cornerPoints.map(([, py]) => py);
wordLeftAbs = Math.min(...cornerXs);
wordRightAbs = Math.max(...cornerXs);
wordTopAbs = Math.min(...cornerYs);
wordBottomAbs = Math.max(...cornerYs);
} else {
const segLeftAbs = left + (right - left) * relStart;
const segRightAbs = left + (right - left) * relEnd;
wordLeftAbs = Math.min(segLeftAbs, segRightAbs);
wordRightAbs = Math.max(segLeftAbs, segRightAbs);
wordTopAbs = top;
wordBottomAbs = bottom;
}
const wordLeft = clamp(wordLeftAbs / viewport.width);
const wordRight = clamp(wordRightAbs / viewport.width);
const wordTop = clamp(wordTopAbs / viewport.height);
const wordBottom = clamp(wordBottomAbs / viewport.height);
const wordWidth = Math.max(0, wordRight - wordLeft);
let wordHeight = Math.max(0, wordBottom - wordTop);
if (wordHeight > 0 && verticalScale < 1) {
const midY = (wordTop + wordBottom) / 2;
const shrunkHeight = Math.max(0, wordHeight * verticalScale);
const half = shrunkHeight / 2;
const newTop = clamp(midY - half);
const newBottom = clamp(midY + half);
wordHeight = Math.max(0, newBottom - newTop);
const bbox = adjustBoundingBox(
wordLeft,
newTop,
wordWidth,
wordHeight,
);
tokens.push(normalizedWord);
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox });
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
continue;
}
const bbox = adjustBoundingBox(
wordLeft,
wordTop,
wordWidth,
wordHeight,
);
tokens.push(normalizedWord);
metadata.push({
page: pageIndex,
paragraph: paragraphIndex,
bbox,
});
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
}
if (isParagraphBreak(item as TextItem, prevItem)) {
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({
page: pageIndex,
paragraph: paragraphIndex,
text: paragraphBuffer.trim(),
});
paragraphBuffer = "";
}
tokens.push("\uE000PARA");
metadata.push({
page: pageIndex,
paragraph: paragraphIndex,
bbox: null,
});
paragraphIndex += 1;
}
prevItem = item as TextItem;
}
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({
page: pageIndex,
paragraph: paragraphIndex,
text: paragraphBuffer.trim(),
});
paragraphBuffer = "";
tokens.push("\uE000PARA");
metadata.push({
page: pageIndex,
paragraph: paragraphIndex,
bbox: null,
});
}
}
return { tokens, metadata, pageSizes, paragraphs };
} finally {
pdfWorkerManager.destroyDocument(pdfDoc);
}
};
@@ -0,0 +1,742 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ADDITION_HIGHLIGHT,
CompareAnyResult,
CompareDiffToken,
CompareFilteredTokenInfo,
CompareResultData,
CompareWorkerRequest,
CompareWorkerResponse,
CompareWorkerWarnings,
REMOVAL_HIGHLIGHT,
} from "@app/types/compare";
import {
runPixelCompare,
revokePixelResult,
} from "@app/services/pixelCompareService";
import { CompareParameters } from "@app/hooks/tools/compare/useCompareParameters";
import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
import type { StirlingFile } from "@app/types/fileContext";
import { useFileContext } from "@app/contexts/file/fileHooks";
import {
aggregateTotals,
buildChanges,
createSummaryFile,
extractContentFromPdf,
getWorkerErrorCode,
filterTokensForDiff,
} from "@app/hooks/tools/compare/operationUtils";
import { alert, dismissToast } from "@app/components/toast";
import type { ToastLocation } from "@app/components/toast/types";
import CompareWorkerCtor from "@app/workers/compareWorker?worker";
const LONG_RUNNING_PAGE_THRESHOLD = 2000;
export interface CompareOperationHook extends ToolOperationHook<CompareParameters> {
result: CompareAnyResult | null;
warnings: string[];
pixelProgress: { current: number; total: number } | null;
}
// extractContentFromPdf moved to utils
export const useCompareOperation = (): CompareOperationHook => {
const { t } = useTranslation();
const { selectors } = useFileContext();
const workerRef = useRef<Worker | null>(null);
const previousUrl = useRef<string | null>(null);
const activeRunIdRef = useRef(0);
const cancelledRef = useRef(false);
type OperationStatus =
| "idle"
| "extracting"
| "processing"
| "complete"
| "cancelled"
| "error";
const [isLoading, setIsLoading] = useState(false);
const [statusState, setStatusState] = useState<OperationStatus>("idle");
const [statusDetailMs, setStatusDetailMs] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState("");
const [result, setResult] = useState<CompareAnyResult | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [pixelProgress, setPixelProgress] = useState<{
current: number;
total: number;
} | null>(null);
const pixelSignalRef = useRef<{ cancelled: boolean }>({ cancelled: false });
const pixelResultRef = useRef<
import("@app/types/compare").CompareResultPixelData | null
>(null);
const revokePixelUrls = useCallback(() => {
if (pixelResultRef.current) {
revokePixelResult(pixelResultRef.current);
pixelResultRef.current = null;
}
}, []);
const longRunningToastIdRef = useRef<string | null>(null);
const dissimilarityToastIdRef = useRef<string | null>(null);
const dissimilarityToastShownRef = useRef<boolean>(false);
const ensureWorker = useCallback(() => {
if (!workerRef.current) {
workerRef.current = new CompareWorkerCtor();
}
return workerRef.current;
}, []);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
revokePixelUrls();
setResult(null);
setWarnings([]);
setFiles([]);
setPixelProgress(null);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename("");
setStatusState("idle");
setStatusDetailMs(null);
setErrorMessage(null);
}, [cleanupDownloadUrl, revokePixelUrls]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const runCompareWorker = useCallback(
async (
baseTokens: string[],
comparisonTokens: string[],
warningMessages: CompareWorkerWarnings,
onChunk?: (chunk: CompareDiffToken[]) => void,
) => {
const worker = ensureWorker();
return await new Promise<{
tokens: CompareDiffToken[];
stats: {
baseWordCount: number;
comparisonWordCount: number;
durationMs: number;
};
warnings: string[];
}>((resolve, reject) => {
const collectedWarnings: string[] = [];
const collectedTokens: CompareDiffToken[] = [];
const handleMessage = (event: MessageEvent<CompareWorkerResponse>) => {
if (cancelledRef.current) {
cleanup();
reject(
Object.assign(new Error("Operation cancelled"), {
code: "CANCELLED" as const,
}),
);
return;
}
const message = event.data;
if (!message) {
return;
}
switch (message.type) {
case "chunk": {
if (message.tokens.length > 0) {
collectedTokens.push(...message.tokens);
onChunk?.(message.tokens);
}
break;
}
case "success":
cleanup();
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
resolve({
tokens: collectedTokens,
stats: message.stats,
warnings: collectedWarnings,
});
break;
case "warning":
collectedWarnings.push(message.message);
break;
case "error": {
cleanup();
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
const error: Error & {
code?: "EMPTY_TEXT" | "TOO_LARGE" | "TOO_DISSIMILAR";
} = new Error(message.message);
error.code = message.code;
reject(error);
break;
}
default:
break;
}
};
const handleError = (event: ErrorEvent) => {
cleanup();
if (cancelledRef.current) {
reject(
Object.assign(new Error("Operation cancelled"), {
code: "CANCELLED" as const,
}),
);
} else {
reject(event.error ?? new Error(event.message));
}
};
const cleanup = () => {
worker.removeEventListener("message", handleMessage as EventListener);
worker.removeEventListener("error", handleError as EventListener);
};
worker.addEventListener("message", handleMessage as EventListener);
worker.addEventListener("error", handleError as EventListener);
const request: CompareWorkerRequest = {
type: "compare",
payload: {
baseTokens,
comparisonTokens,
warnings: warningMessages,
// Static worker settings to support large documents
settings: {
batchSize: 5000,
complexThreshold: 120000,
maxWordThreshold: 200000,
},
},
};
worker.postMessage(request);
});
},
[ensureWorker],
);
const executeOperation = useCallback(
async (params: CompareParameters, selectedFiles: StirlingFile[]) => {
// start new run
const runId = ++activeRunIdRef.current;
cancelledRef.current = false;
if (!params.baseFileId || !params.comparisonFileId) {
setErrorMessage(
t(
"compare.error.selectRequired",
"Select the original and edited document.",
),
);
return;
}
const baseFile =
selectedFiles.find((file) => file.fileId === params.baseFileId) ??
selectors.getFile(params.baseFileId);
const comparisonFile =
selectedFiles.find((file) => file.fileId === params.comparisonFileId) ??
selectors.getFile(params.comparisonFileId);
if (!baseFile || !comparisonFile) {
setErrorMessage(
t(
"compare.error.filesMissing",
"Unable to locate the selected files. Please re-select them.",
),
);
return;
}
setIsLoading(true);
setStatusState("extracting");
setStatusDetailMs(null);
setErrorMessage(null);
setWarnings([]);
revokePixelUrls();
setResult(null);
setFiles([]);
setPixelProgress(null);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename("");
if (params.mode === "pixel") {
const operationStartPx = performance.now();
pixelSignalRef.current = { cancelled: false };
try {
setStatusState("processing");
const pixelResult = await runPixelCompare({
baseFile,
comparisonFile,
baseFileId: baseFile.fileId,
comparisonFileId: comparisonFile.fileId,
dpi: params.pixelDpi,
threshold: params.pixelThreshold,
onProgress: (current, total) => {
if (activeRunIdRef.current === runId)
setPixelProgress({ current, total });
},
signal: pixelSignalRef.current,
warnings: {
pageCountMismatch: t(
"compare.pixel.warnings.pageCountMismatch",
"Page count mismatch: original has {{base}} page(s), edited has {{comparison}}. Extra pages are shown one-sided and marked as fully removed/added.",
),
noPages: t(
"compare.pixel.warnings.noPages",
"One or both documents have no pages.",
),
},
errors: {
canvasContextUnavailable: t(
"compare.pixel.errors.canvasContextUnavailable",
"Unable to acquire 2D canvas context for pixel comparison.",
),
},
});
if (cancelledRef.current || activeRunIdRef.current !== runId) {
revokePixelResult(pixelResult);
return;
}
pixelResultRef.current = pixelResult;
setResult(pixelResult);
setWarnings(pixelResult.warnings);
setStatusState("complete");
} catch (error: unknown) {
if (error instanceof Error && error.message === "CANCELLED") {
setStatusState("cancelled");
} else {
console.error("[compare] pixel operation failed", error);
const fallback = t(
"compare.error.generic",
"Unable to compare these files.",
);
setErrorMessage(
error instanceof Error && error.message
? error.message
: fallback,
);
}
} finally {
setStatusDetailMs(Math.round(performance.now() - operationStartPx));
setIsLoading(false);
setPixelProgress(null);
}
return;
}
const warningMessages: CompareWorkerWarnings = {
// No accuracy warning any more
tooLargeMessage: t(
"compare.large.file.message",
"These documents are very large; comparison may take several minutes. Please keep this tab open.",
),
emptyTextMessage: t(
"compare.no.text.message",
"One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison.",
),
tooDissimilarMessage: t(
"compare.too.dissimilar.message",
"These documents appear highly dissimilar. Comparison was stopped to save time.",
),
};
const operationStart = performance.now();
try {
const [baseContent, comparisonContent] = await Promise.all([
extractContentFromPdf(baseFile),
extractContentFromPdf(comparisonFile),
]);
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
if (
baseContent.tokens.length === 0 ||
comparisonContent.tokens.length === 0
) {
throw Object.assign(new Error(warningMessages.emptyTextMessage), {
code: "EMPTY_TEXT",
});
}
setStatusState("processing");
// Filter out paragraph sentinels before diffing to avoid large false-positive runs
const baseFiltered = filterTokensForDiff(
baseContent.tokens,
baseContent.metadata,
);
const comparisonFiltered = filterTokensForDiff(
comparisonContent.tokens,
comparisonContent.metadata,
);
const combinedPageCount =
(baseContent.pageSizes?.length ?? 0) +
(comparisonContent.pageSizes?.length ?? 0);
if (
combinedPageCount >= LONG_RUNNING_PAGE_THRESHOLD &&
!longRunningToastIdRef.current
) {
const toastId = alert({
alertType: "neutral",
title: t("compare.longJob.title", "Large comparison in progress"),
body: t(
"compare.longJob.body",
"These PDFs together exceed 2,000 pages. Processing can take several minutes.",
),
location: "bottom-right" as ToastLocation,
isPersistentPopup: true,
expandable: false,
});
longRunningToastIdRef.current = toastId || null;
}
// Heuristic: surface an early warning toast when we observe a very high ratio of differences
const EARLY_TOAST_MIN_TOKENS = 15000; // wait for some signal before warning
const EARLY_TOAST_DIFF_RATIO = 0.8; // 80% added/removed vs unchanged
let observedAddedRemoved = 0;
let observedUnchanged = 0;
const handleEarlyDissimilarity = () => {
if (
dissimilarityToastShownRef.current ||
dissimilarityToastIdRef.current
)
return;
const toastId = alert({
alertType: "warning",
title: t(
"compare.earlyDissimilarity.title",
"These PDFs look highly different",
),
body: t(
"compare.earlyDissimilarity.body",
"We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.",
),
location: "bottom-right" as ToastLocation,
isPersistentPopup: true,
expandable: false,
buttonText: t(
"compare.earlyDissimilarity.stopButton",
"Stop comparison",
),
buttonCallback: () => {
try {
cancelOperation();
} catch {
console.error("Failed to cancel operation");
}
try {
window.dispatchEvent(new CustomEvent("compare:clear-selected"));
} catch {
console.error("Failed to dispatch clear selected event");
}
if (dissimilarityToastIdRef.current) {
dismissToast(dissimilarityToastIdRef.current);
dissimilarityToastIdRef.current = null;
}
},
});
dissimilarityToastIdRef.current = toastId || null;
dissimilarityToastShownRef.current = true;
};
const {
tokens,
stats,
warnings: workerWarnings,
} = await runCompareWorker(
baseFiltered.tokens,
comparisonFiltered.tokens,
warningMessages,
(chunk) => {
// Incremental ratio tracking for early warning
for (const tok of chunk) {
if (tok.type === "unchanged") observedUnchanged += 1;
else observedAddedRemoved += 1;
}
const seen = observedAddedRemoved + observedUnchanged;
if (
!dissimilarityToastShownRef.current &&
seen >= EARLY_TOAST_MIN_TOKENS &&
observedAddedRemoved / Math.max(1, seen) >= EARLY_TOAST_DIFF_RATIO
) {
handleEarlyDissimilarity();
}
},
);
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
const baseHasHighlight = new Array<boolean>(
baseFiltered.tokens.length,
).fill(false);
const comparisonHasHighlight = new Array<boolean>(
comparisonFiltered.tokens.length,
).fill(false);
let baseTokenPointer = 0;
let comparisonTokenPointer = 0;
for (const diffToken of tokens) {
if (diffToken.type === "removed") {
if (baseTokenPointer < baseHasHighlight.length) {
baseHasHighlight[baseTokenPointer] = true;
}
baseTokenPointer += 1;
} else if (diffToken.type === "added") {
if (comparisonTokenPointer < comparisonHasHighlight.length) {
comparisonHasHighlight[comparisonTokenPointer] = true;
}
comparisonTokenPointer += 1;
} else {
if (baseTokenPointer < baseHasHighlight.length) {
baseTokenPointer += 1;
}
if (comparisonTokenPointer < comparisonHasHighlight.length) {
comparisonTokenPointer += 1;
}
}
}
const buildFilteredTokenData = (
tokensList: typeof baseFiltered.tokens,
metadataList: typeof baseFiltered.metadata,
highlightFlags: boolean[],
): CompareFilteredTokenInfo[] =>
tokensList.map((token, index) => {
const meta = metadataList[index];
return {
token,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
bbox: meta?.bbox ?? null,
hasHighlight: highlightFlags[index] ?? false,
metaIndex: index,
};
});
const totals = aggregateTotals(tokens);
const processedAt = Date.now();
const baseMetadata = baseFiltered.metadata;
const comparisonMetadata = comparisonFiltered.metadata;
const changes = buildChanges(tokens, baseMetadata, comparisonMetadata);
const comparisonResult: CompareResultData = {
mode: "text",
base: {
fileId: baseFile.fileId,
fileName: baseFile.name,
highlightColor: REMOVAL_HIGHLIGHT,
wordCount: stats.baseWordCount,
pageSizes: baseContent.pageSizes,
},
comparison: {
fileId: comparisonFile.fileId,
fileName: comparisonFile.name,
highlightColor: ADDITION_HIGHLIGHT,
wordCount: stats.comparisonWordCount,
pageSizes: comparisonContent.pageSizes,
},
totals: {
...totals,
durationMs: stats.durationMs,
processedAt,
},
tokens,
tokenMetadata: {
base: baseMetadata,
comparison: comparisonMetadata,
},
filteredTokenData: {
base: buildFilteredTokenData(
baseFiltered.tokens,
baseFiltered.metadata,
baseHasHighlight,
),
comparison: buildFilteredTokenData(
comparisonFiltered.tokens,
comparisonFiltered.metadata,
comparisonHasHighlight,
),
},
sourceTokens: {
base: baseContent.tokens,
comparison: comparisonContent.tokens,
},
changes,
warnings: workerWarnings,
baseParagraphs: baseContent.paragraphs,
comparisonParagraphs: comparisonContent.paragraphs,
};
setResult(comparisonResult);
setWarnings(workerWarnings);
const summaryFile = createSummaryFile(comparisonResult);
setFiles([summaryFile]);
cleanupDownloadUrl();
const blobUrl = URL.createObjectURL(summaryFile);
previousUrl.current = blobUrl;
setDownloadUrl(blobUrl);
setDownloadFilename(summaryFile.name);
setStatusState("complete");
} catch (error: unknown) {
console.error("[compare] operation failed", error);
const errorCode = getWorkerErrorCode(error);
if (errorCode === "EMPTY_TEXT") {
setErrorMessage(
warningMessages.emptyTextMessage ??
t("compare.error.generic", "Unable to compare these files."),
);
} else {
const fallbackMessage = t(
"compare.error.generic",
"Unable to compare these files.",
);
if (error instanceof Error && error.message) {
setErrorMessage(error.message);
} else if (typeof error === "string" && error.trim().length > 0) {
setErrorMessage(error);
} else {
setErrorMessage(fallbackMessage);
}
}
} finally {
const duration = performance.now() - operationStart;
setStatusDetailMs(Math.round(duration));
setIsLoading(false);
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
if (dissimilarityToastIdRef.current) {
dismissToast(dissimilarityToastIdRef.current);
dissimilarityToastIdRef.current = null;
}
dissimilarityToastShownRef.current = false;
}
},
[cleanupDownloadUrl, runCompareWorker, selectors, t],
);
const cancelOperation = useCallback(() => {
if (!isLoading) return;
cancelledRef.current = true;
pixelSignalRef.current.cancelled = true;
setIsLoading(false);
setStatusState("cancelled");
setPixelProgress(null);
if (workerRef.current) {
try {
workerRef.current.terminate();
// eslint-disable-next-line no-empty
} catch {}
workerRef.current = null;
}
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
}, [isLoading]);
const undoOperation = useCallback(async () => {
resetResults();
}, [resetResults]);
useEffect(() => {
return () => {
cleanupDownloadUrl();
revokePixelUrls();
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
};
}, [cleanupDownloadUrl, revokePixelUrls]);
const status = useMemo(() => {
const label =
statusState === "idle"
? ""
: statusState === "extracting"
? t("compare.status.extracting", "Extracting text...")
: statusState === "processing"
? t("compare.status.processing", "Analyzing differences...")
: statusState === "complete"
? t("compare.status.complete", "Comparison ready")
: statusState === "cancelled"
? t("operationCancelled", "Operation cancelled")
: "";
if (label && statusDetailMs != null)
return `${label} (${statusDetailMs} ms)`;
return label;
}, [statusState, statusDetailMs, t]);
return useMemo<CompareOperationHook>(
() => ({
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
result,
warnings,
pixelProgress,
}),
[
cancelOperation,
clearError,
downloadFilename,
downloadUrl,
errorMessage,
executeOperation,
files,
isLoading,
pixelProgress,
resetResults,
result,
status,
undoOperation,
warnings,
],
);
};
@@ -0,0 +1,40 @@
import {
BaseParametersHook,
useBaseParameters,
} from "@app/hooks/tools/shared/useBaseParameters";
import type { CompareMode } from "@app/types/compare";
import type { FileId } from "@app/types/file";
export const DEFAULT_PIXEL_DPI = 150;
export const DEFAULT_PIXEL_THRESHOLD = 0.1;
export interface CompareParameters {
baseFileId: FileId | null;
comparisonFileId: FileId | null;
mode: CompareMode;
pixelDpi: number;
pixelThreshold: number;
}
export const defaultParameters: CompareParameters = {
baseFileId: null,
comparisonFileId: null,
mode: "text",
pixelDpi: DEFAULT_PIXEL_DPI,
pixelThreshold: DEFAULT_PIXEL_THRESHOLD,
};
export type CompareParametersHook = BaseParametersHook<CompareParameters>;
export const useCompareParameters = (): CompareParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "compare",
validateFn: (params) =>
Boolean(
params.baseFileId &&
params.comparisonFileId &&
params.baseFileId !== params.comparisonFileId,
),
});
};
@@ -0,0 +1,63 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
CompressParameters,
defaultParameters,
} from "@app/hooks/tools/compress/useCompressParameters";
// Static configuration that can be used by both the hook and automation executor
export const buildCompressFormData = (
parameters: CompressParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (parameters.compressionMethod === "quality") {
formData.append("optimizeLevel", parameters.compressionLevel.toString());
} else {
// File size method
const fileSize = parameters.fileSizeValue
? `${parameters.fileSizeValue}${parameters.fileSizeUnit}`
: "";
if (fileSize) {
formData.append("expectedOutputSize", fileSize);
}
}
formData.append("grayscale", (parameters.grayscale ?? false).toString());
formData.append("lineArt", parameters.lineArt.toString());
formData.append("linearize", parameters.linearize.toString());
if (parameters.lineArt) {
formData.append("lineArtThreshold", parameters.lineArtThreshold.toString());
formData.append("lineArtEdgeLevel", parameters.lineArtEdgeLevel.toString());
}
return formData;
};
// Static configuration object
export const compressOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCompressFormData,
operationType: "compress",
endpoint: "/api/v1/misc/compress-pdf",
defaultParameters,
} as const;
export const useCompressOperation = () => {
const { t } = useTranslation();
return useToolOperation<CompressParameters>({
...compressOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"compress.error.failed",
"An error occurred while compressing the PDF.",
),
),
});
};
@@ -0,0 +1,44 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface CompressParameters extends BaseParameters {
compressionLevel: number;
grayscale: boolean;
lineArt: boolean;
lineArtThreshold: number;
lineArtEdgeLevel: 1 | 2 | 3;
expectedSize: string;
compressionMethod: "quality" | "filesize";
fileSizeValue: string;
fileSizeUnit: "KB" | "MB";
linearize: boolean;
}
export const defaultParameters: CompressParameters = {
compressionLevel: 5,
grayscale: false,
lineArt: false,
lineArtThreshold: 50,
lineArtEdgeLevel: 3,
expectedSize: "",
compressionMethod: "quality",
fileSizeValue: "",
fileSizeUnit: "MB",
linearize: false,
};
export type CompressParametersHook = BaseParametersHook<CompressParameters>;
export const useCompressParameters = (): CompressParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "compress-pdf",
validateFn: (params) => {
// For compression, we only need to validate that compression level is within range
return params.compressionLevel >= 1 && params.compressionLevel <= 9;
},
});
};
@@ -0,0 +1,365 @@
import { useCallback, useMemo } from "react";
import apiClient from "@app/services/apiClient";
import { useTranslation } from "react-i18next";
import {
ConvertParameters,
defaultParameters,
} from "@app/hooks/tools/convert/useConvertParameters";
import { createFileFromApiResponse } from "@app/utils/fileResponseUtils";
import {
useToolOperation,
ToolType,
CustomProcessorResult,
} from "@app/hooks/tools/shared/useToolOperation";
import {
getEndpointUrl,
getEndpointName,
isImageFormat,
isWebFormat,
isOfficeFormat,
} from "@app/utils/convertUtils";
import { useToolCloudStatus } from "@app/hooks/useToolCloudStatus";
// Static function that can be used by both the hook and automation executor
export const shouldProcessFilesSeparately = (
selectedFiles: File[],
parameters: ConvertParameters,
): boolean => {
return (
selectedFiles.length > 1 &&
// Image to PDF with combineImages = false
(((isImageFormat(parameters.fromExtension) ||
parameters.fromExtension === "image") &&
parameters.toExtension === "pdf" &&
!parameters.imageOptions.combineImages) ||
// SVG to PDF with combineIntoSinglePdf = false
(parameters.fromExtension === "svg" &&
parameters.toExtension === "pdf" &&
!parameters.imageOptions.combineImages) ||
// PDF to image conversions (each PDF should generate its own image file)
(parameters.fromExtension === "pdf" &&
isImageFormat(parameters.toExtension)) ||
// PDF to PDF/A and PDF/X conversions (each PDF should be processed separately)
(parameters.fromExtension === "pdf" &&
(parameters.toExtension === "pdfa" ||
parameters.toExtension === "pdfx")) ||
// PDF to text-like/spreadsheet formats should be one output per input
(parameters.fromExtension === "pdf" &&
["txt", "rtf", "csv", "xlsx"].includes(parameters.toExtension)) ||
// PDF to CBR conversions (each PDF should generate its own archive)
(parameters.fromExtension === "pdf" &&
parameters.toExtension === "cbr") ||
// PDF to EPUB/AZW3 conversions (each PDF should generate its own ebook)
(parameters.fromExtension === "pdf" &&
["epub", "azw3"].includes(parameters.toExtension)) ||
// PDF to office format conversions (each PDF should generate its own office file)
(parameters.fromExtension === "pdf" &&
isOfficeFormat(parameters.toExtension)) ||
// Office files to PDF conversions (each file should be processed separately via LibreOffice)
(isOfficeFormat(parameters.fromExtension) &&
parameters.toExtension === "pdf") ||
// Web files to PDF conversions (each web file should generate its own PDF)
((isWebFormat(parameters.fromExtension) ||
parameters.fromExtension === "web") &&
parameters.toExtension === "pdf") ||
// eBook files to PDF conversions (each file should be processed separately via Calibre)
(["epub", "mobi", "azw3", "fb2"].includes(parameters.fromExtension) &&
parameters.toExtension === "pdf") ||
// Web files smart detection
(parameters.isSmartDetection &&
parameters.smartDetectionType === "web") ||
// Mixed file types (smart detection)
(parameters.isSmartDetection &&
parameters.smartDetectionType === "mixed"))
);
};
// Static function that can be used by both the hook and automation executor
export const buildConvertFormData = (
parameters: ConvertParameters,
selectedFiles: File[],
): FormData => {
const formData = new FormData();
const {
fromExtension,
toExtension,
imageOptions,
htmlOptions,
emailOptions,
pdfaOptions,
pdfxOptions,
cbrOptions,
pdfToCbrOptions,
cbzOptions,
cbzOutputOptions,
ebookOptions,
epubOptions,
} = parameters;
selectedFiles.forEach((file) => {
formData.append("fileInput", file);
});
if (isImageFormat(toExtension)) {
formData.append("imageFormat", toExtension);
formData.append("colorType", imageOptions.colorType);
formData.append("dpi", imageOptions.dpi.toString());
formData.append("singleOrMultiple", imageOptions.singleOrMultiple);
} else if (fromExtension === "pdf" && ["docx", "odt"].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if (fromExtension === "pdf" && ["pptx", "odp"].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if (fromExtension === "pdf" && ["txt", "rtf"].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if (
(isImageFormat(fromExtension) || fromExtension === "image") &&
toExtension === "pdf"
) {
formData.append("fitOption", imageOptions.fitOption);
formData.append("colorType", imageOptions.colorType);
formData.append("autoRotate", imageOptions.autoRotate.toString());
} else if (fromExtension === "svg" && toExtension === "pdf") {
formData.append(
"combineIntoSinglePdf",
imageOptions.combineImages.toString(),
);
} else if (
(fromExtension === "html" || fromExtension === "zip") &&
toExtension === "pdf"
) {
formData.append("zoom", htmlOptions.zoomLevel.toString());
} else if (
(fromExtension === "eml" || fromExtension === "msg") &&
toExtension === "pdf"
) {
formData.append(
"includeAttachments",
emailOptions.includeAttachments.toString(),
);
formData.append(
"maxAttachmentSizeMB",
emailOptions.maxAttachmentSizeMB.toString(),
);
formData.append("downloadHtml", emailOptions.downloadHtml.toString());
formData.append(
"includeAllRecipients",
emailOptions.includeAllRecipients.toString(),
);
} else if (fromExtension === "pdf" && toExtension === "pdfa") {
formData.append("outputFormat", pdfaOptions.outputFormat);
formData.append("strict", String(!!pdfaOptions.strict));
} else if (fromExtension === "pdf" && toExtension === "pdfx") {
// Use PDF/A endpoint with PDF/X format parameter
formData.append("outputFormat", pdfxOptions?.outputFormat || "pdfx");
} else if (fromExtension === "pdf" && toExtension === "csv") {
formData.append("pageNumbers", "all");
} else if (fromExtension === "pdf" && toExtension === "xlsx") {
formData.append("pageNumbers", "all");
} else if (fromExtension === "cbr" && toExtension === "pdf") {
formData.append("optimizeForEbook", cbrOptions.optimizeForEbook.toString());
} else if (fromExtension === "pdf" && toExtension === "cbr") {
formData.append("dpi", pdfToCbrOptions.dpi.toString());
} else if (fromExtension === "cbz" && toExtension === "pdf") {
formData.append(
"optimizeForEbook",
(cbzOptions?.optimizeForEbook ?? false).toString(),
);
} else if (fromExtension === "pdf" && toExtension === "cbz") {
formData.append("dpi", (cbzOutputOptions?.dpi ?? 150).toString());
} else if (
["epub", "mobi", "azw3", "fb2"].includes(fromExtension) &&
toExtension === "pdf"
) {
formData.append(
"embedAllFonts",
(ebookOptions?.embedAllFonts ?? false).toString(),
);
formData.append(
"includeTableOfContents",
(ebookOptions?.includeTableOfContents ?? false).toString(),
);
formData.append(
"includePageNumbers",
(ebookOptions?.includePageNumbers ?? false).toString(),
);
formData.append(
"optimizeForEbook",
(ebookOptions?.optimizeForEbook ?? false).toString(),
);
} else if (
fromExtension === "pdf" &&
["epub", "azw3"].includes(toExtension)
) {
formData.append(
"detectChapters",
(epubOptions?.detectChapters ?? true).toString(),
);
formData.append(
"targetDevice",
epubOptions?.targetDevice ?? "TABLET_PHONE_IMAGES",
);
formData.append(
"outputFormat",
epubOptions?.outputFormat ?? (toExtension === "azw3" ? "AZW3" : "EPUB"),
);
}
return formData;
};
// Static function that can be used by both the hook and automation executor
export const createFileFromResponse = (
responseData: any,
headers: any,
originalFileName: string,
targetExtension: string,
): File => {
const originalName = originalFileName.split(".")[0];
// Map both pdfa and pdfx to pdf since they both result in PDF files
if (targetExtension == "pdfa" || targetExtension == "pdfx") {
targetExtension = "pdf";
}
const fallbackFilename = `${originalName}.${targetExtension}`;
return createFileFromApiResponse(responseData, headers, fallbackFilename);
};
// Static processor that can be used by both the hook and automation executor
export const convertProcessor = async (
parameters: ConvertParameters,
selectedFiles: File[],
): Promise<CustomProcessorResult> => {
const processedFiles: File[] = [];
// Map PDF/X to use PDF/A endpoint
const actualToExtension =
parameters.toExtension === "pdfx" ? "pdfa" : parameters.toExtension;
const endpoint = getEndpointUrl(parameters.fromExtension, actualToExtension);
if (!endpoint) {
throw new Error("Unsupported conversion format");
}
// Convert-specific routing logic: decide batch vs individual processing
// For PDF/X, we want to treat it similar to PDF/A (separate processing)
const isSeparateProcessing = shouldProcessFilesSeparately(selectedFiles, {
...parameters,
toExtension: actualToExtension, // Use the mapped extension for decision logic
});
if (isSeparateProcessing) {
// Individual processing for complex cases (PDF→image, smart detection, etc.)
for (const file of selectedFiles) {
try {
const formData = buildConvertFormData(parameters, [file]);
const response = await apiClient.post(endpoint, formData, {
responseType: "blob",
});
const convertedFile = createFileFromResponse(
response.data,
response.headers,
file.name,
actualToExtension === "pdfa" ? "pdfx" : parameters.toExtension,
);
processedFiles.push(convertedFile);
} catch (error) {
console.warn(`Failed to convert file ${file.name}:`, error);
}
}
} else {
// Batch processing for simple cases (image→PDF combine)
const formData = buildConvertFormData(parameters, selectedFiles);
const response = await apiClient.post(endpoint, formData, {
responseType: "blob",
});
const baseFilename =
selectedFiles.length === 1 ? selectedFiles[0].name : "converted_files";
const convertedFile = createFileFromResponse(
response.data,
response.headers,
baseFilename,
actualToExtension === "pdfa" ? "pdfx" : parameters.toExtension,
);
processedFiles.push(convertedFile);
}
// When batch processing multiple files into one output (e.g., 3 images → 1 PDF),
// mark all inputs as consumed even though there's only 1 output file
const isCombiningMultiple = !isSeparateProcessing && selectedFiles.length > 1;
return {
files: processedFiles,
consumedAllInputs: isCombiningMultiple,
};
};
// Static configuration object
export const convertOperationConfig = {
toolType: ToolType.custom,
customProcessor: convertProcessor, // Can't use callback version here
operationType: "convert",
defaultParameters,
endpoint: (params: ConvertParameters): string | undefined => {
if (!params.fromExtension || !params.toExtension) return undefined;
const actualToExtension =
params.toExtension === "pdfx" ? "pdfa" : params.toExtension;
return getEndpointUrl(params.fromExtension, actualToExtension) ?? undefined;
},
} as const;
export const useConvertOperation = (parameters?: ConvertParameters) => {
const { t } = useTranslation();
// Calculate current endpoint name for cloud detection
const currentEndpointName = useMemo(() => {
if (!parameters?.fromExtension || !parameters?.toExtension)
return undefined;
// Map PDF/X to use PDF/A endpoint (same as in convertProcessor)
const actualToExtension =
parameters.toExtension === "pdfx" ? "pdfa" : parameters.toExtension;
return getEndpointName(parameters.fromExtension, actualToExtension);
}, [parameters?.fromExtension, parameters?.toExtension]);
// Check if current conversion will use cloud
const willUseCloud = useToolCloudStatus(currentEndpointName);
const customConvertProcessor = useCallback(
async (
parameters: ConvertParameters,
selectedFiles: File[],
): Promise<CustomProcessorResult> => {
return convertProcessor(parameters, selectedFiles);
},
[],
);
const operation = useToolOperation<ConvertParameters>({
...convertOperationConfig,
customProcessor: customConvertProcessor, // Use instance-specific processor for translation support
getErrorMessage: (error) => {
if (error.response?.data && typeof error.response.data === "string") {
return error.response.data;
}
if (error.message) {
return error.message;
}
return t(
"convert.errorConversion",
"An error occurred while converting the file.",
);
},
});
// Override willUseCloud with our calculated value
return {
...operation,
willUseCloud,
};
};
@@ -0,0 +1,250 @@
/**
* Unit tests for useConvertParameters hook
*/
import { describe, test, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import { FIT_OPTIONS } from "@app/constants/convertConstants";
describe("useConvertParameters", () => {
describe("Parameter Management", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useConvertParameters());
expect(result.current.parameters.fromExtension).toBe("");
expect(result.current.parameters.toExtension).toBe("");
expect(result.current.parameters.imageOptions.colorType).toBe("color");
expect(result.current.parameters.imageOptions.dpi).toBe(300);
expect(result.current.parameters.imageOptions.singleOrMultiple).toBe(
"multiple",
);
expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.0);
expect(result.current.parameters.emailOptions.includeAttachments).toBe(
true,
);
expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(
10,
);
expect(result.current.parameters.emailOptions.downloadHtml).toBe(false);
expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(
false,
);
expect(result.current.parameters.pdfaOptions.outputFormat).toBe(
"pdfa-2b",
);
});
test("should update individual parameters", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("fromExtension", "pdf");
});
expect(result.current.parameters.fromExtension).toBe("pdf");
expect(result.current.parameters.toExtension).toBe(""); // Should not affect other params
});
test("should update nested image options", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("imageOptions", {
colorType: "grayscale",
dpi: 150,
singleOrMultiple: "single",
fitOption: FIT_OPTIONS.FILL_PAGE,
autoRotate: false,
combineImages: false,
});
});
expect(result.current.parameters.imageOptions.colorType).toBe(
"grayscale",
);
expect(result.current.parameters.imageOptions.dpi).toBe(150);
expect(result.current.parameters.imageOptions.singleOrMultiple).toBe(
"single",
);
expect(result.current.parameters.imageOptions.fitOption).toBe(
FIT_OPTIONS.FILL_PAGE,
);
expect(result.current.parameters.imageOptions.autoRotate).toBe(false);
expect(result.current.parameters.imageOptions.combineImages).toBe(false);
});
test("should update nested HTML options", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("htmlOptions", {
zoomLevel: 1.5,
});
});
expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.5);
});
test("should update nested email options", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("emailOptions", {
includeAttachments: false,
maxAttachmentSizeMB: 20,
downloadHtml: true,
includeAllRecipients: true,
});
});
expect(result.current.parameters.emailOptions.includeAttachments).toBe(
false,
);
expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(
20,
);
expect(result.current.parameters.emailOptions.downloadHtml).toBe(true);
expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(
true,
);
});
test("should update nested PDF/A options", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("pdfaOptions", {
outputFormat: "pdfa",
strict: false,
});
});
expect(result.current.parameters.pdfaOptions.outputFormat).toBe("pdfa");
});
test("should reset parameters to defaults", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("fromExtension", "pdf");
result.current.updateParameter("toExtension", "png");
});
expect(result.current.parameters.fromExtension).toBe("pdf");
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters.fromExtension).toBe("");
expect(result.current.parameters.toExtension).toBe("");
});
});
describe("Parameter Validation", () => {
test("should validate parameters correctly", () => {
const { result } = renderHook(() => useConvertParameters());
// No parameters - should be invalid
expect(result.current.validateParameters()).toBe(false);
// Only fromExtension - should be invalid
act(() => {
result.current.updateParameter("fromExtension", "pdf");
});
expect(result.current.validateParameters()).toBe(false);
// Both extensions with supported conversion - should be valid
act(() => {
result.current.updateParameter("toExtension", "png");
});
expect(result.current.validateParameters()).toBe(true);
});
test("should validate unsupported conversions", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("fromExtension", "pdf");
result.current.updateParameter("toExtension", "unsupported");
});
expect(result.current.validateParameters()).toBe(false);
});
});
describe("Endpoint Generation", () => {
test("should generate correct endpoint names", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("fromExtension", "pdf");
result.current.updateParameter("toExtension", "png");
});
const endpointName = result.current.getEndpointName();
expect(endpointName).toBe("pdf-to-img");
});
test("should generate correct endpoint URLs", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("fromExtension", "pdf");
result.current.updateParameter("toExtension", "png");
});
const endpoint = result.current.getEndpoint();
expect(endpoint).toBe("/api/v1/convert/pdf/img");
});
test("should return empty strings for invalid conversions", () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter("fromExtension", "invalid");
result.current.updateParameter("toExtension", "invalid");
});
expect(result.current.getEndpointName()).toBe("");
expect(result.current.getEndpoint()).toBe("");
});
});
describe("Available Extensions", () => {
test("should return available extensions for valid source format", () => {
const { result } = renderHook(() => useConvertParameters());
const availableExtensions =
result.current.getAvailableToExtensions("pdf");
expect(availableExtensions.length).toBeGreaterThan(0);
expect(availableExtensions.some((ext) => ext.value === "png")).toBe(true);
expect(availableExtensions.some((ext) => ext.value === "jpg")).toBe(true);
});
test("should return empty array for invalid source format", () => {
const { result } = renderHook(() => useConvertParameters());
const availableExtensions =
result.current.getAvailableToExtensions("invalid");
expect(availableExtensions).toEqual([
{
group: "Document",
label: "PDF",
value: "pdf",
},
]);
});
test("should return empty array for empty source format", () => {
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions("");
expect(availableExtensions).toEqual([]);
});
});
});
@@ -0,0 +1,442 @@
import {
COLOR_TYPES,
OUTPUT_OPTIONS,
FIT_OPTIONS,
CONVERSION_MATRIX,
type ColorType,
type OutputOption,
type FitOption,
} from "@app/constants/convertConstants";
import {
getEndpointName as getEndpointNameUtil,
getEndpointUrl,
isImageFormat,
isWebFormat,
getAvailableToExtensions as getAvailableToExtensionsUtil,
} from "@app/utils/convertUtils";
import { detectFileExtension as detectFileExtensionUtil } from "@app/utils/fileUtils";
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
import { useCallback, useMemo } from "react";
export interface ConvertParameters extends BaseParameters {
fromExtension: string;
toExtension: string;
imageOptions: {
colorType: ColorType;
dpi: number;
singleOrMultiple: OutputOption;
fitOption: FitOption;
autoRotate: boolean;
combineImages: boolean;
};
htmlOptions: {
zoomLevel: number;
};
emailOptions: {
includeAttachments: boolean;
maxAttachmentSizeMB: number;
downloadHtml: boolean;
includeAllRecipients: boolean;
};
pdfaOptions: {
outputFormat: string;
strict?: boolean;
};
pdfxOptions: {
outputFormat: string;
};
cbrOptions: {
optimizeForEbook: boolean;
};
pdfToCbrOptions: {
dpi: number;
};
cbzOptions: {
optimizeForEbook: boolean;
};
cbzOutputOptions: {
dpi: number;
};
ebookOptions?: {
embedAllFonts: boolean;
includeTableOfContents: boolean;
includePageNumbers: boolean;
optimizeForEbook: boolean;
};
epubOptions?: {
detectChapters: boolean;
targetDevice: string;
outputFormat: string;
};
isSmartDetection: boolean;
smartDetectionType: "mixed" | "images" | "web" | "none";
}
export interface ConvertParametersHook extends BaseParametersHook<ConvertParameters> {
getEndpoint: () => string;
getAvailableToExtensions: (
fromExtension: string,
) => Array<{ value: string; label: string; group: string }>;
analyzeFileTypes: (files: Array<{ name: string }>) => void;
}
export const defaultParameters: ConvertParameters = {
fromExtension: "",
toExtension: "",
imageOptions: {
colorType: COLOR_TYPES.COLOR,
dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
},
htmlOptions: {
zoomLevel: 1.0,
},
emailOptions: {
includeAttachments: true,
maxAttachmentSizeMB: 10,
downloadHtml: false,
includeAllRecipients: false,
},
pdfaOptions: {
outputFormat: "pdfa-2b",
strict: false,
},
pdfxOptions: {
outputFormat: "pdfx",
},
cbrOptions: {
optimizeForEbook: false,
},
pdfToCbrOptions: {
dpi: 150,
},
cbzOptions: {
optimizeForEbook: false,
},
cbzOutputOptions: {
dpi: 150,
},
ebookOptions: {
embedAllFonts: false,
includeTableOfContents: false,
includePageNumbers: false,
optimizeForEbook: false,
},
epubOptions: {
detectChapters: true,
targetDevice: "TABLET_PHONE_IMAGES",
outputFormat: "EPUB",
},
isSmartDetection: false,
smartDetectionType: "none",
};
const validateParameters = (params: ConvertParameters): boolean => {
const { fromExtension, toExtension } = params;
if (!fromExtension || !toExtension) return false;
// Handle dynamic format identifiers (file-<extension>)
let supportedToExtensions: string[];
if (fromExtension.startsWith("file-")) {
// Dynamic format - use 'any' conversion options
supportedToExtensions = CONVERSION_MATRIX["any"] || [];
} else {
// Regular format - check conversion matrix
supportedToExtensions = CONVERSION_MATRIX[fromExtension] || [];
}
if (!supportedToExtensions.includes(toExtension)) {
return false;
}
return true;
};
const getEndpointName = (params: ConvertParameters): string => {
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } =
params;
if (isSmartDetection) {
if (smartDetectionType === "mixed") {
// Mixed file types -> PDF using file-to-pdf endpoint
return "file-to-pdf";
} else if (smartDetectionType === "images") {
// All images -> PDF using img-to-pdf endpoint
return "img-to-pdf";
} else if (smartDetectionType === "web") {
// All web files -> PDF using html-to-pdf endpoint
return "html-to-pdf";
}
}
// Handle dynamic format identifiers (file-<extension>)
if (fromExtension.startsWith("file-")) {
// Dynamic format - use file-to-pdf endpoint
return "file-to-pdf";
}
return getEndpointNameUtil(fromExtension, toExtension);
};
export const useConvertParameters = (): ConvertParametersHook => {
const config = useMemo(
() => ({
defaultParameters,
endpointName: getEndpointName,
validateFn: validateParameters,
}),
[],
);
const baseHook = useBaseParameters(config);
const getEndpoint = () => {
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } =
baseHook.parameters;
if (isSmartDetection) {
if (smartDetectionType === "mixed") {
// Mixed file types -> PDF using file-to-pdf endpoint
return "/api/v1/convert/file/pdf";
} else if (smartDetectionType === "images") {
// All images -> PDF using img-to-pdf endpoint
return "/api/v1/convert/img/pdf";
} else if (smartDetectionType === "web") {
// All web files -> PDF using html-to-pdf endpoint
return "/api/v1/convert/html/pdf";
}
}
// Handle dynamic format identifiers (file-<extension>)
if (fromExtension.startsWith("file-")) {
// Dynamic format - use file-to-pdf endpoint
return "/api/v1/convert/file/pdf";
}
return getEndpointUrl(fromExtension, toExtension);
};
const getAvailableToExtensions = getAvailableToExtensionsUtil;
const analyzeFileTypes = useCallback(
(files: Array<{ name: string }>) => {
if (files.length === 0) {
// No files - only reset smart detection, keep user's format choices
baseHook.setParameters((prev) => {
// Only update if something actually changed
if (
prev.isSmartDetection === false &&
prev.smartDetectionType === "none"
) {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: false,
smartDetectionType: "none",
// Don't reset fromExtension and toExtension - let user keep their choices
};
});
return;
}
if (files.length === 1) {
// Single file - use regular detection with smart target selection
const detectedExt = detectFileExtensionUtil(files[0].name);
let fromExt = detectedExt;
let availableTargets = detectedExt
? CONVERSION_MATRIX[detectedExt] || []
: [];
// If no explicit conversion exists for this file type, create a dynamic format entry
// and fall back to 'any' conversion logic for the actual endpoint
if (availableTargets.length === 0 && detectedExt) {
fromExt = `file-${detectedExt}`; // Create dynamic format identifier
availableTargets = CONVERSION_MATRIX["any"] || [];
} else if (availableTargets.length === 0) {
// No extension detected - fall back to 'any'
fromExt = "any";
availableTargets = CONVERSION_MATRIX["any"] || [];
}
baseHook.setParameters((prev) => {
// Check if current toExtension is still valid for the new fromExtension
const currentToExt = prev.toExtension;
const isCurrentToExtValid = availableTargets.includes(currentToExt);
// Auto-select target only if:
// 1. No current target is set, OR
// 2. Current target is invalid for new source type, OR
// 3. There's only one possible target (forced conversion)
let newToExtension = currentToExt;
if (!currentToExt || !isCurrentToExtValid) {
newToExtension =
availableTargets.length === 1 ? availableTargets[0] : "";
}
const newState = {
...prev,
isSmartDetection: false,
smartDetectionType: "none" as const,
fromExtension: fromExt,
toExtension: newToExtension,
};
// Only update if something actually changed
if (
prev.isSmartDetection === newState.isSmartDetection &&
prev.smartDetectionType === newState.smartDetectionType &&
prev.fromExtension === newState.fromExtension &&
prev.toExtension === newState.toExtension
) {
return prev; // Return the same object to prevent re-render
}
return newState;
});
return;
}
// Multiple files - analyze file types
const extensions = files.map((file) =>
detectFileExtensionUtil(file.name),
);
const uniqueExtensions = [...new Set(extensions)];
if (uniqueExtensions.length === 1) {
// All files are the same type - use regular detection with smart target selection
const detectedExt = uniqueExtensions[0];
let fromExt = detectedExt;
let availableTargets = CONVERSION_MATRIX[detectedExt] || [];
// If no explicit conversion exists for this file type, fall back to 'any'
if (availableTargets.length === 0) {
fromExt = "any";
availableTargets = CONVERSION_MATRIX["any"] || [];
}
baseHook.setParameters((prev) => {
// Check if current toExtension is still valid for the new fromExtension
const currentToExt = prev.toExtension;
const isCurrentToExtValid = availableTargets.includes(currentToExt);
// Auto-select target only if:
// 1. No current target is set, OR
// 2. Current target is invalid for new source type, OR
// 3. There's only one possible target (forced conversion)
let newToExtension = currentToExt;
if (!currentToExt || !isCurrentToExtValid) {
newToExtension =
availableTargets.length === 1 ? availableTargets[0] : "";
}
const newState = {
...prev,
isSmartDetection: false,
smartDetectionType: "none" as const,
fromExtension: fromExt,
toExtension: newToExtension,
};
// Only update if something actually changed
if (
prev.isSmartDetection === newState.isSmartDetection &&
prev.smartDetectionType === newState.smartDetectionType &&
prev.fromExtension === newState.fromExtension &&
prev.toExtension === newState.toExtension
) {
return prev; // Return the same object to prevent re-render
}
return newState;
});
} else {
// Mixed file types
const allImages = uniqueExtensions.every((ext) => isImageFormat(ext));
const allWeb = uniqueExtensions.every((ext) => isWebFormat(ext));
if (allImages) {
// All files are images - use image-to-pdf conversion
baseHook.setParameters((prev) => {
// Only update if something actually changed
if (
prev.isSmartDetection === true &&
prev.smartDetectionType === "images" &&
prev.fromExtension === "image" &&
prev.toExtension === "pdf"
) {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: true,
smartDetectionType: "images",
fromExtension: "image",
toExtension: "pdf",
};
});
} else if (allWeb) {
// All files are web files - use html-to-pdf conversion
baseHook.setParameters((prev) => {
// Only update if something actually changed
if (
prev.isSmartDetection === true &&
prev.smartDetectionType === "web" &&
prev.fromExtension === "html" &&
prev.toExtension === "pdf"
) {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: true,
smartDetectionType: "web",
fromExtension: "html",
toExtension: "pdf",
};
});
} else {
// Mixed non-image types - use file-to-pdf conversion
baseHook.setParameters((prev) => {
// Only update if something actually changed
if (
prev.isSmartDetection === true &&
prev.smartDetectionType === "mixed" &&
prev.fromExtension === "any" &&
prev.toExtension === "pdf"
) {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: true,
smartDetectionType: "mixed",
fromExtension: "any",
toExtension: "pdf",
};
});
}
}
},
[baseHook.setParameters],
);
return {
...baseHook,
getEndpoint,
getAvailableToExtensions,
analyzeFileTypes,
};
};
@@ -0,0 +1,333 @@
/**
* Tests for auto-detection and smart conversion features in useConvertParameters
* This covers the analyzeFileTypes function and related smart detection logic
*/
import { describe, test, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
describe("useConvertParameters - Auto Detection & Smart Conversion", () => {
describe("Single File Detection", () => {
test("should detect single file extension and set auto-target", () => {
const { result } = renderHook(() => useConvertParameters());
const pdfFile = [{ name: "document.pdf" }];
act(() => {
result.current.analyzeFileTypes(pdfFile);
});
expect(result.current.parameters.fromExtension).toBe("pdf");
expect(result.current.parameters.toExtension).toBe(""); // No auto-selection for multiple targets
expect(result.current.parameters.isSmartDetection).toBe(false);
expect(result.current.parameters.smartDetectionType).toBe("none");
});
test("should handle unknown file types with file-to-pdf fallback", () => {
const { result } = renderHook(() => useConvertParameters());
const unknownFile = [{ name: "document.xyz" }, { name: "image.jpggg" }];
act(() => {
result.current.analyzeFileTypes(unknownFile);
});
expect(result.current.parameters.fromExtension).toBe("any");
expect(result.current.parameters.toExtension).toBe("pdf"); // Fallback to file-to-pdf
expect(result.current.parameters.isSmartDetection).toBe(true);
});
test("should handle files without extensions", () => {
const { result } = renderHook(() => useConvertParameters());
const noExtFile = [{ name: "document" }];
act(() => {
result.current.analyzeFileTypes(noExtFile);
});
expect(result.current.parameters.fromExtension).toBe("any");
expect(result.current.parameters.toExtension).toBe("pdf"); // Fallback to file-to-pdf
});
});
describe("Multiple Identical Files", () => {
test("should detect multiple PDF files and set auto-target", () => {
const { result } = renderHook(() => useConvertParameters());
const pdfFiles = [
{ name: "doc1.pdf" },
{ name: "doc2.pdf" },
{ name: "doc3.pdf" },
];
act(() => {
result.current.analyzeFileTypes(pdfFiles);
});
expect(result.current.parameters.fromExtension).toBe("pdf");
expect(result.current.parameters.toExtension).toBe(""); // Auto-selected
expect(result.current.parameters.isSmartDetection).toBe(false);
expect(result.current.parameters.smartDetectionType).toBe("none");
});
test("should handle multiple unknown file types with fallback", () => {
const { result } = renderHook(() => useConvertParameters());
const unknownFiles = [{ name: "file1.xyz" }, { name: "file2.xyz" }];
act(() => {
result.current.analyzeFileTypes(unknownFiles);
});
expect(result.current.parameters.fromExtension).toBe("any");
expect(result.current.parameters.toExtension).toBe("pdf");
expect(result.current.parameters.isSmartDetection).toBe(false);
});
});
describe("Smart Detection - All Images", () => {
test("should detect all image files and enable smart detection", () => {
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: "photo1.jpg" },
{ name: "photo2.png" },
{ name: "photo3.gif" },
];
act(() => {
result.current.analyzeFileTypes(imageFiles);
});
expect(result.current.parameters.fromExtension).toBe("image");
expect(result.current.parameters.toExtension).toBe("pdf");
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe("images");
});
test("should handle mixed case image extensions", () => {
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [{ name: "photo1.JPG" }, { name: "photo2.PNG" }];
act(() => {
result.current.analyzeFileTypes(imageFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe("images");
});
});
describe("Smart Detection - All Web Files", () => {
test("should detect all web files and enable web smart detection", () => {
const { result } = renderHook(() => useConvertParameters());
const webFiles = [{ name: "page1.html" }, { name: "archive.zip" }];
act(() => {
result.current.analyzeFileTypes(webFiles);
});
expect(result.current.parameters.fromExtension).toBe("html");
expect(result.current.parameters.toExtension).toBe("pdf");
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe("web");
});
test("should handle mixed case web extensions", () => {
const { result } = renderHook(() => useConvertParameters());
const webFiles = [{ name: "page1.HTML" }, { name: "archive.ZIP" }];
act(() => {
result.current.analyzeFileTypes(webFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe("web");
});
test("should detect multiple web files and enable web smart detection", () => {
const { result } = renderHook(() => useConvertParameters());
const zipFiles = [{ name: "site1.zip" }, { name: "site2.html" }];
act(() => {
result.current.analyzeFileTypes(zipFiles);
});
expect(result.current.parameters.fromExtension).toBe("html");
expect(result.current.parameters.toExtension).toBe("pdf");
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe("web");
});
});
describe("Smart Detection - Mixed File Types", () => {
test("should detect mixed file types and enable smart detection", () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: "document.pdf" },
{ name: "spreadsheet.xlsx" },
{ name: "presentation.pptx" },
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.parameters.fromExtension).toBe("any");
expect(result.current.parameters.toExtension).toBe("pdf");
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe("mixed");
});
test("should detect mixed images and documents as mixed type", () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: "photo.jpg" },
{ name: "document.pdf" },
{ name: "text.txt" },
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe("mixed");
});
test("should handle mixed with unknown file types", () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: "document.pdf" },
{ name: "unknown.xyz" },
{ name: "noextension" },
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe("mixed");
});
});
describe("Smart Detection Endpoint Resolution", () => {
test("should return correct endpoint for image smart detection", () => {
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [{ name: "photo1.jpg" }, { name: "photo2.png" }];
act(() => {
result.current.analyzeFileTypes(imageFiles);
});
expect(result.current.getEndpointName()).toBe("img-to-pdf");
expect(result.current.getEndpoint()).toBe("/api/v1/convert/img/pdf");
});
test("should return correct endpoint for web smart detection", () => {
const { result } = renderHook(() => useConvertParameters());
const webFiles = [{ name: "page1.html" }, { name: "archive.zip" }];
act(() => {
result.current.analyzeFileTypes(webFiles);
});
expect(result.current.getEndpointName()).toBe("html-to-pdf");
expect(result.current.getEndpoint()).toBe("/api/v1/convert/html/pdf");
});
test("should return correct endpoint for mixed smart detection", () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: "document.pdf" },
{ name: "spreadsheet.xlsx" },
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.getEndpointName()).toBe("file-to-pdf");
expect(result.current.getEndpoint()).toBe("/api/v1/convert/file/pdf");
});
});
describe("Auto-Target Selection Logic", () => {
test("should select single available target automatically", () => {
const { result } = renderHook(() => useConvertParameters());
// Markdown has only one conversion target (PDF)
const mdFile = [{ name: "readme.md" }];
act(() => {
result.current.analyzeFileTypes(mdFile);
});
expect(result.current.parameters.fromExtension).toBe("md");
expect(result.current.parameters.toExtension).toBe("pdf"); // Only available target
});
test("should not auto-select when multiple targets available", () => {
const { result } = renderHook(() => useConvertParameters());
// PDF has multiple conversion targets, so no auto-selection
const pdfFile = [{ name: "document.pdf" }];
act(() => {
result.current.analyzeFileTypes(pdfFile);
});
expect(result.current.parameters.fromExtension).toBe("pdf");
// Should NOT auto-select when multiple targets available
expect(result.current.parameters.toExtension).toBe("");
});
});
describe("Edge Cases", () => {
test("should handle empty file names", () => {
const { result } = renderHook(() => useConvertParameters());
const emptyFiles = [{ name: "" }];
act(() => {
result.current.analyzeFileTypes(emptyFiles);
});
expect(result.current.parameters.fromExtension).toBe("any");
expect(result.current.parameters.toExtension).toBe("pdf");
});
test("should handle malformed file objects", () => {
const { result } = renderHook(() => useConvertParameters());
const malformedFiles: Array<{ name: string }> = [
{ name: "valid.pdf" },
// @ts-expect-error - Testing runtime resilience
{ name: null },
// @ts-expect-error - Testing runtime resilience
{ name: undefined },
];
act(() => {
result.current.analyzeFileTypes(malformedFiles);
});
// Should still process the valid file and handle gracefully
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe("mixed");
});
});
});
@@ -0,0 +1,52 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
CropParameters,
defaultParameters,
} from "@app/hooks/tools/crop/useCropParameters";
// Static configuration that can be used by both the hook and automation executor
export const buildCropFormData = (
parameters: CropParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (!parameters.autoCrop) {
const cropArea = parameters.cropArea;
formData.append("x", cropArea.x.toString());
formData.append("y", cropArea.y.toString());
formData.append("width", cropArea.width.toString());
formData.append("height", cropArea.height.toString());
}
formData.append("autoCrop", parameters.autoCrop.toString());
return formData;
};
// Static configuration object
export const cropOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCropFormData,
operationType: "crop",
endpoint: "/api/v1/general/crop",
defaultParameters,
} as const;
export const useCropOperation = () => {
const { t } = useTranslation();
return useToolOperation<CropParameters>({
...cropOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("crop.error.failed", "An error occurred while cropping the PDF."),
),
});
};
@@ -0,0 +1,178 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
import { useCallback } from "react";
import {
Rectangle,
PDFBounds,
constrainCropAreaToPDF,
createFullPDFCropArea,
roundCropArea,
isRectangle,
} from "@app/utils/cropCoordinates";
import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
export interface CropParameters extends BaseParameters {
cropArea: Rectangle;
autoCrop: boolean;
}
export const defaultParameters: CropParameters = {
cropArea: DEFAULT_CROP_AREA,
autoCrop: false,
};
export type CropParametersHook = BaseParametersHook<CropParameters> & {
/** Set crop area with PDF bounds validation */
setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void;
/** Get current crop area as CropArea object */
getCropArea: () => Rectangle;
/** Reset to full PDF dimensions */
resetToFullPDF: (pdfBounds: PDFBounds) => void;
/** Check if current crop area is valid for the PDF */
isCropAreaValid: (pdfBounds?: PDFBounds) => boolean;
/** Check if crop area covers the entire PDF */
isFullPDFCrop: (pdfBounds?: PDFBounds) => boolean;
/** Update crop area with constraints applied */
updateCropAreaConstrained: (
cropArea: Partial<Rectangle>,
pdfBounds?: PDFBounds,
) => void;
};
export const useCropParameters = (): CropParametersHook => {
const baseHook = useBaseParameters({
defaultParameters,
endpointName: "crop",
validateFn: (params) => {
const rect = params.cropArea;
// Basic validation - coordinates and dimensions must be positive
return rect.x >= 0 && rect.y >= 0 && rect.width > 0 && rect.height > 0;
},
});
// Get current crop area as CropArea object
const getCropArea = useCallback((): Rectangle => {
return baseHook.parameters.cropArea;
}, [baseHook.parameters]);
// Set crop area with optional PDF bounds validation
const setCropArea = useCallback(
(cropArea: Rectangle, pdfBounds?: PDFBounds) => {
let finalCropArea = roundCropArea(cropArea);
// Apply PDF bounds constraints if provided
if (pdfBounds) {
finalCropArea = constrainCropAreaToPDF(finalCropArea, pdfBounds);
}
baseHook.updateParameter("cropArea", finalCropArea);
},
[baseHook],
);
// Reset to cover entire PDF
const resetToFullPDF = useCallback(
(pdfBounds: PDFBounds) => {
const fullCropArea = createFullPDFCropArea(pdfBounds);
setCropArea(fullCropArea);
},
[setCropArea],
);
// Check if current crop area is valid for the given PDF bounds
const isCropAreaValid = useCallback(
(pdfBounds?: PDFBounds): boolean => {
const cropArea = getCropArea();
// Basic validation
if (
cropArea.x < 0 ||
cropArea.y < 0 ||
cropArea.width <= 0 ||
cropArea.height <= 0
) {
return false;
}
// PDF bounds validation if provided
if (pdfBounds) {
const tolerance = 0.01; // Small tolerance for floating point precision
return (
cropArea.x + cropArea.width <= pdfBounds.actualWidth + tolerance &&
cropArea.y + cropArea.height <= pdfBounds.actualHeight + tolerance
);
}
return true;
},
[getCropArea],
);
// Check if crop area covers the entire PDF
const isFullPDFCrop = useCallback(
(pdfBounds?: PDFBounds): boolean => {
if (!pdfBounds) return false;
const cropArea = getCropArea();
const tolerance = 0.5; // Allow 0.5 point tolerance for floating point precision
return (
Math.abs(cropArea.x) < tolerance &&
Math.abs(cropArea.y) < tolerance &&
Math.abs(cropArea.width - pdfBounds.actualWidth) < tolerance &&
Math.abs(cropArea.height - pdfBounds.actualHeight) < tolerance
);
},
[getCropArea],
);
// Update crop area with constraints applied
const updateCropAreaConstrained = useCallback(
(partialCropArea: Partial<Rectangle>, pdfBounds?: PDFBounds) => {
const currentCropArea = getCropArea();
const newCropArea = { ...currentCropArea, ...partialCropArea };
setCropArea(newCropArea, pdfBounds);
},
[getCropArea, setCropArea],
);
// Enhanced validation that considers PDF bounds
const validateParameters = useCallback(
(pdfBounds?: PDFBounds): boolean => {
return baseHook.validateParameters() && isCropAreaValid(pdfBounds);
},
[baseHook, isCropAreaValid],
);
// Override updateParameter to ensure positive values
const updateParameter = useCallback(
<K extends keyof CropParameters>(
parameter: K,
value: CropParameters[K],
) => {
if (isRectangle(value)) {
value.x = Math.max(0.1, value.x); // Minimum 0.1 point
value.x = Math.max(0.1, value.y); // Minimum 0.1 point
value.width = Math.max(0, value.width); // Minimum 0 point
value.height = Math.max(0, value.height); // Minimum 0 point
}
baseHook.updateParameter(parameter, value);
},
[baseHook],
);
return {
...baseHook,
updateParameter,
validateParameters: () => validateParameters(),
setCropArea,
getCropArea,
resetToFullPDF,
isCropAreaValid,
isFullPDFCrop,
updateCropAreaConstrained,
};
};
@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
type ToolOperationConfig,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { EditTableOfContentsParameters } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters";
import { serializeBookmarkNodes } from "@app/utils/editTableOfContents";
const buildFormData = (
parameters: EditTableOfContentsParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("replaceExisting", String(parameters.replaceExisting));
formData.append(
"bookmarkData",
JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)),
);
return formData;
};
export const editTableOfContentsOperationConfig: ToolOperationConfig<EditTableOfContentsParameters> =
{
toolType: ToolType.singleFile,
operationType: "editTableOfContents",
endpoint: "/api/v1/general/edit-table-of-contents",
buildFormData,
};
export const useEditTableOfContentsOperation = () => {
const { t } = useTranslation();
return useToolOperation<EditTableOfContentsParameters>({
...editTableOfContentsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"editTableOfContents.error.failed",
"Failed to update the table of contents",
),
),
});
};
@@ -0,0 +1,43 @@
import { useCallback } from "react";
import {
useBaseParameters,
type BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
import { BookmarkNode } from "@app/utils/editTableOfContents";
export interface EditTableOfContentsParameters {
replaceExisting: boolean;
bookmarks: BookmarkNode[];
}
export interface EditTableOfContentsParametersHook extends BaseParametersHook<EditTableOfContentsParameters> {
setBookmarks: (bookmarks: BookmarkNode[]) => void;
}
const defaultParameters: EditTableOfContentsParameters = {
replaceExisting: true,
bookmarks: [],
};
export const useEditTableOfContentsParameters =
(): EditTableOfContentsParametersHook => {
const base = useBaseParameters<EditTableOfContentsParameters>({
defaultParameters,
endpointName: "edit-table-of-contents",
});
const setBookmarks = useCallback(
(bookmarks: BookmarkNode[]) => {
base.setParameters((prev) => ({
...prev,
bookmarks,
}));
},
[base.setParameters],
);
return {
...base,
setBookmarks,
};
};
@@ -0,0 +1,58 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ExtractImagesParameters,
defaultParameters,
} from "@app/hooks/tools/extractImages/useExtractImagesParameters";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
// 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;
};
// Static configuration object (without response handler - will be added in hook)
export const extractImagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildExtractImagesFormData,
operationType: "extractImages",
endpoint: "/api/v1/misc/extract-images",
defaultParameters,
} as const;
export const useExtractImagesOperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
// Response handler that respects auto-unzip preferences
const responseHandler = useCallback(
async (blob: Blob, _originalFiles: File[]): Promise<File[]> => {
// Extract images returns a ZIP file - use preference-aware extraction
return await extractZipFiles(blob);
},
[extractZipFiles],
);
return useToolOperation<ExtractImagesParameters>({
...extractImagesOperationConfig,
responseHandler,
getErrorMessage: createStandardErrorHandler(
t(
"extractImages.error.failed",
"An error occurred while extracting images from the PDF.",
),
),
});
};
@@ -0,0 +1,19 @@
import { useBaseParameters } from "@app/hooks/tools/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,84 @@
import apiClient from "@app/services/apiClient";
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
CustomProcessorResult,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ExtractPagesParameters,
defaultParameters,
} from "@app/hooks/tools/extractPages/useExtractPagesParameters";
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
// Convert advanced page selection expression into CSV of explicit one-based page numbers
async function resolveSelectionToCsv(
expression: string,
file: File,
): Promise<string> {
// Load PDF to determine max pages
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
try {
const maxPages = pdf.numPages;
const pages = parseSelection(expression || "", maxPages);
return pages.join(",");
} finally {
pdfWorkerManager.destroyDocument(pdf);
}
}
export const extractPagesOperationConfig = {
toolType: ToolType.custom,
operationType: "extractPages",
customProcessor: async (
parameters: ExtractPagesParameters,
files: File[],
): Promise<CustomProcessorResult> => {
const outputs: File[] = [];
for (const file of files) {
// Resolve selection into CSV acceptable by backend
const csv = await resolveSelectionToCsv(parameters.pageNumbers, file);
const formData = new FormData();
formData.append("fileInput", file);
formData.append("pageNumbers", csv);
const response = await apiClient.post(
"/api/v1/general/rearrange-pages",
formData,
{ responseType: "blob" },
);
// Name output file with suffix
const base = (file.name || "document.pdf").replace(/\.[^.]+$/, "");
const outName = `${base}_extracted_pages.pdf`;
const outFile = new File([response.data], outName, {
type: "application/pdf",
});
outputs.push(outFile);
}
return {
files: outputs,
consumedAllInputs: false,
};
},
defaultParameters,
} as const;
export const useExtractPagesOperation = () => {
const { t } = useTranslation();
return useToolOperation<ExtractPagesParameters>({
...extractPagesOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("extractPages.error.failed", "Failed to extract pages"),
),
});
};
@@ -0,0 +1,24 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface ExtractPagesParameters extends BaseParameters {
pageNumbers: string;
}
export const defaultParameters: ExtractPagesParameters = {
pageNumbers: "",
};
export type ExtractPagesParametersHook =
BaseParametersHook<ExtractPagesParameters>;
export const useExtractPagesParameters = (): ExtractPagesParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "rearrange-pages",
validateFn: (p) => (p.pageNumbers || "").trim().length > 0,
});
};
@@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
FlattenParameters,
defaultParameters,
} from "@app/hooks/tools/flatten/useFlattenParameters";
// Static function that can be used by both the hook and automation executor
export const buildFlattenFormData = (
parameters: FlattenParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("flattenOnlyForms", parameters.flattenOnlyForms.toString());
if (parameters.renderDpi != null) {
formData.append("renderDpi", parameters.renderDpi.toString());
}
return formData;
};
// Static configuration object
export const flattenOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildFlattenFormData,
operationType: "flatten",
endpoint: "/api/v1/misc/flatten",
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useFlattenOperation = () => {
const { t } = useTranslation();
return useToolOperation<FlattenParameters>({
...flattenOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("flatten.error.failed", "An error occurred while flattening the PDF."),
),
});
};
@@ -0,0 +1,24 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface FlattenParameters extends BaseParameters {
flattenOnlyForms: boolean;
renderDpi?: number;
}
export const defaultParameters: FlattenParameters = {
flattenOnlyForms: false,
renderDpi: undefined,
};
export type FlattenParametersHook = BaseParametersHook<FlattenParameters>;
export const useFlattenParameters = (): FlattenParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "flatten",
});
};
@@ -0,0 +1,202 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { useFileContext } from "@app/contexts/file/fileHooks";
import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
import type { StirlingFile } from "@app/types/fileContext";
import { extractErrorMessage } from "@app/utils/toolErrorHandler";
import { PdfInfoReportEntry, INFO_JSON_FILENAME } from "@app/types/getPdfInfo";
import type { GetPdfInfoParameters } from "@app/hooks/tools/getPdfInfo/useGetPdfInfoParameters";
export interface GetPdfInfoOperationHook extends ToolOperationHook<GetPdfInfoParameters> {
results: PdfInfoReportEntry[];
}
export const useGetPdfInfoOperation = (): GetPdfInfoOperationHook => {
const { t } = useTranslation();
const { selectors } = useFileContext();
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState("");
const [results, setResults] = useState<PdfInfoReportEntry[]>([]);
const cancelRequested = useRef(false);
const previousUrl = useRef<string | null>(null);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
cancelRequested.current = false;
setResults([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename("");
setStatus("");
setErrorMessage(null);
}, [cleanupDownloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const executeOperation = useCallback(
async (_params: GetPdfInfoParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t("noFileSelected", "No file loaded"));
return;
}
cancelRequested.current = false;
setIsLoading(true);
setStatus(t("getPdfInfo.processing", "Extracting information..."));
setErrorMessage(null);
setResults([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename("");
try {
const aggregated: PdfInfoReportEntry[] = [];
const generatedAt = Date.now();
for (const file of selectedFiles) {
if (cancelRequested.current) break;
const formData = new FormData();
formData.append("fileInput", file);
try {
const response = await apiClient.post(
"/api/v1/security/get-info-on-pdf",
formData,
);
const stub = selectors.getStirlingFileStub(file.fileId);
const entry: PdfInfoReportEntry = {
fileId: file.fileId,
fileName: file.name,
fileSize: file.size ?? null,
lastModified: file.lastModified ?? null,
thumbnailUrl: stub?.thumbnailUrl ?? null,
data: response.data ?? {},
error: null,
summaryGeneratedAt: generatedAt,
};
aggregated.push(entry);
} catch (error) {
const stub = selectors.getStirlingFileStub(file.fileId);
aggregated.push({
fileId: file.fileId,
fileName: file.name,
fileSize: file.size ?? null,
lastModified: file.lastModified ?? null,
thumbnailUrl: stub?.thumbnailUrl ?? null,
data: {},
error: extractErrorMessage(error),
summaryGeneratedAt: generatedAt,
});
}
}
if (!cancelRequested.current) {
setResults(aggregated);
if (aggregated.length > 0) {
// Build V1-compatible JSON: use backend payloads directly.
const payloads = aggregated
.filter((e) => !e.error)
.map((e) => e.data);
const content = payloads.length === 1 ? payloads[0] : payloads;
const json = JSON.stringify(content, null, 2);
const resultFile = new File([json], INFO_JSON_FILENAME, {
type: "application/json",
});
setFiles([resultFile]);
}
const anyError = aggregated.some((item) => item.error);
if (anyError) {
setErrorMessage(
t(
"getPdfInfo.error.partial",
"Some files could not be processed.",
),
);
}
setStatus(t("getPdfInfo.status.complete", "Extraction complete"));
}
} catch (e) {
console.error("[getPdfInfo] unexpected failure", e);
setErrorMessage(
t(
"getPdfInfo.error.unexpected",
"Unexpected error during extraction.",
),
);
} finally {
setIsLoading(false);
}
},
[cleanupDownloadUrl, selectors, t],
);
const cancelOperation = useCallback(() => {
if (isLoading) {
cancelRequested.current = true;
setIsLoading(false);
setStatus(t("operationCancelled", "Operation cancelled"));
}
}, [isLoading, t]);
const undoOperation = useCallback(async () => {
resetResults();
}, [resetResults]);
useEffect(() => {
return () => {
cleanupDownloadUrl();
};
}, [cleanupDownloadUrl]);
return useMemo<GetPdfInfoOperationHook>(
() => ({
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
results,
}),
[
cancelOperation,
clearError,
downloadFilename,
downloadUrl,
errorMessage,
executeOperation,
files,
isLoading,
resetResults,
results,
status,
],
);
};
@@ -0,0 +1,20 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface GetPdfInfoParameters extends BaseParameters {
// No parameters needed
}
export const defaultParameters: GetPdfInfoParameters = {};
export type GetPdfInfoParametersHook = BaseParametersHook<GetPdfInfoParameters>;
export const useGetPdfInfoParameters = (): GetPdfInfoParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "get-info-on-pdf",
});
};
@@ -0,0 +1,131 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useMergeOperation } from "@app/hooks/tools/merge/useMergeOperation";
import type { MergeParameters } from "@app/hooks/tools/merge/useMergeParameters";
// Mock the useToolOperation hook
vi.mock("../shared/useToolOperation", async () => {
const actual = await vi.importActual("../shared/useToolOperation");
return {
...actual,
useToolOperation: vi.fn(),
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: mockT }),
}));
// Mock the error handler
vi.mock("../../../utils/toolErrorHandler", () => ({
createStandardErrorHandler: vi.fn(() => "error-handler-function"),
}));
// Import the mocked function
import {
MultiFileToolOperationConfig,
ToolOperationHook,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
describe("useMergeOperation", () => {
const mockUseToolOperation = vi.mocked(useToolOperation<MergeParameters>);
const getToolConfig = () =>
mockUseToolOperation.mock
.calls[0][0] as MultiFileToolOperationConfig<MergeParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: "",
isLoading: false,
errorMessage: null,
status: "",
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: function (): Promise<void> {
throw new Error("Function not implemented.");
},
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test("should build FormData correctly", () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockFiles = [
new File(["content1"], "file1.pdf", { type: "application/pdf" }),
new File(["content2"], "file2.pdf", { type: "application/pdf" }),
];
const parameters: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: false,
};
const formData = config.buildFormData(parameters, mockFiles);
// Verify files are appended
expect(formData.getAll("fileInput")).toHaveLength(2);
expect(formData.getAll("fileInput")[0]).toBe(mockFiles[0]);
expect(formData.getAll("fileInput")[1]).toBe(mockFiles[1]);
// Verify parameters are appended correctly
expect(formData.get("sortType")).toBe("orderProvided");
expect(formData.get("removeCertSign")).toBe("true");
expect(formData.get("generateToc")).toBe("false");
});
test("should return the hook result from useToolOperation", () => {
const { result } = renderHook(() => useMergeOperation());
expect(result.current).toBe(mockToolOperationReturn);
});
test("should use correct translation keys for error handling", () => {
renderHook(() => useMergeOperation());
expect(mockT).toHaveBeenCalledWith(
"merge.error.failed",
"An error occurred while merging the PDFs.",
);
});
test("should build FormData with different parameter combinations", () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockFiles = [
new File(["test"], "test.pdf", { type: "application/pdf" }),
];
// Test case 1: All options disabled
const params1: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
};
const formData1 = config.buildFormData(params1, mockFiles);
expect(formData1.get("removeCertSign")).toBe("false");
expect(formData1.get("generateToc")).toBe("false");
// Test case 2: All options enabled
const params2: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: true,
};
const formData2 = config.buildFormData(params2, mockFiles);
expect(formData2.get("removeCertSign")).toBe("true");
expect(formData2.get("generateToc")).toBe("true");
});
});
@@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
MergeParameters,
defaultParameters,
} from "@app/hooks/tools/merge/useMergeParameters";
const buildFormData = (
parameters: MergeParameters,
files: File[],
): FormData => {
const formData = new FormData();
files.forEach((file) => {
formData.append("fileInput", file);
});
// Provide stable client file IDs (align with files order)
const clientIds: string[] = files.map((f: any) =>
String((f as any).fileId || f.name),
);
formData.append("clientFileIds", JSON.stringify(clientIds));
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
formData.append(
"removeCertSign",
(parameters.removeDigitalSignature ?? false).toString(),
);
formData.append(
"generateToc",
(parameters.generateTableOfContents ?? false).toString(),
);
return formData;
};
// Operation configuration for automation
export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
toolType: ToolType.multiFile,
buildFormData,
operationType: "merge",
endpoint: "/api/v1/general/merge-pdfs",
filePrefix: "merged_",
defaultParameters,
};
export const useMergeOperation = () => {
const { t } = useTranslation();
return useToolOperation<MergeParameters>({
...mergeOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("merge.error.failed", "An error occurred while merging the PDFs."),
),
});
};
@@ -0,0 +1,71 @@
import { describe, expect, test } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useMergeParameters,
defaultParameters,
} from "@app/hooks/tools/merge/useMergeParameters";
describe("useMergeParameters", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useMergeParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: "removeDigitalSignature" as const, value: true },
{ paramName: "removeDigitalSignature" as const, value: false },
{ paramName: "generateTableOfContents" as const, value: true },
{ paramName: "generateTableOfContents" as const, value: false },
])("should update parameter $paramName to $value", ({ paramName, value }) => {
const { result } = renderHook(() => useMergeParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test("should reset parameters to defaults", () => {
const { result } = renderHook(() => useMergeParameters());
// First, change some parameters
act(() => {
result.current.updateParameter("removeDigitalSignature", true);
result.current.updateParameter("generateTableOfContents", true);
});
expect(result.current.parameters.removeDigitalSignature).toBe(true);
expect(result.current.parameters.generateTableOfContents).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test("should validate parameters correctly - always returns true", () => {
const { result } = renderHook(() => useMergeParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Change parameters and validate again
act(() => {
result.current.updateParameter("removeDigitalSignature", true);
result.current.updateParameter("generateTableOfContents", true);
});
expect(result.current.validateParameters()).toBe(true);
// Reset and validate again
act(() => {
result.current.resetParameters();
});
expect(result.current.validateParameters()).toBe(true);
});
});
@@ -0,0 +1,24 @@
import { BaseParameters } from "@app/types/parameters";
import {
BaseParametersHook,
useBaseParameters,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface MergeParameters extends BaseParameters {
removeDigitalSignature: boolean;
generateTableOfContents: boolean;
}
export const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
};
export type MergeParametersHook = BaseParametersHook<MergeParameters>;
export const useMergeParameters = (): MergeParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "merge-pdfs",
});
};
@@ -0,0 +1,160 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
OCRParameters,
defaultParameters,
} from "@app/hooks/tools/ocr/useOCRParameters";
import {
useToolOperation,
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
// Helper: get MIME type based on file extension
function getMimeType(filename: string): string {
const ext = filename.toLowerCase().split(".").pop();
switch (ext) {
case "pdf":
return "application/pdf";
case "txt":
return "text/plain";
case "zip":
return "application/zip";
default:
return "application/octet-stream";
}
}
// Lightweight ZIP extractor (keep or replace with a shared util if you have one)
async function extractZipFile(zipBlob: Blob): Promise<File[]> {
const JSZip = await import("jszip");
const zip = new JSZip.default();
const zipContent = await zip.loadAsync(await zipBlob.arrayBuffer());
const out: File[] = [];
for (const [filename, file] of Object.entries(zipContent.files)) {
if (!file.dir) {
const content = await file.async("blob");
out.push(new File([content], filename, { type: getMimeType(filename) }));
}
}
return out;
}
// Helper: strip extension
function stripExt(name: string): string {
const i = name.lastIndexOf(".");
return i > 0 ? name.slice(0, i) : name;
}
// Static function that can be used by both the hook and automation executor
export const buildOCRFormData = (
parameters: OCRParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
parameters.languages.forEach((lang) => formData.append("languages", lang));
formData.append("ocrType", parameters.ocrType);
formData.append("ocrRenderType", parameters.ocrRenderType);
const options = parameters.additionalOptions || [];
formData.append("sidecar", options.includes("sidecar").toString());
formData.append("deskew", options.includes("deskew").toString());
formData.append("clean", options.includes("clean").toString());
formData.append("cleanFinal", options.includes("cleanFinal").toString());
formData.append(
"removeImagesAfter",
options.includes("removeImagesAfter").toString(),
);
return formData;
};
// Static response handler for OCR - can be used by automation executor
export const ocrResponseHandler = async (
blob: Blob,
originalFiles: File[],
extractZipFiles: (blob: Blob) => Promise<File[]>,
): Promise<File[]> => {
const headBuf = await blob.slice(0, 8).arrayBuffer();
const head = new TextDecoder().decode(new Uint8Array(headBuf));
// ZIP: sidecar or multi-asset output
if (head.startsWith("PK")) {
const base = stripExt(originalFiles[0].name);
try {
const extractedFiles = await extractZipFiles(blob);
if (extractedFiles.length > 0) return extractedFiles;
} catch {
/* ignore and try local extractor */
}
try {
const local = await extractZipFile(blob); // local fallback
if (local.length > 0) return local;
} catch {
/* fall through */
}
return [new File([blob], `ocr_${base}.zip`, { type: "application/zip" })];
}
// Not a PDF: surface error details if present
if (!head.startsWith("%PDF")) {
const textBuf = await blob.slice(0, 1024).arrayBuffer();
const text = new TextDecoder().decode(new Uint8Array(textBuf));
if (/error|exception|html/i.test(text)) {
if (text.includes("OCR tools") && text.includes("not installed")) {
throw new Error(
"OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.",
);
}
const title =
text.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1] ||
text.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ||
"Unknown error";
throw new Error(`OCR service error: ${title}`);
}
throw new Error(`Response is not a valid PDF. Header: "${head}"`);
}
const originalName = originalFiles[0].name;
return [new File([blob], originalName, { type: "application/pdf" })];
};
// Static configuration object (without t function dependencies)
export const ocrOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildOCRFormData,
operationType: "ocr",
endpoint: "/api/v1/misc/ocr-pdf",
defaultParameters,
} as const;
export const useOCROperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
// OCR-specific parsing: ZIP (sidecar) vs PDF vs HTML error
const responseHandler = useCallback(
async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
// extractZipFiles from useToolResources already returns File[] directly
const simpleExtractZipFiles = extractZipFiles;
return ocrResponseHandler(blob, originalFiles, simpleExtractZipFiles);
},
[extractZipFiles],
);
const ocrConfig: ToolOperationConfig<OCRParameters> = {
...ocrOperationConfig,
responseHandler,
getErrorMessage: (error) =>
error.message?.includes("OCR tools") &&
error.message?.includes("not installed")
? "OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually."
: createStandardErrorHandler(
t("ocr.error.failed", "OCR operation failed"),
)(error),
};
return useToolOperation(ocrConfig);
};
@@ -0,0 +1,32 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface OCRParameters extends BaseParameters {
languages: string[];
ocrType: string;
ocrRenderType: string;
additionalOptions: string[];
}
export type OCRParametersHook = BaseParametersHook<OCRParameters>;
export const defaultParameters: OCRParameters = {
languages: [],
ocrType: "skip-text",
ocrRenderType: "hocr",
additionalOptions: [],
};
export const useOCRParameters = (): OCRParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "ocr-pdf",
validateFn: (params) => {
// At minimum, we need at least one language selected
return params.languages.length > 0;
},
});
};
@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
type ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { type OverlayPdfsParameters } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
const buildFormData = (
parameters: OverlayPdfsParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Overlay files
for (const overlay of parameters.overlayFiles || []) {
formData.append("overlayFiles", overlay);
}
// Mode and position
formData.append("overlayMode", parameters.overlayMode);
formData.append("overlayPosition", String(parameters.overlayPosition));
// Counts (only relevant for FixedRepeatOverlay, server accepts repeated 'counts' fields)
if (parameters.overlayMode === "FixedRepeatOverlay") {
for (const count of parameters.counts || []) {
formData.append("counts", String(count));
}
}
return formData;
};
export const overlayPdfsOperationConfig: ToolOperationConfig<OverlayPdfsParameters> =
{
toolType: ToolType.singleFile,
buildFormData,
operationType: "overlayPdfs",
endpoint: "/api/v1/general/overlay-pdfs",
};
export const useOverlayPdfsOperation = () => {
const { t } = useTranslation();
return useToolOperation<OverlayPdfsParameters>({
...overlayPdfsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"overlay-pdfs.error.failed",
"An error occurred while overlaying PDFs.",
),
),
});
};
@@ -0,0 +1,61 @@
import { useCallback } from "react";
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
type BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export type OverlayMode =
| "SequentialOverlay"
| "InterleavedOverlay"
| "FixedRepeatOverlay";
export interface OverlayPdfsParameters extends BaseParameters {
overlayFiles: File[];
overlayMode: OverlayMode;
overlayPosition: 0 | 1;
counts: number[];
}
export const defaultParameters: OverlayPdfsParameters = {
overlayFiles: [],
overlayMode: "SequentialOverlay",
overlayPosition: 0,
counts: [],
};
export type OverlayPdfsParametersHook =
BaseParametersHook<OverlayPdfsParameters>;
export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => {
const base = useBaseParameters<OverlayPdfsParameters>({
defaultParameters,
endpointName: "overlay-pdfs",
validateFn: (params) => {
if (!params.overlayFiles || params.overlayFiles.length === 0)
return false;
if (params.overlayMode === "FixedRepeatOverlay") {
if (
!params.counts ||
params.counts.length !== params.overlayFiles.length
)
return false;
if (params.counts.some((c) => !Number.isFinite(c) || c <= 0))
return false;
}
return true;
},
});
// Overlay files are chosen independently of the base file selection, so they
// must survive the parameter reset that fires when the workbench selection
// transitions from 0 → 1+ files. Only mode/position/counts are reset.
const resetParameters = useCallback(() => {
base.setParameters((prev) => ({
...defaultParameters,
overlayFiles: prev.overlayFiles,
}));
}, [base.setParameters]);
return { ...base, resetParameters };
};
@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
PageLayoutParameters,
defaultParameters,
} from "@app/hooks/tools/pageLayout/usePageLayoutParameters";
export const buildPageLayoutFormData = (
parameters: PageLayoutParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("mode", String(parameters.mode));
formData.append("pagesPerSheet", String(parameters.pagesPerSheet));
formData.append("rows", String(parameters.rows));
formData.append("cols", String(parameters.cols));
formData.append("orientation", String(parameters.orientation));
formData.append("arrangement", String(parameters.arrangement));
formData.append("readingDirection", String(parameters.readingDirection));
formData.append("innerMargin", String(parameters.innerMargin ?? 0));
formData.append("topMargin", String(parameters.topMargin ?? 0));
formData.append("bottomMargin", String(parameters.bottomMargin ?? 0));
formData.append("leftMargin", String(parameters.leftMargin ?? 0));
formData.append("rightMargin", String(parameters.rightMargin ?? 0));
formData.append("addBorder", String(parameters.addBorder));
formData.append("borderWidth", String(parameters.borderWidth ?? 1));
return formData;
};
export const pageLayoutOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildPageLayoutFormData,
operationType: "pageLayout",
endpoint: "/api/v1/general/multi-page-layout",
defaultParameters,
} as const;
export const usePageLayoutOperation = () => {
const { t } = useTranslation();
return useToolOperation<PageLayoutParameters>({
...pageLayoutOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"pageLayout.error.failed",
"An error occurred while creating the multi-page layout.",
),
),
});
};
@@ -0,0 +1,80 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface PageLayoutParameters extends BaseParameters {
mode: "DEFAULT" | "CUSTOM";
pagesPerSheet: number;
rows: number;
cols: number;
orientation: "PORTRAIT" | "LANDSCAPE";
arrangement: "BY_COLUMNS" | "BY_ROWS";
readingDirection: "LTR" | "RTL";
innerMargin?: number;
topMargin?: number;
bottomMargin?: number;
leftMargin?: number;
rightMargin?: number;
addBorder: boolean;
borderWidth?: number;
}
export const defaultParameters: PageLayoutParameters = {
mode: "DEFAULT",
pagesPerSheet: 4,
rows: 1,
cols: 1,
orientation: "PORTRAIT",
arrangement: "BY_COLUMNS",
readingDirection: "LTR",
addBorder: false,
innerMargin: 0,
topMargin: 0,
bottomMargin: 0,
leftMargin: 0,
rightMargin: 0,
borderWidth: 1,
};
export type PageLayoutParametersHook = BaseParametersHook<PageLayoutParameters>;
export const usePageLayoutParameters = (): PageLayoutParametersHook => {
return useBaseParameters<PageLayoutParameters>({
defaultParameters,
endpointName: "multi-page-layout",
validateFn: (params) => {
const cols =
params.mode === "DEFAULT"
? Math.ceil(Math.sqrt(params.pagesPerSheet))
: params.cols;
const rows =
params.mode === "DEFAULT"
? Math.ceil(params.pagesPerSheet / cols)
: params.rows;
if (cols <= 0 || rows <= 0) return false;
const pageWidth = params.orientation === "PORTRAIT" ? 595.28 : 841.89;
const pageHeight = params.orientation === "PORTRAIT" ? 841.89 : 595.28;
const left = params.leftMargin ?? 0;
const right = params.rightMargin ?? 0;
const top = params.topMargin ?? 0;
const bottom = params.bottomMargin ?? 0;
const inner = params.innerMargin ?? 0;
// Reject impossible outer margins first.
if (left + right >= pageWidth) return false;
if (top + bottom >= pageHeight) return false;
const cellWidth = (pageWidth - left - right) / cols;
const cellHeight = (pageHeight - top - bottom) / rows;
const innerWidth = cellWidth - 2 * inner;
const innerHeight = cellHeight - 2 * inner;
return innerWidth > 0 && innerHeight > 0;
},
});
};
@@ -0,0 +1,164 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import {
buildRedactFormData,
redactOperationConfig,
useRedactOperation,
} from "@app/hooks/tools/redact/useRedactOperation";
import {
defaultParameters,
RedactParameters,
} from "@app/hooks/tools/redact/useRedactParameters";
// Mock the useToolOperation hook
vi.mock("../shared/useToolOperation", async () => {
const actual = await vi.importActual("../shared/useToolOperation"); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn(),
};
});
// Mock the translation hook
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: vi.fn((_key: string, fallback: string) => fallback),
}),
}));
// Mock the error handler utility
vi.mock("../../../utils/toolErrorHandler", () => ({
createStandardErrorHandler: vi.fn(() => vi.fn()),
}));
describe("buildRedactFormData", () => {
const mockFile = new File(["test content"], "test.pdf", {
type: "application/pdf",
});
test("should build form data for automatic mode", () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: "automatic",
wordsToRedact: ["Confidential", "Secret"],
useRegex: true,
wholeWordSearch: true,
redactColor: "#FF0000",
customPadding: 0.5,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get("fileInput")).toBe(mockFile);
expect(formData.get("listOfText")).toBe("Confidential\nSecret");
expect(formData.get("useRegex")).toBe("true");
expect(formData.get("wholeWordSearch")).toBe("true");
expect(formData.get("redactColor")).toBe("FF0000"); // Hash should be removed
expect(formData.get("customPadding")).toBe("0.5");
expect(formData.get("convertPDFToImage")).toBe("false");
});
test("should handle empty words array", () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: "automatic",
wordsToRedact: [],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get("listOfText")).toBe("");
});
test("should join multiple words with newlines", () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: "automatic",
wordsToRedact: ["Word1", "Word2", "Word3"],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get("listOfText")).toBe("Word1\nWord2\nWord3");
});
test.each([
{
description: "remove hash from redact color",
redactColor: "#123456",
expected: "123456",
},
{
description: "handle redact color without hash",
redactColor: "ABCDEF",
expected: "ABCDEF",
},
])("should $description", ({ redactColor, expected }) => {
const parameters: RedactParameters = {
...defaultParameters,
mode: "automatic",
redactColor,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get("redactColor")).toBe(expected);
});
test("should convert boolean parameters to strings", () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: "automatic",
useRegex: false,
wholeWordSearch: true,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get("useRegex")).toBe("false");
expect(formData.get("wholeWordSearch")).toBe("true");
expect(formData.get("convertPDFToImage")).toBe("false");
});
test("should return empty form data for manual mode (handled client-side)", () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: "manual",
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get("fileInput")).toBeNull();
});
});
describe("useRedactOperation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should call useToolOperation with correct configuration", async () => {
const { useToolOperation } =
await import("@app/hooks/tools/shared/useToolOperation");
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
expect(mockUseToolOperation).toHaveBeenCalledWith({
...redactOperationConfig,
getErrorMessage: expect.any(Function),
});
});
test("should provide error handler to useToolOperation", async () => {
const { useToolOperation } =
await import("@app/hooks/tools/shared/useToolOperation");
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
const callArgs = mockUseToolOperation.mock.calls[0][0];
expect(typeof callArgs.getErrorMessage).toBe("function");
});
});
@@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RedactParameters,
defaultParameters,
} from "@app/hooks/tools/redact/useRedactParameters";
// Static configuration that can be used by both the hook and automation executor
export const buildRedactFormData = (
parameters: RedactParameters,
file: File,
): FormData => {
const formData = new FormData();
// For automatic mode we hit the backend and need full payload
if (parameters.mode === "automatic") {
formData.append("fileInput", file);
// Convert array to newline-separated string as expected by backend
formData.append("listOfText", parameters.wordsToRedact.join("\n"));
formData.append("useRegex", parameters.useRegex.toString());
formData.append("wholeWordSearch", parameters.wholeWordSearch.toString());
formData.append("redactColor", parameters.redactColor.replace("#", ""));
formData.append("customPadding", parameters.customPadding.toString());
formData.append(
"convertPDFToImage",
parameters.convertPDFToImage.toString(),
);
} else {
// Manual redaction uses EmbedPDF in-viewer; we don't call the API.
// Return an empty formData to satisfy shared interfaces without throwing.
}
return formData;
};
// Static configuration object
export const redactOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRedactFormData,
operationType: "redact",
endpoint: (parameters: RedactParameters) => {
if (parameters.mode === "automatic") {
return "/api/v1/security/auto-redact";
}
// Manual redaction is handled by EmbedPDF in the viewer; no endpoint call.
return "";
},
defaultParameters,
} as const;
export const useRedactOperation = () => {
const { t } = useTranslation();
return useToolOperation<RedactParameters>({
...redactOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("redact.error.failed", "An error occurred while redacting the PDF."),
),
});
};
@@ -0,0 +1,162 @@
import { describe, expect, test } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useRedactParameters,
defaultParameters,
} from "@app/hooks/tools/redact/useRedactParameters";
describe("useRedactParameters", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useRedactParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: "mode" as const, value: "manual" as const },
{ paramName: "wordsToRedact" as const, value: ["word1", "word2"] },
{ paramName: "useRegex" as const, value: true },
{ paramName: "wholeWordSearch" as const, value: true },
{ paramName: "redactColor" as const, value: "#FF0000" },
{ paramName: "customPadding" as const, value: 0.5 },
{ paramName: "convertPDFToImage" as const, value: false },
])("should update parameter $paramName", ({ paramName, value }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toStrictEqual(value);
});
test("should reset parameters to defaults", () => {
const { result } = renderHook(() => useRedactParameters());
// Modify some parameters
act(() => {
result.current.updateParameter("mode", "manual");
result.current.updateParameter("wordsToRedact", ["test"]);
result.current.updateParameter("useRegex", true);
});
// Reset parameters
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
describe("validation", () => {
test.each([
{
description:
"validate when wordsToRedact has non-empty words in automatic mode",
wordsToRedact: ["word1", "word2"],
expected: true,
},
{
description:
"not validate when wordsToRedact is empty in automatic mode",
wordsToRedact: [],
expected: false,
},
{
description:
"not validate when wordsToRedact contains only empty strings in automatic mode",
wordsToRedact: ["", " ", ""],
expected: false,
},
{
description:
"validate when wordsToRedact contains at least one non-empty word in automatic mode",
wordsToRedact: ["", "valid", " "],
expected: true,
},
])("should $description", ({ wordsToRedact, expected }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter("mode", "automatic");
result.current.updateParameter("wordsToRedact", wordsToRedact);
});
expect(result.current.validateParameters()).toBe(expected);
});
test("should not validate in manual mode (not implemented)", () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter("mode", "manual");
});
expect(result.current.validateParameters()).toBe(false);
});
});
describe("endpoint handling", () => {
test("should return correct endpoint for automatic mode", () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter("mode", "automatic");
});
expect(result.current.getEndpointName()).toBe(
"/api/v1/security/auto-redact",
);
});
test("should return empty endpoint for manual mode (handled client-side)", () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter("mode", "manual");
});
expect(result.current.getEndpointName()).toBe("");
});
});
test("should maintain parameter state across updates", () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter("redactColor", "#FF0000");
result.current.updateParameter("customPadding", 0.5);
result.current.updateParameter("wordsToRedact", ["word1"]);
});
// All parameters should be updated
expect(result.current.parameters.redactColor).toBe("#FF0000");
expect(result.current.parameters.customPadding).toBe(0.5);
expect(result.current.parameters.wordsToRedact).toEqual(["word1"]);
// Other parameters should remain at defaults
expect(result.current.parameters.mode).toBe("automatic");
expect(result.current.parameters.useRegex).toBe(false);
expect(result.current.parameters.wholeWordSearch).toBe(false);
expect(result.current.parameters.convertPDFToImage).toBe(true);
});
test("should handle array parameter updates correctly", () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter("wordsToRedact", ["initial"]);
});
expect(result.current.parameters.wordsToRedact).toEqual(["initial"]);
act(() => {
result.current.updateParameter("wordsToRedact", ["updated", "multiple"]);
});
expect(result.current.parameters.wordsToRedact).toEqual([
"updated",
"multiple",
]);
});
});
@@ -0,0 +1,54 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export type RedactMode = "automatic" | "manual";
export interface RedactParameters extends BaseParameters {
mode: RedactMode;
// Automatic redaction parameters
wordsToRedact: string[];
useRegex: boolean;
wholeWordSearch: boolean;
redactColor: string;
customPadding: number;
convertPDFToImage: boolean;
}
export const defaultParameters: RedactParameters = {
mode: "automatic",
wordsToRedact: [],
useRegex: false,
wholeWordSearch: false,
redactColor: "#000000",
customPadding: 0.1,
convertPDFToImage: true,
};
export type RedactParametersHook = BaseParametersHook<RedactParameters>;
export const useRedactParameters = (): RedactParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: (params) => {
if (params.mode === "automatic") {
return "/api/v1/security/auto-redact";
}
// Manual redaction handled client-side (validation prevents this path)
return "";
},
validateFn: (params) => {
if (params.mode === "automatic") {
return (
params.wordsToRedact.length > 0 &&
params.wordsToRedact.some((word) => word.trim().length > 0)
);
}
// Manual mode is not yet supported via this flow
return false;
},
});
};
@@ -0,0 +1,100 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
CustomProcessorResult,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemoveAnnotationsParameters,
defaultParameters,
} from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters";
import {
getPdfiumModule,
openRawDocumentSafe,
closeDocAndFreeBuffer,
saveRawDocument,
} from "@app/services/pdfiumService";
// Client-side PDF processing using PDFium WASM
const removeAnnotationsProcessor = async (
_parameters: RemoveAnnotationsParameters,
files: File[],
): Promise<CustomProcessorResult> => {
const processedFiles: File[] = [];
for (const file of files) {
try {
const m = await getPdfiumModule();
const fileArrayBuffer = await file.arrayBuffer();
const docPtr = await openRawDocumentSafe(fileArrayBuffer);
try {
const pageCount = m.FPDF_GetPageCount(docPtr);
for (let i = 0; i < pageCount; i++) {
const pagePtr = m.FPDF_LoadPage(docPtr, i);
if (!pagePtr) continue;
// Remove all annotations from the page (iterate backward)
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
for (let j = annotCount - 1; j >= 0; j--) {
try {
m.FPDFPage_RemoveAnnot(pagePtr, j);
} catch (err) {
console.warn(
`Failed to remove annotation ${j} on page ${i + 1}:`,
err,
);
}
}
m.FPDF_ClosePage(pagePtr);
}
const outBytes = await saveRawDocument(docPtr);
const processedFile = new File([outBytes], file.name, {
type: "application/pdf",
});
processedFiles.push(processedFile);
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
} catch (error) {
console.error("Error processing file:", file.name, error);
throw new Error(
`Failed to process ${file.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
{
cause: error,
},
);
}
}
return {
files: processedFiles,
consumedAllInputs: false,
};
};
// Static configuration object
export const removeAnnotationsOperationConfig = {
toolType: ToolType.custom,
operationType: "removeAnnotations",
customProcessor: removeAnnotationsProcessor,
defaultParameters,
} as const;
export const useRemoveAnnotationsOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemoveAnnotationsParameters>({
...removeAnnotationsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"removeAnnotations.error.failed",
"An error occurred while removing annotations from the PDF.",
),
),
});
};
@@ -0,0 +1,13 @@
import { useBaseParameters } from "@app/hooks/tools/shared/useBaseParameters";
export type RemoveAnnotationsParameters = Record<string, never>;
export const defaultParameters: RemoveAnnotationsParameters = {};
export const useRemoveAnnotationsParameters = () => {
return useBaseParameters<RemoveAnnotationsParameters>({
defaultParameters,
endpointName: "remove-annotations", // Not used for client-side processing, but required by base hook
validateFn: () => true, // No parameters to validate
});
};
@@ -0,0 +1,54 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemoveBlanksParameters,
defaultParameters,
} from "@app/hooks/tools/removeBlanks/useRemoveBlanksParameters";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
export const buildRemoveBlanksFormData = (
parameters: RemoveBlanksParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("threshold", String(parameters.threshold));
formData.append("whitePercent", String(parameters.whitePercent));
// Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP
return formData;
};
export const removeBlanksOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveBlanksFormData,
operationType: "removeBlanks",
endpoint: "/api/v1/misc/remove-blanks",
defaultParameters,
} as const satisfies ToolOperationConfig<RemoveBlanksParameters>;
export const useRemoveBlanksOperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
const responseHandler = useCallback(
async (blob: Blob): Promise<File[]> => {
// Backend always returns a ZIP file containing the processed PDFs
return await extractZipFiles(blob);
},
[extractZipFiles],
);
return useToolOperation<RemoveBlanksParameters>({
...removeBlanksOperationConfig,
responseHandler,
getErrorMessage: createStandardErrorHandler(
t("removeBlanks.error.failed", "Failed to remove blank pages"),
),
});
};
@@ -0,0 +1,32 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface RemoveBlanksParameters extends BaseParameters {
threshold: number; // 0-255
whitePercent: number; // 0.1-100
includeBlankPages: boolean; // whether to include detected blank pages in output
}
export const defaultParameters: RemoveBlanksParameters = {
threshold: 10,
whitePercent: 99.9,
includeBlankPages: false,
};
export type RemoveBlanksParametersHook =
BaseParametersHook<RemoveBlanksParameters>;
export const useRemoveBlanksParameters = (): RemoveBlanksParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "remove-blanks",
validateFn: (p) =>
p.threshold >= 0 &&
p.threshold <= 255 &&
p.whitePercent > 0 &&
p.whitePercent <= 100,
});
};
@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemoveCertificateSignParameters,
defaultParameters,
} from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters";
// Static function that can be used by both the hook and automation executor
export const buildRemoveCertificateSignFormData = (
_parameters: RemoveCertificateSignParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
// Static configuration object
export const removeCertificateSignOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveCertificateSignFormData,
operationType: "removeCertSign",
endpoint: "/api/v1/security/remove-cert-sign",
defaultParameters,
} as const;
export const useRemoveCertificateSignOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemoveCertificateSignParameters>({
...removeCertificateSignOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"removeCertSign.error.failed",
"An error occurred while removing certificate signatures.",
),
),
});
};
@@ -0,0 +1,24 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface RemoveCertificateSignParameters extends BaseParameters {
// Extends BaseParameters - ready for future parameter additions if needed
}
export const defaultParameters: RemoveCertificateSignParameters = {
// No parameters needed
};
export type RemoveCertificateSignParametersHook =
BaseParametersHook<RemoveCertificateSignParameters>;
export const useRemoveCertificateSignParameters =
(): RemoveCertificateSignParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "remove-cert-sign",
});
};
@@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import type { RemoveImageParameters } from "@app/hooks/tools/removeImage/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,16 @@
import { useBaseParameters } from "@app/hooks/tools/shared/useBaseParameters";
import type { BaseParametersHook } from "@app/hooks/tools/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,42 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemovePagesParameters,
defaultParameters,
} from "@app/hooks/tools/removePages/useRemovePagesParameters";
// import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
export const buildRemovePagesFormData = (
parameters: RemovePagesParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
const cleaned = parameters.pageNumbers.replace(/\s+/g, "");
formData.append("pageNumbers", cleaned);
return formData;
};
export const removePagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePagesFormData,
operationType: "removePages",
endpoint: "/api/v1/general/remove-pages",
defaultParameters,
} as const satisfies ToolOperationConfig<RemovePagesParameters>;
export const useRemovePagesOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemovePagesParameters>({
...removePagesOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("removePages.error.failed", "Failed to remove pages"),
),
});
};
@@ -0,0 +1,25 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
import { validatePageNumbers } from "@app/utils/pageSelection";
export interface RemovePagesParameters extends BaseParameters {
pageNumbers: string; // comma-separated page numbers or ranges (e.g., "1,3,5-8")
}
export const defaultParameters: RemovePagesParameters = {
pageNumbers: "",
};
export type RemovePagesParametersHook =
BaseParametersHook<RemovePagesParameters>;
export const useRemovePagesParameters = (): RemovePagesParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "remove-pages",
validateFn: (p) => validatePageNumbers(p.pageNumbers),
});
};
@@ -0,0 +1,15 @@
import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
/**
* Builds FormData for remove password API request.
* Separated from operation config to avoid circular dependencies with FileContext.
*/
export const buildRemovePasswordFormData = (
parameters: RemovePasswordParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
return formData;
};
@@ -0,0 +1,119 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useRemovePasswordOperation } from "@app/hooks/tools/removePassword/useRemovePasswordOperation";
import type { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
vi.mock("../shared/useToolOperation", async () => {
const actual = await vi.importActual("../shared/useToolOperation"); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn(),
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: mockT }),
}));
// Mock the error handler
vi.mock("../../../utils/toolErrorHandler", () => ({
createStandardErrorHandler: vi.fn(() => "error-handler-function"),
}));
// Import the mocked function
import {
SingleFileToolOperationConfig,
ToolOperationHook,
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
describe("useRemovePasswordOperation", () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = () =>
mockUseToolOperation.mock
.calls[0][0] as SingleFileToolOperationConfig<RemovePasswordParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: "",
isLoading: false,
errorMessage: null,
status: "",
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test.each([
{
description: "with valid password",
password: "test-password",
},
{
description: "with complex password",
password: "C0mpl3x@P@ssw0rd!",
},
{
description: "with single character password",
password: "a",
},
])("should create form data correctly $description", ({ password }) => {
renderHook(() => useRemovePasswordOperation());
const callArgs = getToolConfig();
const buildFormData = callArgs.buildFormData;
const testParameters: RemovePasswordParameters = {
password,
};
const testFile = new File(["test content"], "test.pdf", {
type: "application/pdf",
});
const formData = buildFormData(testParameters, testFile as any);
// Verify the form data contains the file
expect(formData.get("fileInput")).toBe(testFile);
// Verify password parameter
expect(formData.get("password")).toBe(password);
});
test("should use correct translation for error messages", () => {
renderHook(() => useRemovePasswordOperation());
expect(mockT).toHaveBeenCalledWith(
"removePassword.error.failed",
"An error occurred while removing the password from the PDF.",
);
});
test.each([
{ property: "toolType" as const, expectedValue: ToolType.singleFile },
{
property: "endpoint" as const,
expectedValue: "/api/v1/security/remove-password",
},
{ property: "operationType" as const, expectedValue: "removePassword" },
])("should configure $property correctly", ({ property, expectedValue }) => {
renderHook(() => useRemovePasswordOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});
@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemovePasswordParameters,
defaultParameters,
} from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
import { buildRemovePasswordFormData } from "@app/hooks/tools/removePassword/buildRemovePasswordFormData";
// Re-export for backwards compatibility with any other imports
export { buildRemovePasswordFormData };
// Static configuration object
export const removePasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePasswordFormData,
operationType: "removePassword",
endpoint: "/api/v1/security/remove-password",
defaultParameters,
} as const;
export const useRemovePasswordOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemovePasswordParameters>({
...removePasswordOperationConfig,
getErrorMessage: createStandardErrorHandler(
t(
"removePassword.error.failed",
"An error occurred while removing the password from the PDF.",
),
),
});
};
@@ -0,0 +1,87 @@
import { describe, expect, test } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useRemovePasswordParameters,
defaultParameters,
} from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
describe("useRemovePasswordParameters", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useRemovePasswordParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test("should update password parameter", () => {
const { result } = renderHook(() => useRemovePasswordParameters());
act(() => {
result.current.updateParameter("password", "test-password");
});
expect(result.current.parameters.password).toBe("test-password");
});
test("should reset parameters to defaults", () => {
const { result } = renderHook(() => useRemovePasswordParameters());
// First, change the password
act(() => {
result.current.updateParameter("password", "test-password");
});
expect(result.current.parameters.password).toBe("test-password");
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test("should return correct endpoint name", () => {
const { result } = renderHook(() => useRemovePasswordParameters());
expect(result.current.getEndpointName()).toBe("remove-password");
});
test.each([
{
description: "with valid password",
password: "valid-password",
expectedValid: true,
},
{
description: "with empty password",
password: "",
expectedValid: false,
},
{
description: "with whitespace only password",
password: " \t ",
expectedValid: true,
},
{
description: "with password containing special characters",
password: "p@ssw0rd!",
expectedValid: true,
},
{
description: "with single character password",
password: "a",
expectedValid: true,
},
])(
"should validate parameters correctly $description",
({ password, expectedValid }) => {
const { result } = renderHook(() => useRemovePasswordParameters());
act(() => {
result.current.updateParameter("password", password);
});
expect(result.current.validateParameters()).toBe(expectedValid);
},
);
});
@@ -0,0 +1,26 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface RemovePasswordParameters extends BaseParameters {
password: string;
}
export type RemovePasswordParametersHook =
BaseParametersHook<RemovePasswordParameters>;
export const defaultParameters: RemovePasswordParameters = {
password: "",
};
export const useRemovePasswordParameters = (): RemovePasswordParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "remove-password",
validateFn: (params) => {
return params.password !== "";
},
});
};
@@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import {
ToolOperationConfig,
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { ReorganizePagesParameters } from "@app/hooks/tools/reorganizePages/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,40 @@
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,40 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RepairParameters,
defaultParameters,
} from "@app/hooks/tools/repair/useRepairParameters";
// Static function that can be used by both the hook and automation executor
export const buildRepairFormData = (
_parameters: RepairParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
// Static configuration object
export const repairOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRepairFormData,
operationType: "repair",
endpoint: "/api/v1/misc/repair",
defaultParameters,
} as const;
export const useRepairOperation = () => {
const { t } = useTranslation();
return useToolOperation<RepairParameters>({
...repairOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("repair.error.failed", "An error occurred while repairing the PDF."),
),
});
};
@@ -0,0 +1,23 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface RepairParameters extends BaseParameters {
// Extends BaseParameters - ready for future parameter additions if needed
}
export const defaultParameters: RepairParameters = {
// No parameters needed
};
export type RepairParametersHook = BaseParametersHook<RepairParameters>;
export const useRepairParameters = (): RepairParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "repair",
// validateFn: optional custom validation if needed in future
});
};
@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ReplaceColorParameters,
defaultParameters,
} from "@app/hooks/tools/replaceColor/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 colour replacement.",
),
),
});
};
@@ -0,0 +1,41 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface ReplaceColorParameters extends BaseParameters {
replaceAndInvertOption:
| "HIGH_CONTRAST_COLOR"
| "CUSTOM_COLOR"
| "FULL_INVERSION"
| "COLOR_SPACE_CONVERSION";
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,116 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useRotateOperation } from "@app/hooks/tools/rotate/useRotateOperation";
import type { RotateParameters } from "@app/hooks/tools/rotate/useRotateParameters";
// Mock the useToolOperation hook
vi.mock("../shared/useToolOperation", async () => {
const actual = await vi.importActual("../shared/useToolOperation");
return {
...actual,
useToolOperation: vi.fn(),
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: mockT }),
}));
// Mock the error handler
vi.mock("../../../utils/toolErrorHandler", () => ({
createStandardErrorHandler: vi.fn(() => "error-handler-function"),
}));
// Import the mocked function
import {
SingleFileToolOperationConfig,
ToolOperationHook,
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
describe("useRotateOperation", () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = () =>
mockUseToolOperation.mock
.calls[0][0] as SingleFileToolOperationConfig<RotateParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: "",
isLoading: false,
errorMessage: null,
status: "",
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test.each([
{ angle: 0, expectedNormalized: 0 },
{ angle: 90, expectedNormalized: 90 },
{ angle: 180, expectedNormalized: 180 },
{ angle: 270, expectedNormalized: 270 },
{ angle: 360, expectedNormalized: 0 },
{ angle: -90, expectedNormalized: 270 },
{ angle: -180, expectedNormalized: 180 },
{ angle: -270, expectedNormalized: 90 },
{ angle: 450, expectedNormalized: 90 },
])(
"should create form data correctly with angle $angle (normalized to $expectedNormalized)",
({ angle, expectedNormalized }) => {
renderHook(() => useRotateOperation());
const callArgs = getToolConfig();
const testParameters: RotateParameters = { angle };
const testFile = new File(["test content"], "test.pdf", {
type: "application/pdf",
});
const formData = callArgs.buildFormData(testParameters, testFile);
// Verify the form data contains the file
expect(formData.get("fileInput")).toBe(testFile);
// Verify angle parameter is normalized for backend
expect(formData.get("angle")).toBe(expectedNormalized.toString());
},
);
test("should use correct translation for error messages", () => {
renderHook(() => useRotateOperation());
expect(mockT).toHaveBeenCalledWith(
"rotate.error.failed",
"An error occurred while rotating the PDF.",
);
});
test.each([
{ property: "toolType" as const, expectedValue: ToolType.singleFile },
{
property: "endpoint" as const,
expectedValue: "/api/v1/general/rotate-pdf",
},
{ property: "operationType" as const, expectedValue: "rotate" },
])("should configure $property correctly", ({ property, expectedValue }) => {
renderHook(() => useRotateOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});
@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RotateParameters,
defaultParameters,
normalizeAngle,
} from "@app/hooks/tools/rotate/useRotateParameters";
// Static configuration that can be used by both the hook and automation executor
export const buildRotateFormData = (
parameters: RotateParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Normalize angle for backend (0, 90, 180, 270)
formData.append("angle", normalizeAngle(parameters.angle).toString());
return formData;
};
// Static configuration object
export const rotateOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRotateFormData,
operationType: "rotate",
endpoint: "/api/v1/general/rotate-pdf",
defaultParameters,
} as const;
export const useRotateOperation = () => {
const { t } = useTranslation();
return useToolOperation<RotateParameters>({
...rotateOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("rotate.error.failed", "An error occurred while rotating the PDF."),
),
});
};
@@ -0,0 +1,164 @@
import { describe, expect, test } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useRotateParameters,
defaultParameters,
normalizeAngle,
} from "@app/hooks/tools/rotate/useRotateParameters";
describe("useRotateParameters", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.parameters.angle).toBe(0);
expect(result.current.hasRotation).toBe(false);
});
test("should validate parameters correctly", () => {
const { result } = renderHook(() => useRotateParameters());
// Default should be valid
expect(result.current.validateParameters()).toBe(true);
// Set invalid angle
act(() => {
result.current.updateParameter("angle", 45);
});
expect(result.current.validateParameters()).toBe(false);
// Set valid angle
act(() => {
result.current.updateParameter("angle", 90);
});
expect(result.current.validateParameters()).toBe(true);
});
test("should rotate clockwise correctly", () => {
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(90);
expect(result.current.hasRotation).toBe(true);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(180);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(270);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(360);
expect(normalizeAngle(result.current.parameters.angle)).toBe(0);
expect(result.current.hasRotation).toBe(false);
});
test("should rotate anticlockwise correctly", () => {
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-90);
expect(result.current.hasRotation).toBe(true);
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-180);
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-270);
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-360);
expect(normalizeAngle(result.current.parameters.angle)).toBe(0);
expect(result.current.hasRotation).toBe(false);
});
test("should normalize angles correctly", () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.normalizeAngle(360)).toBe(0);
expect(result.current.normalizeAngle(450)).toBe(90);
expect(result.current.normalizeAngle(-90)).toBe(270);
expect(result.current.normalizeAngle(-180)).toBe(180);
});
test("should reset parameters correctly", () => {
const { result } = renderHook(() => useRotateParameters());
// Set some rotation
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(90);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(180);
// Reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.hasRotation).toBe(false);
});
test("should update parameters", () => {
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.updateParameter("angle", 450);
});
expect(result.current.parameters.angle).toBe(450);
expect(normalizeAngle(result.current.parameters.angle)).toBe(90);
act(() => {
result.current.updateParameter("angle", -90);
});
expect(result.current.parameters.angle).toBe(-90);
expect(normalizeAngle(result.current.parameters.angle)).toBe(270);
});
test("should return correct endpoint name", () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.getEndpointName()).toBe("rotate-pdf");
});
test("should detect rotation state correctly", () => {
const { result } = renderHook(() => useRotateParameters());
// Initially no rotation
expect(result.current.hasRotation).toBe(false);
// After rotation
act(() => {
result.current.rotateClockwise();
});
expect(result.current.hasRotation).toBe(true);
// After full rotation (360 degrees) - 3 more clicks to complete 360°
for (let i = 0; i < 3; i++) {
act(() => {
result.current.rotateClockwise();
});
}
expect(result.current.hasRotation).toBe(false);
});
});
@@ -0,0 +1,73 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
import { useMemo, useCallback } from "react";
// Normalize angle to number between 0 and 359
export const normalizeAngle = (angle: number): number => {
return ((angle % 360) + 360) % 360;
};
export interface RotateParameters extends BaseParameters {
angle: number; // Current rotation angle (0, 90, 180, 270)
}
export const defaultParameters: RotateParameters = {
angle: 0,
};
export type RotateParametersHook = BaseParametersHook<RotateParameters> & {
rotateClockwise: () => void;
rotateAnticlockwise: () => void;
hasRotation: boolean;
normalizeAngle: (angle: number) => number;
};
export const useRotateParameters = (): RotateParametersHook => {
const baseHook = useBaseParameters({
defaultParameters,
endpointName: "rotate-pdf",
validateFn: (params) => {
// Angle must be a multiple of 90
return params.angle % 90 === 0;
},
});
// Rotate clockwise by 90 degrees
const rotateClockwise = useCallback(() => {
baseHook.updateParameter("angle", baseHook.parameters.angle + 90);
}, [baseHook]);
// Rotate anticlockwise by 90 degrees
const rotateAnticlockwise = useCallback(() => {
baseHook.updateParameter("angle", baseHook.parameters.angle - 90);
}, [baseHook]);
// Check if rotation will actually change the document
const hasRotation = useMemo(() => {
const normalized = normalizeAngle(baseHook.parameters.angle);
return normalized !== 0;
}, [baseHook.parameters.angle, normalizeAngle]);
// Override updateParameter - no longer normalize angles here
const updateParameter = useCallback(
<K extends keyof RotateParameters>(
parameter: K,
value: RotateParameters[K],
) => {
baseHook.updateParameter(parameter, value);
},
[baseHook],
);
return {
...baseHook,
updateParameter,
rotateClockwise,
rotateAnticlockwise,
hasRotation,
normalizeAngle,
};
};
@@ -0,0 +1,62 @@
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
SanitizeParameters,
defaultParameters,
} from "@app/hooks/tools/sanitize/useSanitizeParameters";
// Static function that can be used by both the hook and automation executor
export const buildSanitizeFormData = (
parameters: SanitizeParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add parameters
formData.append(
"removeJavaScript",
(parameters.removeJavaScript ?? false).toString(),
);
formData.append(
"removeEmbeddedFiles",
(parameters.removeEmbeddedFiles ?? false).toString(),
);
formData.append(
"removeXMPMetadata",
(parameters.removeXMPMetadata ?? false).toString(),
);
formData.append(
"removeMetadata",
(parameters.removeMetadata ?? false).toString(),
);
formData.append("removeLinks", (parameters.removeLinks ?? false).toString());
formData.append("removeFonts", (parameters.removeFonts ?? false).toString());
return formData;
};
// Static configuration object
export const sanitizeOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSanitizeFormData,
operationType: "sanitize",
endpoint: "/api/v1/security/sanitize-pdf",
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useSanitizeOperation = () => {
const { t } = useTranslation();
return useToolOperation<SanitizeParameters>({
...sanitizeOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("sanitize.error.failed", "An error occurred while sanitising the PDF."),
),
});
};
@@ -0,0 +1,95 @@
import { describe, expect, test } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
defaultParameters,
useSanitizeParameters,
} from "@app/hooks/tools/sanitize/useSanitizeParameters";
describe("useSanitizeParameters", () => {
test("should initialize with default parameters", () => {
const { result } = renderHook(() => useSanitizeParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test("should update individual parameters", () => {
const { result } = renderHook(() => useSanitizeParameters());
act(() => {
result.current.updateParameter("removeXMPMetadata", true);
});
expect(result.current.parameters).toStrictEqual({
...defaultParameters, // Other params unchanged
removeXMPMetadata: true,
});
});
test("should reset parameters to defaults", () => {
const { result } = renderHook(() => useSanitizeParameters());
// First, change some parameters
act(() => {
result.current.updateParameter("removeXMPMetadata", true);
result.current.updateParameter("removeJavaScript", false);
});
expect(result.current.parameters.removeXMPMetadata).toBe(true);
expect(result.current.parameters.removeJavaScript).toBe(false);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test("should return correct endpoint name", () => {
const { result } = renderHook(() => useSanitizeParameters());
expect(result.current.getEndpointName()).toBe("sanitize-pdf");
});
test("should validate parameters correctly", () => {
const { result } = renderHook(() => useSanitizeParameters());
// Default state should be valid (has removeJavaScript and removeEmbeddedFiles enabled)
expect(result.current.validateParameters()).toBe(true);
// Turn off all parameters - should be invalid
act(() => {
result.current.updateParameter("removeJavaScript", false);
result.current.updateParameter("removeEmbeddedFiles", false);
});
expect(result.current.validateParameters()).toBe(false);
// Turn on one parameter - should be valid again
act(() => {
result.current.updateParameter("removeLinks", true);
});
expect(result.current.validateParameters()).toBe(true);
});
test("should handle all parameter types correctly", () => {
const { result } = renderHook(() => useSanitizeParameters());
const allParameters = Object.keys(
defaultParameters,
) as (keyof typeof defaultParameters)[];
allParameters.forEach((param) => {
act(() => {
result.current.updateParameter(param, true);
});
expect(result.current.parameters[param]).toBe(true);
act(() => {
result.current.updateParameter(param, false);
});
expect(result.current.parameters[param]).toBe(false);
});
});
});
@@ -0,0 +1,35 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface SanitizeParameters extends BaseParameters {
removeJavaScript: boolean;
removeEmbeddedFiles: boolean;
removeXMPMetadata: boolean;
removeMetadata: boolean;
removeLinks: boolean;
removeFonts: boolean;
}
export const defaultParameters: SanitizeParameters = {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
};
export type SanitizeParametersHook = BaseParametersHook<SanitizeParameters>;
export const useSanitizeParameters = (): SanitizeParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "sanitize-pdf",
validateFn: (params) => {
return Object.values(params).some((value) => value === true);
},
});
};
@@ -0,0 +1,84 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
ToolType,
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ScannerImageSplitParameters,
defaultParameters,
} from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitParameters";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
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;
};
// Static configuration object
export const scannerImageSplitOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildScannerImageSplitFormData,
operationType: "scannerImageSplit",
endpoint: "/api/v1/misc/extract-image-scans",
defaultParameters,
} as const;
export const useScannerImageSplitOperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
// Custom response handler that extracts ZIP files containing images
// Can't add to exported config because it requires access to the hook so must be part of the hook
const responseHandler = useCallback(
async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
try {
// Scanner image split returns ZIP files with multiple images
const extractedFiles = await extractZipFiles(blob);
// If extraction succeeded and returned files, use them
if (extractedFiles.length > 0) {
return extractedFiles;
}
} catch (error) {
console.warn(
"Failed to extract as ZIP, treating as single file:",
error,
);
}
// Fallback: treat as single file (PNG image)
const inputFileName = originalFiles[0]?.name || "document";
const baseFileName = inputFileName.replace(/\.[^.]+$/, "");
const singleFile = new File([blob], `${baseFileName}.png`, {
type: "image/png",
});
return [singleFile];
},
[extractZipFiles],
);
const config: ToolOperationConfig<ScannerImageSplitParameters> = {
...scannerImageSplitOperationConfig,
responseHandler,
getErrorMessage: createStandardErrorHandler(
t(
"scannerImageSplit.error.failed",
"An error occurred while extracting image scans.",
),
),
};
return useToolOperation(config);
};
@@ -0,0 +1,36 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/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;
},
});
};
@@ -0,0 +1,63 @@
import {
StirlingFile,
FileId,
StirlingFileStub,
createStirlingFile,
ProcessedFileMetadata,
createNewStirlingFileStub,
} from "@app/types/fileContext";
/**
* Builds parallel inputFileIds and inputStirlingFileStubs arrays from the valid input files.
* Falls back to a fresh stub when the file is not found in the current context state
* (e.g. it was removed between operation start and this point).
*/
export function buildInputTracking(
validFiles: StirlingFile[],
selectors: {
getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
},
): { inputFileIds: FileId[]; inputStirlingFileStubs: StirlingFileStub[] } {
const inputFileIds: FileId[] = [];
const inputStirlingFileStubs: StirlingFileStub[] = [];
for (const file of validFiles) {
const fileId = file.fileId;
const record = selectors.getStirlingFileStub(fileId);
if (record) {
inputFileIds.push(fileId);
inputStirlingFileStubs.push(record);
} else {
console.warn(`No file stub found for file: ${file.name}`);
inputFileIds.push(fileId);
inputStirlingFileStubs.push(createNewStirlingFileStub(file, fileId));
}
}
return { inputFileIds, inputStirlingFileStubs };
}
/**
* Creates parallel outputStirlingFileStubs and outputStirlingFiles arrays from processed files.
* The stubFactory determines how each stub is constructed (child version vs fresh root).
*/
export function buildOutputPairs(
processedFiles: File[],
thumbnails: string[],
metadataArray: Array<ProcessedFileMetadata | undefined>,
stubFactory: (
file: File,
thumbnail: string,
metadata: ProcessedFileMetadata | undefined,
index: number,
) => StirlingFileStub,
): {
outputStirlingFileStubs: StirlingFileStub[];
outputStirlingFiles: StirlingFile[];
} {
const outputStirlingFileStubs = processedFiles.map((file, index) =>
stubFactory(file, thumbnails[index], metadataArray[index], index),
);
const outputStirlingFiles = processedFiles.map((file, index) =>
createStirlingFile(file, outputStirlingFileStubs[index].id),
);
return { outputStirlingFileStubs, outputStirlingFiles };
}

Some files were not shown because too many files have changed in this diff Show More