mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -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.",
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
+150
@@ -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;
|
||||
}
|
||||
+51
@@ -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.",
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
+41
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
+144
@@ -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.",
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
+252
@@ -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,
|
||||
};
|
||||
};
|
||||
+143
@@ -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);
|
||||
});
|
||||
});
|
||||
+57
@@ -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.",
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
+118
@@ -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);
|
||||
});
|
||||
});
|
||||
+38
@@ -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,
|
||||
};
|
||||
};
|
||||
+333
@@ -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,
|
||||
};
|
||||
};
|
||||
+44
@@ -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",
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
+43
@@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
+100
@@ -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.",
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
+13
@@ -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,
|
||||
});
|
||||
};
|
||||
+43
@@ -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.",
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
+24
@@ -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;
|
||||
};
|
||||
+119
@@ -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.",
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
+87
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
+84
@@ -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);
|
||||
};
|
||||
+36
@@ -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
Reference in New Issue
Block a user