Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

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

  ### Why

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

  ### What moves

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

  ### Wiring edits (40 files)

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

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

  ### Verification

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

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

  ### Test plan

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,273 @@
/**
* Unit tests for automationConverter import/export round-tripping.
*/
import { describe, test, expect } from "vitest";
import {
convertToAutomationConfig,
convertToFolderScanningConfig,
detectAutomationFormat,
parseAutomationConfigJson,
parseAutomationFile,
parseFolderScanningConfig,
} from "@app/utils/automationConverter";
import { AutomationConfig } from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
// Minimal stub registry — only the fields automationConverter actually reads.
const registry = {
merge: {
operationConfig: {
endpoint: "/api/v1/general/merge-pdfs",
},
},
compress: {
operationConfig: {
endpoint: "/api/v1/misc/compress-pdf",
},
},
ocr: {
operationConfig: {
endpoint: "/api/v1/misc/ocr-pdf",
},
},
// Dynamic endpoint — endpoint depends on parameters.
convert: {
operationConfig: {
endpoint: (params: Record<string, any>) =>
`/api/v1/convert/${params.fromExtension}-to-${params.toExtension}`,
},
},
} as unknown as Partial<ToolRegistry>;
const sampleAutomation: AutomationConfig = {
id: "auto-123",
name: "Sample",
description: "Test automation",
icon: "CompressIcon",
operations: [
{ operation: "merge", parameters: { generateToc: true } },
{ operation: "compress", parameters: { compressionLevel: 3 } },
],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
};
describe("automationConverter", () => {
describe("convertToAutomationConfig", () => {
test("strips id/createdAt/updatedAt and clones parameters", () => {
const result = convertToAutomationConfig(sampleAutomation);
expect(result).toEqual({
name: "Sample",
description: "Test automation",
icon: "CompressIcon",
operations: [
{ operation: "merge", parameters: { generateToc: true } },
{ operation: "compress", parameters: { compressionLevel: 3 } },
],
});
expect(result).not.toHaveProperty("id");
expect(result).not.toHaveProperty("createdAt");
expect(result).not.toHaveProperty("updatedAt");
// Parameters should be cloned, not the same reference.
expect(result.operations[0].parameters).not.toBe(
sampleAutomation.operations[0].parameters,
);
});
});
describe("convertToFolderScanningConfig", () => {
test("rewrites operation keys to backend endpoints and adds fileInput", () => {
const config = convertToFolderScanningConfig(sampleAutomation, registry);
expect(config.pipeline).toEqual([
{
operation: "/api/v1/general/merge-pdfs",
parameters: { generateToc: true, fileInput: "automated" },
},
{
operation: "/api/v1/misc/compress-pdf",
parameters: { compressionLevel: 3, fileInput: "automated" },
},
]);
});
test("preserves icon and description so round-tripping keeps UI metadata", () => {
const config = convertToFolderScanningConfig(sampleAutomation, registry);
expect(config.icon).toBe("CompressIcon");
expect(config.description).toBe("Test automation");
});
test("omits icon and description when not set on the source automation", () => {
const minimal: AutomationConfig = {
id: "min",
name: "Minimal",
operations: [{ operation: "merge", parameters: {} }],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
};
const config = convertToFolderScanningConfig(minimal, registry);
expect(config).not.toHaveProperty("icon");
expect(config).not.toHaveProperty("description");
});
test("falls back to operation key when no endpoint is registered", () => {
const automation: AutomationConfig = {
...sampleAutomation,
operations: [{ operation: "unknownTool", parameters: {} }],
};
const config = convertToFolderScanningConfig(automation, registry);
expect(config.pipeline[0].operation).toBe("unknownTool");
});
});
describe("detectAutomationFormat", () => {
test("detects native Automate JSON", () => {
expect(detectAutomationFormat({ operations: [] })).toBe("automate");
});
test("detects folder-scanning JSON", () => {
expect(detectAutomationFormat({ pipeline: [] })).toBe("folderScanning");
});
test("returns unknown for ambiguous or invalid input", () => {
expect(detectAutomationFormat({ pipeline: [], operations: [] })).toBe(
"unknown",
);
expect(detectAutomationFormat(null)).toBe("unknown");
expect(detectAutomationFormat("string")).toBe("unknown");
});
});
describe("parseAutomationConfigJson", () => {
test("accepts a previously exported native Automate JSON", () => {
const exported = convertToAutomationConfig(sampleAutomation);
const parsed = parseAutomationConfigJson(exported, registry);
expect(parsed.unresolvedOperations).toEqual([]);
expect(parsed.automation.name).toBe("Sample");
expect(parsed.automation.operations).toHaveLength(2);
expect(parsed.automation.operations[0].operation).toBe("merge");
// Icon must round-trip — losing it makes saved entries fall back to the
// settings cog, which historically looked like a silent picker bug.
expect(parsed.automation.icon).toBe("CompressIcon");
});
test("flags operations not present in the registry", () => {
const result = parseAutomationConfigJson(
{
name: "x",
operations: [{ operation: "notARealTool", parameters: {} }],
},
registry,
);
expect(result.unresolvedOperations).toEqual(["notARealTool"]);
});
test("throws on missing operations array", () => {
expect(() => parseAutomationConfigJson({ name: "x" }, registry)).toThrow(
/operations/,
);
});
});
describe("parseFolderScanningConfig", () => {
test("round-trips a folder-scan export back to tool IDs", () => {
const exported = convertToFolderScanningConfig(
sampleAutomation,
registry,
);
const parsed = parseFolderScanningConfig(exported, registry);
expect(parsed.unresolvedOperations).toEqual([]);
expect(parsed.automation.operations.map((o) => o.operation)).toEqual([
"merge",
"compress",
]);
// Icon survives the round trip even through the folder-scan format.
expect(parsed.automation.icon).toBe("CompressIcon");
// The export-time `fileInput: "automated"` marker must not survive import.
for (const op of parsed.automation.operations) {
expect(op.parameters).not.toHaveProperty("fileInput");
}
});
test("resolves a dynamic endpoint by replaying it with the imported parameters", () => {
const config = {
name: "Convert",
pipeline: [
{
operation: "/api/v1/convert/pdf-to-docx",
parameters: {
fromExtension: "pdf",
toExtension: "docx",
fileInput: "automated",
},
},
],
};
const parsed = parseFolderScanningConfig(config, registry);
expect(parsed.unresolvedOperations).toEqual([]);
expect(parsed.automation.operations[0].operation).toBe("convert");
expect(parsed.automation.operations[0].parameters).toEqual({
fromExtension: "pdf",
toExtension: "docx",
});
});
test("keeps unmappable endpoints verbatim and reports them", () => {
const config = {
name: "Mystery",
pipeline: [{ operation: "/api/v1/unknown/op", parameters: {} }],
};
const parsed = parseFolderScanningConfig(config, registry);
expect(parsed.unresolvedOperations).toEqual(["/api/v1/unknown/op"]);
expect(parsed.automation.operations[0].operation).toBe(
"/api/v1/unknown/op",
);
});
test("throws on missing pipeline array", () => {
expect(() => parseFolderScanningConfig({ name: "x" }, registry)).toThrow(
/pipeline/,
);
});
});
describe("parseAutomationFile", () => {
test("auto-detects Automate JSON", () => {
const text = JSON.stringify({
name: "x",
operations: [{ operation: "merge", parameters: {} }],
});
const result = parseAutomationFile(text, registry);
expect(result.format).toBe("automate");
});
test("auto-detects Folder Scanning JSON", () => {
const text = JSON.stringify({
name: "x",
pipeline: [{ operation: "/api/v1/general/merge-pdfs", parameters: {} }],
});
const result = parseAutomationFile(text, registry);
expect(result.format).toBe("folderScanning");
});
test("rejects mismatched explicit format", () => {
const text = JSON.stringify({
name: "x",
operations: [{ operation: "merge", parameters: {} }],
});
expect(() =>
parseAutomationFile(text, registry, "folderScanning"),
).toThrow();
});
test("rejects malformed JSON", () => {
expect(() => parseAutomationFile("{ not json", registry)).toThrow(
/not valid JSON/,
);
});
test("rejects unrecognized shape", () => {
expect(() =>
parseAutomationFile(JSON.stringify({ foo: "bar" }), registry),
).toThrow(/Unrecognized JSON/);
});
});
});
@@ -0,0 +1,426 @@
/**
* Utility functions for converting between automation formats.
*
* Two on-disk formats are supported:
*
* 1. **Automate JSON** (native) — mirrors {@link AutomationConfig}; the format
* used internally by the Automate tool and persisted in IndexedDB. Operation
* names are frontend tool IDs (e.g. "merge", "compress").
*
* 2. **Folder Scanning JSON** — the format consumed by the backend
* PipelineDirectoryProcessor. Operation names are full backend endpoint
* paths (e.g. "/api/v1/general/merge-pdfs").
*/
import { AutomationConfig, AutomationOperation } from "@app/types/automation";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
import { downloadFile } from "@app/services/downloadService";
import { ToolId } from "@app/types/toolId";
/**
* Pipeline configuration format used by folder scanning.
*
* `description` and `icon` are unused by the backend pipeline runner but are
* preserved so a folder-scan export can round-trip cleanly back into the
* Automate UI without losing display metadata.
*/
interface FolderScanningPipeline {
name: string;
description?: string;
icon?: string;
pipeline: Array<{
operation: string;
parameters: Record<string, any>;
}>;
_examples: {
outputDir: string;
outputFileName: string;
};
outputDir: string;
outputFileName: string;
}
/**
* Discriminated result returned by {@link parseAutomationFile}.
*/
export type ParsedAutomationImport =
| {
format: "automate";
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
}
| {
format: "folderScanning";
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
};
/**
* Sanitize a filename so it works on Windows / macOS / Linux.
*/
const sanitizeFilename = (name: string): string =>
(name || "automation").replace(/[\\/:*?"<>|]+/g, "_").trim() || "automation";
/**
* Converts an AutomationConfig to a folder scanning pipeline configuration.
*/
export function convertToFolderScanningConfig(
automation: AutomationConfig,
toolRegistry: Partial<ToolRegistry>,
): FolderScanningPipeline {
return {
name: automation.name,
...(automation.description ? { description: automation.description } : {}),
...(automation.icon ? { icon: automation.icon } : {}),
pipeline: automation.operations.map((op) => {
const toolId = op.operation as ToolId;
const toolEntry = toolRegistry[toolId];
const endpointConfig = toolEntry?.operationConfig?.endpoint;
let endpoint: string | undefined;
if (typeof endpointConfig === "string") {
endpoint = endpointConfig;
} else if (typeof endpointConfig === "function") {
try {
endpoint = endpointConfig(op.parameters);
} catch (error) {
console.warn(
`Failed to resolve dynamic endpoint for operation "${op.operation}". ` +
`This may happen if the tool requires specific parameters. ` +
`Error: ${error}`,
);
}
}
if (!endpoint) {
console.warn(
`No endpoint found for operation "${op.operation}". ` +
`This operation may fail in folder scanning. ` +
`Using operation type as fallback.`,
);
}
return {
operation: endpoint || op.operation,
parameters: {
...op.parameters,
fileInput: "automated",
},
};
}),
_examples: {
outputDir: "{outputFolder}/{folderName}",
outputFileName: "{filename}-{pipelineName}-{date}-{time}",
},
outputDir: "{outputFolder}",
outputFileName: "{filename}",
};
}
/**
* Downloads a folder scanning configuration as a JSON file.
*/
export function downloadFolderScanningConfig(
automation: AutomationConfig,
toolRegistry: Partial<ToolRegistry>,
): void {
const config = convertToFolderScanningConfig(automation, toolRegistry);
const json = JSON.stringify(config, null, 2);
const blob = new Blob([json], { type: "application/json" });
void downloadFile({
data: blob,
filename: `${sanitizeFilename(automation.name)}.folder-scan.json`,
});
}
/**
* Builds an exportable native Automate JSON object. Strips the IndexedDB
* primary key and timestamps so an imported copy looks fresh.
*/
export function convertToAutomationConfig(
automation: AutomationConfig,
): Omit<AutomationConfig, "id" | "createdAt" | "updatedAt"> {
return {
name: automation.name,
description: automation.description,
icon: automation.icon,
operations: automation.operations.map((op) => ({
operation: op.operation,
parameters: { ...(op.parameters || {}) },
})),
};
}
/**
* Downloads an automation in the native Automate JSON format. The file can be
* re-imported via {@link parseAutomationFile} to restore the automation on a
* different machine or browser profile.
*/
export function downloadAutomationConfig(automation: AutomationConfig): void {
const config = convertToAutomationConfig(automation);
const json = JSON.stringify(config, null, 2);
const blob = new Blob([json], { type: "application/json" });
void downloadFile({
data: blob,
filename: `${sanitizeFilename(automation.name)}.automate.json`,
});
}
/**
* Build an inverse map of `endpoint string` → `frontend tool ID` from the
* tool registry. Only static string endpoints are added — dynamic
* (function) endpoints are matched at parse time by replaying them.
*/
function buildEndpointToToolIdMap(
toolRegistry: Partial<ToolRegistry>,
): Map<string, ToolId> {
const map = new Map<string, ToolId>();
for (const [toolId, entry] of Object.entries(toolRegistry)) {
const endpoint = entry?.operationConfig?.endpoint;
if (typeof endpoint === "string" && !map.has(endpoint)) {
map.set(endpoint, toolId as ToolId);
}
}
return map;
}
/**
* Try every dynamic endpoint in the registry with the supplied parameters,
* returning the first tool ID whose endpoint function produces `targetEndpoint`.
*/
function findDynamicEndpointMatch(
targetEndpoint: string,
parameters: Record<string, any>,
toolRegistry: Partial<ToolRegistry>,
): ToolId | undefined {
for (const [toolId, entry] of Object.entries(toolRegistry)) {
const endpoint = entry?.operationConfig?.endpoint;
if (typeof endpoint === "function") {
try {
if (endpoint(parameters) === targetEndpoint) {
return toolId as ToolId;
}
} catch {
// Endpoint function expected different parameters — ignore.
}
}
}
return undefined;
}
const isToolIdInRegistry = (
candidate: string,
toolRegistry: Partial<ToolRegistry>,
): boolean => Object.prototype.hasOwnProperty.call(toolRegistry, candidate);
/**
* Parse a folder-scanning pipeline JSON into the native AutomationConfig
* shape. Endpoint paths are reverse-mapped to frontend tool IDs via the
* supplied registry; unmappable operations are kept verbatim and reported in
* `unresolvedOperations`.
*/
export function parseFolderScanningConfig(
raw: unknown,
toolRegistry: Partial<ToolRegistry>,
): {
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
} {
if (!raw || typeof raw !== "object") {
throw new Error("Invalid folder scanning config: expected JSON object");
}
const obj = raw as Record<string, unknown>;
const pipeline = obj.pipeline;
if (!Array.isArray(pipeline)) {
throw new Error("Invalid folder scanning config: missing 'pipeline' array");
}
const endpointMap = buildEndpointToToolIdMap(toolRegistry);
const unresolved: string[] = [];
const operations: AutomationOperation[] = pipeline.map(
(step: unknown, index: number) => {
if (!step || typeof step !== "object") {
throw new Error(
`Invalid folder scanning config: pipeline[${index}] is not an object`,
);
}
const stepObj = step as Record<string, unknown>;
const rawOperation = stepObj.operation;
if (typeof rawOperation !== "string" || rawOperation.length === 0) {
throw new Error(
`Invalid folder scanning config: pipeline[${index}].operation must be a non-empty string`,
);
}
const rawParameters = (stepObj.parameters as Record<string, any>) || {};
// Strip the export-time marker so the imported automation runs cleanly.
const { fileInput: _fileInput, ...parameters } = rawParameters;
// 1) Direct frontend-tool-id match (handles the converter's fallback when
// no endpoint could be resolved at export time).
if (isToolIdInRegistry(rawOperation, toolRegistry)) {
return { operation: rawOperation, parameters };
}
// 2) Static endpoint string match.
const staticMatch = endpointMap.get(rawOperation);
if (staticMatch) {
return { operation: staticMatch, parameters };
}
// 3) Dynamic endpoint match — replay each function endpoint with the
// parameters we have and look for a match.
const dynamicMatch = findDynamicEndpointMatch(
rawOperation,
parameters,
toolRegistry,
);
if (dynamicMatch) {
return { operation: dynamicMatch, parameters };
}
unresolved.push(rawOperation);
return { operation: rawOperation, parameters };
},
);
const name =
typeof obj.name === "string" && obj.name.length > 0
? obj.name
: "Imported Automation";
return {
automation: {
name,
description: typeof obj.description === "string" ? obj.description : "",
icon: typeof obj.icon === "string" ? obj.icon : undefined,
operations,
},
unresolvedOperations: unresolved,
};
}
/**
* Parse a native Automate JSON file (a previously-exported AutomationConfig).
* The id / createdAt / updatedAt fields are dropped — the storage layer
* regenerates them on save.
*/
export function parseAutomationConfigJson(
raw: unknown,
toolRegistry: Partial<ToolRegistry>,
): {
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
} {
if (!raw || typeof raw !== "object") {
throw new Error("Invalid automation config: expected JSON object");
}
const obj = raw as Record<string, unknown>;
const operations = obj.operations;
if (!Array.isArray(operations)) {
throw new Error("Invalid automation config: missing 'operations' array");
}
const unresolved: string[] = [];
const parsedOperations: AutomationOperation[] = operations.map(
(op: unknown, index: number) => {
if (!op || typeof op !== "object") {
throw new Error(
`Invalid automation config: operations[${index}] is not an object`,
);
}
const opObj = op as Record<string, unknown>;
const operation = opObj.operation;
if (typeof operation !== "string" || operation.length === 0) {
throw new Error(
`Invalid automation config: operations[${index}].operation must be a non-empty string`,
);
}
const parameters = (opObj.parameters as Record<string, any>) || {};
if (!isToolIdInRegistry(operation, toolRegistry)) {
unresolved.push(operation);
}
return { operation, parameters };
},
);
const name =
typeof obj.name === "string" && obj.name.length > 0
? obj.name
: "Imported Automation";
return {
automation: {
name,
description: typeof obj.description === "string" ? obj.description : "",
icon: typeof obj.icon === "string" ? obj.icon : undefined,
operations: parsedOperations,
},
unresolvedOperations: unresolved,
};
}
/**
* Heuristic format detector. Folder-scanning JSON uses a `pipeline` array;
* native Automate JSON uses an `operations` array. Both is invalid.
*/
export function detectAutomationFormat(
raw: unknown,
): "automate" | "folderScanning" | "unknown" {
if (!raw || typeof raw !== "object") return "unknown";
const obj = raw as Record<string, unknown>;
const hasPipeline = Array.isArray(obj.pipeline);
const hasOperations = Array.isArray(obj.operations);
if (hasPipeline && !hasOperations) return "folderScanning";
if (hasOperations && !hasPipeline) return "automate";
return "unknown";
}
/**
* Parse a JSON file's text content into a normalized AutomationConfig.
* Auto-detects the format unless `expectedFormat` is supplied; throws with a
* user-readable message on any structural problem.
*/
export function parseAutomationFile(
fileText: string,
toolRegistry: Partial<ToolRegistry>,
expectedFormat?: "automate" | "folderScanning",
): ParsedAutomationImport {
let raw: unknown;
try {
raw = JSON.parse(fileText);
} catch (err) {
throw new Error(`File is not valid JSON: ${(err as Error).message}`, {
cause: err,
});
}
const detected = detectAutomationFormat(raw);
const format = expectedFormat ?? detected;
if (expectedFormat && detected !== "unknown" && detected !== expectedFormat) {
throw new Error(
`Expected ${
expectedFormat === "automate"
? "Automate JSON (operations array)"
: "Folder Scanning JSON (pipeline array)"
} but file looks like ${
detected === "automate" ? "Automate JSON" : "Folder Scanning JSON"
}.`,
);
}
if (format === "automate") {
const result = parseAutomationConfigJson(raw, toolRegistry);
return { format: "automate", ...result };
}
if (format === "folderScanning") {
const result = parseFolderScanningConfig(raw, toolRegistry);
return { format: "folderScanning", ...result };
}
throw new Error(
"Unrecognized JSON shape. Expected an Automate config (operations[]) or a Folder Scanning config (pipeline[]).",
);
}
@@ -0,0 +1,268 @@
import apiClient from "@app/services/apiClient";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
import { ToolId } from "@app/types/toolId";
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
import { AutomationFileProcessor } from "@app/utils/automationFileProcessor";
import { ToolType } from "@app/hooks/tools/shared/useToolOperation";
import { processResponse } from "@app/utils/toolResponseProcessor";
/**
* Process multi-file tool response (handles ZIP or single PDF responses)
*/
const processMultiFileResponse = async (
responseData: Blob,
responseHeaders: any,
files: File[],
filePrefix: string,
preserveBackendFilename?: boolean,
): Promise<File[]> => {
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
if (
responseData.type === "application/pdf" ||
(responseHeaders && responseHeaders["content-type"] === "application/pdf")
) {
// Single PDF response - use processResponse to respect preserveBackendFilename
const processedFiles = await processResponse(
responseData,
files,
filePrefix,
undefined,
preserveBackendFilename ? responseHeaders : undefined,
);
return processedFiles;
} else {
// ZIP response
const result =
await AutomationFileProcessor.extractAutomationZipFiles(responseData);
if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors);
}
// Apply prefix to files, replacing any existing prefix
const processedFiles =
filePrefix && !preserveBackendFilename
? result.files.map((file) => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, "");
return new File([file], `${filePrefix}${nameWithoutPrefix}`, {
type: file.type,
});
})
: result.files;
return processedFiles;
}
};
/**
* Core execution function for API requests
*/
const executeApiRequest = async (
endpoint: string,
formData: FormData,
files: File[],
filePrefix: string,
preserveBackendFilename?: boolean,
): Promise<File[]> => {
const response = await apiClient.post(endpoint, formData, {
responseType: "blob",
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT,
});
return await processMultiFileResponse(
response.data,
response.headers,
files,
filePrefix,
preserveBackendFilename,
);
};
/**
* Execute single-file tool operation (processes files one at a time)
*/
const executeSingleFileOperation = async (
config: any,
parameters: any,
files: File[],
filePrefix: string,
): Promise<File[]> => {
const resultFiles: File[] = [];
for (const file of files) {
const endpoint =
typeof config.endpoint === "function"
? config.endpoint(parameters)
: config.endpoint;
const formData = (
config.buildFormData as (params: any, file: File) => FormData
)(parameters, file);
const processedFiles = await executeApiRequest(
endpoint,
formData,
[file],
filePrefix,
config.preserveBackendFilename,
);
resultFiles.push(...processedFiles);
}
return resultFiles;
};
/**
* Execute multi-file tool operation (processes all files in one request)
*/
const executeMultiFileOperation = async (
config: any,
parameters: any,
files: File[],
filePrefix: string,
): Promise<File[]> => {
const endpoint =
typeof config.endpoint === "function"
? config.endpoint(parameters)
: config.endpoint;
const formData = (
config.buildFormData as (params: any, files: File[]) => FormData
)(parameters, files);
return await executeApiRequest(
endpoint,
formData,
files,
filePrefix,
config.preserveBackendFilename,
);
};
/**
* Execute a tool operation directly without using React hooks
*/
export const executeToolOperation = async (
operationName: string,
parameters: any,
files: File[],
toolRegistry: ToolRegistry,
): Promise<File[]> => {
return executeToolOperationWithPrefix(
operationName,
parameters,
files,
toolRegistry,
AUTOMATION_CONSTANTS.FILE_PREFIX,
);
};
/**
* Execute a tool operation with custom prefix
*/
export const executeToolOperationWithPrefix = async (
operationName: string,
parameters: any,
files: File[],
toolRegistry: ToolRegistry,
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX,
): Promise<File[]> => {
const config = toolRegistry[operationName as ToolId]?.operationConfig;
if (!config) {
throw new Error(`Tool operation not supported: ${operationName}`);
}
// Merge with default parameters to ensure all required fields are present
const mergedParameters = { ...config.defaultParameters, ...parameters };
try {
// Check if tool uses custom processor (like Convert tool)
if (config.customProcessor) {
const result = await config.customProcessor(mergedParameters, files);
return result.files;
}
// Execute based on tool type
if (config.toolType === ToolType.multiFile) {
return await executeMultiFileOperation(
config,
mergedParameters,
files,
filePrefix,
);
} else {
return await executeSingleFileOperation(
config,
mergedParameters,
files,
filePrefix,
);
}
} catch (error: any) {
console.error(`${operationName} failed:`, error);
throw new Error(
`${operationName} operation failed: ${error.response?.data || error.message}`,
{
cause: error,
},
);
}
};
/**
* Execute an entire automation sequence
*/
export const executeAutomationSequence = async (
automation: any,
initialFiles: File[],
toolRegistry: ToolRegistry,
onStepStart?: (stepIndex: number, operationName: string) => void,
onStepComplete?: (stepIndex: number, resultFiles: File[]) => void,
onStepError?: (stepIndex: number, error: string) => void,
): Promise<File[]> => {
console.log(`🚀 Starting automation: ${automation.name || "Unnamed"}`);
console.log(`📁 Input: ${initialFiles.length} file(s)`);
if (!automation?.operations || automation.operations.length === 0) {
throw new Error("No operations in automation");
}
let currentFiles = [...initialFiles];
const automationPrefix = automation.name
? `${automation.name}_`
: "automated_";
for (let i = 0; i < automation.operations.length; i++) {
const operation = automation.operations[i];
console.log(
`\n📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`,
);
console.log(` Input: ${currentFiles.length} file(s)`);
try {
onStepStart?.(i, operation.operation);
const resultFiles = await executeToolOperationWithPrefix(
operation.operation,
operation.parameters || {},
currentFiles,
toolRegistry,
i === automation.operations.length - 1 ? automationPrefix : "", // Only add prefix to final step
);
console.log(
`✅ Step ${i + 1} completed: ${resultFiles.length} result files`,
);
currentFiles = resultFiles;
onStepComplete?.(i, resultFiles);
} catch (error: any) {
console.error(`❌ Step ${i + 1} failed:`, error);
onStepError?.(i, error.message);
throw error;
}
}
console.log(`\n🎉 Automation complete: ${currentFiles.length} file(s)`);
return currentFiles;
};
@@ -0,0 +1,212 @@
/**
* File processing utilities specifically for automation workflows
*/
import apiClient from "@app/services/apiClient";
import { zipFileService } from "@app/services/zipFileService";
import { ResourceManager } from "@app/utils/resourceManager";
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
export interface AutomationProcessingOptions {
timeout?: number;
responseType?: "blob" | "json";
}
export interface AutomationProcessingResult {
success: boolean;
files: File[];
errors: string[];
}
export class AutomationFileProcessor {
/**
* Check if a blob is a ZIP file by examining its header
*/
static isZipFile(blob: Blob): boolean {
// This is a simple check - in a real implementation you might want to read the first few bytes
// For now, we'll rely on the extraction attempt and fallback
return (
blob.type === "application/zip" ||
blob.type === "application/x-zip-compressed"
);
}
/**
* Extract files from a ZIP blob during automation execution, with fallback for non-ZIP files
* Extracts all file types (PDFs, images, etc.) except HTML files which stay zipped
*/
static async extractAutomationZipFiles(
blob: Blob,
): Promise<AutomationProcessingResult> {
try {
const zipFile = ResourceManager.createTimestampedFile(
blob,
AUTOMATION_CONSTANTS.RESPONSE_ZIP_PREFIX,
".zip",
"application/zip",
);
// Check if ZIP contains HTML files - if so, keep as ZIP
const containsHtml = await zipFileService.containsHtmlFiles(zipFile);
if (containsHtml) {
// HTML files should stay zipped - return ZIP as-is
return {
success: true,
files: [zipFile],
errors: [],
};
}
// Extract all files (not just PDFs) - handles images from scanner-image-split, etc.
const result = await zipFileService.extractAllFiles(zipFile);
if (!result.success || result.extractedFiles.length === 0) {
// Fallback: keep as ZIP file (might be valid ZIP with extraction issues)
return {
success: true,
files: [zipFile],
errors: [
`ZIP extraction failed, kept as ZIP: ${result.errors?.join(", ") || "Unknown error"}`,
],
};
}
return {
success: true,
files: result.extractedFiles,
errors: [],
};
} catch (error) {
console.warn(
"Failed to extract automation ZIP files, keeping as ZIP:",
error,
);
// Fallback: keep as ZIP file for next automation step to handle
const fallbackFile = ResourceManager.createTimestampedFile(
blob,
AUTOMATION_CONSTANTS.RESPONSE_ZIP_PREFIX,
".zip",
"application/zip",
);
return {
success: true,
files: [fallbackFile],
errors: [`ZIP extraction failed, kept as ZIP: ${error}`],
};
}
}
/**
* Process a single file through an automation step
*/
static async processAutomationSingleFile(
endpoint: string,
formData: FormData,
originalFileName: string,
options: AutomationProcessingOptions = {},
): Promise<AutomationProcessingResult> {
try {
const response = await apiClient.post(endpoint, formData, {
responseType: options.responseType || "blob",
timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT,
});
if (response.status !== 200) {
return {
success: false,
files: [],
errors: [
`Automation step failed - HTTP ${response.status}: ${response.statusText}`,
],
};
}
const resultFile = ResourceManager.createResultFile(
response.data,
originalFileName,
AUTOMATION_CONSTANTS.FILE_PREFIX,
);
return {
success: true,
files: [resultFile],
errors: [],
};
} catch (error: any) {
return {
success: false,
files: [],
errors: [
`Automation step failed: ${error.response?.data || error.message}`,
],
};
}
}
/**
* Process multiple files through an automation step
*/
static async processAutomationMultipleFiles(
endpoint: string,
formData: FormData,
options: AutomationProcessingOptions = {},
): Promise<AutomationProcessingResult> {
try {
const response = await apiClient.post(endpoint, formData, {
responseType: options.responseType || "blob",
timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT,
});
if (response.status !== 200) {
return {
success: false,
files: [],
errors: [
`Automation step failed - HTTP ${response.status}: ${response.statusText}`,
],
};
}
// Multi-file responses are typically ZIP files
return await this.extractAutomationZipFiles(response.data);
} catch (error: any) {
return {
success: false,
files: [],
errors: [
`Automation step failed: ${error.response?.data || error.message}`,
],
};
}
}
/**
* Build form data for automation tool operations
*/
static buildAutomationFormData(
parameters: Record<string, any>,
files: File | File[],
fileFieldName: string = "fileInput",
): FormData {
const formData = new FormData();
// Add files
if (Array.isArray(files)) {
files.forEach((file) => formData.append(fileFieldName, file));
} else {
formData.append(fileFieldName, files);
}
// Add parameters
Object.entries(parameters).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => formData.append(key, item));
} else if (value !== undefined && value !== null) {
formData.append(key, value);
}
});
return formData;
}
}
@@ -0,0 +1,46 @@
/**
* Browser identifier utility for anonymous usage tracking
* Generates and persists a unique UUID in localStorage for WAU tracking
*/
const BROWSER_ID_KEY = "stirling_browser_id";
/**
* Gets or creates a unique browser identifier
* Used for Weekly Active Users (WAU) tracking in no-login mode
*/
export function getBrowserId(): string {
try {
// Try to get existing ID from localStorage
let browserId = localStorage.getItem(BROWSER_ID_KEY);
if (!browserId) {
// Generate new UUID v4
browserId = generateUUID();
localStorage.setItem(BROWSER_ID_KEY, browserId);
}
return browserId;
} catch (error) {
// Fallback to session-based ID if localStorage is unavailable
console.warn("localStorage unavailable, using session-based ID", error);
return `session_${generateUUID()}`;
}
}
/**
* Generates a UUID v4
*/
function generateUUID(): string {
// Use crypto.randomUUID if available (modern browsers)
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback to manual UUID generation
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
@@ -0,0 +1,77 @@
## Bulk Selection Expressions
### What this does
- Lets you select pages using compact expressions instead of typing long CSV lists.
- Your input expression is preserved exactly as typed; we only expand it under the hood into concrete page numbers based on the current document's page count.
- The final selection is always deduplicated, clamped to valid page numbers, and sorted ascending.
### Basic forms
- Numbers: `5` selects page 5.
- Ranges: `3-7` selects pages 3,4,5,6,7 (inclusive). If the start is greater than the end, it is swapped automatically (e.g., `7-3``3-7`).
- Lists (OR): `1,3-5,10` selects pages 1,3,4,5,10.
You can still use the original CSV format. For example, `1,2,3,4,5` (first five pages) continues to work.
### Logical operators
- OR (union): `,` or `|` or the word `or`
- AND (intersection): `&` or the word `and`
- NOT (complement within 1..max): `!term` or `!(group)` or the word `not term` / `not (group)`
Operator precedence (from highest to lowest):
1) `!` (NOT)
2) `&` / `and` (AND)
3) `,` / `|` / `or` (OR)
Use parentheses `(...)` to override precedence where needed.
### Keywords and progressions
- Keywords (case-insensitive):
- `even`: all even pages (2, 4, 6, ...)
- `odd`: all odd pages (1, 3, 5, ...)
- Arithmetic progressions: `k n ± c`, e.g. `2n`, `3n+1`, `4n-1`
- `n` starts at 0 (CSS-style: `:nth-child`), then increases by 1 (n = 0,1,2,...). Non-positive results are discarded.
- `k` must be a positive integer (≥ 1). `c` can be any integer (including negative).
- Examples:
- `2n` → 0,2,4,6,... → becomes 2,4,6,... after discarding non-positive
- `2n-1` → -1,1,3,5,... → becomes 1,3,5,... (odd)
- `3n+1` → 1,4,7,10,13,...
All selections are automatically limited to the current document's valid page numbers `[1..maxPages]`.
### Parentheses
- Group with parentheses to control evaluation order and combine NOT with groups.
- Examples:
- `1-10 & (even, 15)` → even pages 2,4,6,8,10 (15 is outside 1-10)
- `!(1-5, odd)` → remove pages 1..5 and all odd pages; for a 10-page doc this yields 6,8,10
- `!(10-20 & !2n)` → complement of odd pages from 11..19 inside 10..20
- `(2n | 3n+1) & 1-20` → union of even numbers and 3n+1 numbers, intersected with 1..20
### Whitespace and case
- Whitespace is ignored: ` odd & 1 - 7` is valid.
- Keywords are case-insensitive: `ODD`, `Odd`, `odd` all work.
### Universe, clamping, deduplication
- The selection universe is the document's pages `[1..maxPages]`.
- Numbers outside the universe are discarded.
- Ranges are clamped to `[1..maxPages]` (e.g., `0-5``1-5`, `9-999` in a 10-page doc → `9-10`).
- Duplicates are removed; the final result is sorted ascending.
### Examples
- `1-10 & 2n & !5-7` → 2,4,8,10
- `odd` → 1,3,5,7,9,...
- `even` → 2,4,6,8,10,...
- `2n-1` → 1,3,5,7,9,...
- `3n+1` → 4,7,10,13,16,... (up to max pages)
- `1-3, 8-9` → 1,2,3,8,9
- `1-2 | 9-10 or 5` → 1,2,5,9,10
- `!(1-5)` → remove the first five pages from the universe
- `!(10-20 & !2n)` → complement of odd pages between 10 and 20
@@ -0,0 +1,269 @@
import { describe, it, expect } from "vitest";
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
describe("parseSelection", () => {
const max = 120;
it("1) parses single numbers", () => {
expect(parseSelection("5", max)).toEqual([5]);
});
it("2) parses simple range", () => {
expect(parseSelection("3-7", max)).toEqual([3, 4, 5, 6, 7]);
});
it("3) parses multiple numbers and ranges via comma OR", () => {
expect(parseSelection("1,3-5,10", max)).toEqual([1, 3, 4, 5, 10]);
});
it("4) respects bounds (clamps to 1..max and filters invalid)", () => {
expect(parseSelection("0, -2, 1-2, 9999", max)).toEqual([1, 2]);
});
it("5) supports even keyword", () => {
expect(parseSelection("even", 10)).toEqual([2, 4, 6, 8, 10]);
});
it("6) supports odd keyword", () => {
expect(parseSelection("odd", 10)).toEqual([1, 3, 5, 7, 9]);
});
it("7) supports 2n progression", () => {
expect(parseSelection("2n", 12)).toEqual([2, 4, 6, 8, 10, 12]);
});
it("8) supports kn±c progression (3n+1)", () => {
expect(parseSelection("3n+1", 10)).toEqual([1, 4, 7, 10]);
});
it("9) supports kn±c progression (4n-1)", () => {
expect(parseSelection("4n-1", 15)).toEqual([3, 7, 11, 15]);
});
it("10) supports logical AND (&) intersection", () => {
// even AND 1-10 => even numbers within 1..10
expect(parseSelection("even & 1-10", 20)).toEqual([2, 4, 6, 8, 10]);
});
it("11) supports logical OR with comma", () => {
expect(parseSelection("1-3, 8-9", 20)).toEqual([1, 2, 3, 8, 9]);
});
it("12) supports logical OR with | and word or", () => {
expect(parseSelection("1-2 | 9-10 or 5", 20)).toEqual([1, 2, 5, 9, 10]);
});
it("13) supports NOT operator !", () => {
// !1-5 within max=10 -> 6..10
expect(parseSelection("!1-5", 10)).toEqual([6, 7, 8, 9, 10]);
});
it("14) supports combination: 1-10 & 2n & !5-7", () => {
expect(parseSelection("1-10 & 2n & !5-7", 20)).toEqual([2, 4, 8, 10]);
});
it("15) preserves precedence: AND over OR", () => {
// 1-10 & even, 15 OR => ( (1-10 & even) , 15 )
expect(parseSelection("1-10 & even, 15", 20)).toEqual([2, 4, 6, 8, 10, 15]);
});
it("16) handles whitespace and case-insensitive keywords", () => {
expect(parseSelection(" OdD & 1-7 ", 10)).toEqual([1, 3, 5, 7]);
});
it("17) progression plus range: 2n | 9-11 within 12", () => {
expect(parseSelection("2n | 9-11", 12)).toEqual([
2, 4, 6, 8, 9, 10, 11, 12,
]);
});
it("18) complex: (2n-1 & 1-20) & ! (5-7)", () => {
expect(parseSelection("2n-1 & 1-20 & !5-7", 20)).toEqual([
1, 3, 9, 11, 13, 15, 17, 19,
]);
});
it("19) falls back to CSV when expression malformed", () => {
// malformed: "2x" -> fallback should treat as CSV tokens -> only 2 ignored -> result empty
expect(parseSelection("2x", 10)).toEqual([]);
// malformed middle; still fallback handles CSV bits
expect(parseSelection("1, 3-5, foo, 9", 10)).toEqual([1, 3, 4, 5, 9]);
});
it("20) clamps ranges that exceed bounds", () => {
expect(parseSelection("0-5, 9-10", 10)).toEqual([1, 2, 3, 4, 5, 9, 10]);
});
it("21) supports parentheses to override precedence", () => {
// Without parentheses: 1-10 & even, 15 => [2,4,6,8,10,15]
// With parentheses around OR: 1-10 & (even, 15) => [2,4,6,8,10]
expect(parseSelection("1-10 & (even, 15)", 20)).toEqual([2, 4, 6, 8, 10]);
});
it("22) NOT over a grouped intersection", () => {
// !(10-20 & !2n) within 1..25
// Inner: 10-20 & !2n => odd numbers from 11..19 plus 10,12,14,16,18,20 excluded
// Complement in 1..25 removes those, keeping others
const result = parseSelection("!(10-20 & !2n)", 25);
expect(result).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20, 21, 22, 23, 24, 25,
]);
});
it("23) nested parentheses with progressions", () => {
expect(parseSelection("(2n | 3n+1) & 1-20", 50)).toEqual([
1, 2, 4, 6, 7, 8, 10, 12, 13, 14, 16, 18, 19, 20,
]);
});
it("24) parentheses with NOT directly on group", () => {
expect(parseSelection("!(1-5, odd)", 10)).toEqual([6, 8, 10]);
});
it("25) whitespace within parentheses is ignored", () => {
expect(parseSelection("( 1 - 3 , 6 )", 10)).toEqual([1, 2, 3, 6]);
});
it("26) malformed missing closing parenthesis falls back to CSV", () => {
// Expression parse should fail; fallback CSV should pick numbers only
expect(parseSelection("(1-3, 6", 10)).toEqual([6]);
});
it("27) nested NOT and AND with parentheses", () => {
// !(odd & 5-9) within 1..12 => remove odd numbers 5,7,9
expect(parseSelection("!(odd & 5-9)", 12)).toEqual([
1, 2, 3, 4, 6, 8, 10, 11, 12,
]);
});
it("28) deep nesting and mixing operators", () => {
const expr = "(1-4 & 2n) , ( (5-10 & odd) & !(7) ), (3n+1 & 1-20)";
expect(parseSelection(expr, 20)).toEqual([
1, 2, 4, 5, 7, 9, 10, 13, 16, 19,
]);
});
it("31) word NOT works like ! for terms", () => {
expect(parseSelection("not 1-3", 6)).toEqual([4, 5, 6]);
});
it("32) word NOT works like ! for groups", () => {
expect(parseSelection("not (odd & 1-6)", 8)).toEqual([2, 4, 6, 7, 8]);
});
it("29) parentheses around a single term has no effect", () => {
expect(parseSelection("(even)", 8)).toEqual([2, 4, 6, 8]);
});
it("30) redundant nested parentheses", () => {
expect(parseSelection("(((1-3))), ((2n))", 6)).toEqual([1, 2, 3, 4, 6]);
});
// Additional edge cases and comprehensive coverage
it("33) handles empty input gracefully", () => {
expect(parseSelection("", 10)).toEqual([]);
expect(parseSelection(" ", 10)).toEqual([]);
});
it("34) handles zero or negative maxPages", () => {
expect(parseSelection("1-10", 0)).toEqual([]);
expect(parseSelection("1-10", -5)).toEqual([]);
});
it("35) handles large progressions efficiently", () => {
expect(parseSelection("100n", 1000)).toEqual([
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
]);
});
it("36) handles progressions with large offsets", () => {
expect(parseSelection("5n+97", 100)).toEqual([97]);
expect(parseSelection("3n-2", 10)).toEqual([1, 4, 7, 10]);
});
it("37) mixed case keywords work correctly", () => {
expect(parseSelection("EVEN & Odd", 6)).toEqual([]);
expect(parseSelection("Even OR odd", 6)).toEqual([1, 2, 3, 4, 5, 6]);
});
it("38) complex nested expressions with all operators", () => {
const expr = "(1-20 & even) | (odd & !5-15) | (3n+1 & 1-10)";
// (1-20 & even) = [2,4,6,8,10,12,14,16,18,20]
// (odd & !5-15) = odd numbers not in 5-15 = [1,3,17,19]
// (3n+1 & 1-10) = [1,4,7,10]
// Union of all = [1,2,3,4,6,7,8,10,12,14,16,17,18,19,20]
expect(parseSelection(expr, 20)).toEqual([
1, 2, 3, 4, 6, 7, 8, 10, 12, 14, 16, 17, 18, 19, 20,
]);
});
it("39) multiple NOT operators in sequence", () => {
expect(parseSelection("not not 1-5", 10)).toEqual([1, 2, 3, 4, 5]);
expect(parseSelection("!!!1-3", 10)).toEqual([4, 5, 6, 7, 8, 9, 10]);
});
it("40) edge case: single page selection", () => {
expect(parseSelection("1", 1)).toEqual([1]);
expect(parseSelection("5", 3)).toEqual([]);
});
it("41) backwards ranges are handled correctly", () => {
expect(parseSelection("10-5", 15)).toEqual([5, 6, 7, 8, 9, 10]);
});
it("42) progressions that start beyond maxPages", () => {
expect(parseSelection("10n+50", 40)).toEqual([]);
expect(parseSelection("5n+35", 40)).toEqual([35, 40]);
});
it("43) complex operator precedence with mixed syntax", () => {
// AND has higher precedence than OR
expect(parseSelection("1-3, 5-7 & even", 10)).toEqual([1, 2, 3, 6]);
expect(parseSelection("1-3 | 5-7 and even", 10)).toEqual([1, 2, 3, 6]);
});
it("44) whitespace tolerance in complex expressions", () => {
const expr1 = "1-5&even|odd&!3";
const expr2 = " 1 - 5 & even | odd & ! 3 ";
expect(parseSelection(expr1, 10)).toEqual(parseSelection(expr2, 10));
});
it("45) fallback behavior with partial valid expressions", () => {
// Should fallback and extract valid CSV parts
expect(parseSelection("1, 2-4, invalid, 7", 10)).toEqual([1, 2, 3, 4, 7]);
expect(parseSelection("1-3, @#$, 8-9", 10)).toEqual([1, 2, 3, 8, 9]);
});
it("46) progressions with k=1 (equivalent to n)", () => {
expect(parseSelection("1n", 5)).toEqual([1, 2, 3, 4, 5]);
expect(parseSelection("1n+2", 5)).toEqual([2, 3, 4, 5]);
});
it("47) very large ranges are clamped correctly", () => {
expect(parseSelection("1-999999", 10)).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
]);
// Note: -100-5 would fallback to CSV and reject -100, but 0-5 should work
expect(parseSelection("0-5", 10)).toEqual([1, 2, 3, 4, 5]);
});
it("48) multiple comma-separated ranges", () => {
expect(parseSelection("1-2, 4-5, 7-8, 10", 10)).toEqual([
1, 2, 4, 5, 7, 8, 10,
]);
});
it("49) combination of all features in one expression", () => {
const expr = "(1-10 & even) | (odd & 15-25) & !(3n+1 & 1-30) | 50n";
const result = parseSelection(expr, 100);
// This should combine: even numbers 2,4,6,8,10 with odd 15-25 excluding 3n+1 matches, plus 50n
expect(result.length).toBeGreaterThan(0);
expect(result).toContain(50);
expect(result).toContain(100);
});
it("50) stress test with deeply nested parentheses", () => {
const expr = "((((1-5)))) & ((((even)))) | ((((odd & 7-9))))";
expect(parseSelection(expr, 10)).toEqual([2, 4, 7, 9]);
});
});
@@ -0,0 +1,424 @@
// A parser that converts selection expressions (e.g., "1-10 & 2n & !50-100", "odd", "2n-1")
// into a list of page numbers within [1, maxPages].
/*
Supported grammar (case-insensitive for words):
expression := disjunction
disjunction := conjunction ( ("," | "|" | "or") conjunction )*
conjunction := unary ( ("&" | "and") unary )*
unary := ("!" unary) | ("not" unary) | primary
primary := "(" expression ")" | range | progression | keyword | number
range := number "-" number // inclusive
progression := k ["*"] "n" (("+" | "-") c)? // k >= 1, c any integer, n starts at 0
keyword := "even" | "odd"
number := digits (>= 1)
Precedence: "!" (NOT) > "&"/"and" (AND) > "," "|" "or" (OR)
Associativity: left-to-right within the same precedence level
Notes:
- Whitespace is ignored.
- The universe is [1..maxPages]. The complement operator ("!" / "not") applies within this universe.
- Out-of-bounds numbers are clamped in ranges and ignored as singletons.
- On parse failure, the parser falls back to CSV (numbers and ranges separated by commas).
Examples:
1-10 & even -> even pages between 1 and 10
!(5-7) -> all pages except 5..7
3n+1 -> 1,4,7,... (n starts at 0)
(2n | 3n+1) & 1-20 -> multiples of 2 or numbers of the form 3n+1 within 1..20
*/
export function parseSelection(input: string, maxPages: number): number[] {
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
if (clampedMax === 0) return [];
const trimmed = (input || "").trim();
if (trimmed.length === 0) return [];
try {
const parser = new ExpressionParser(trimmed, clampedMax);
const resultSet = parser.parse();
return toSortedArray(resultSet);
} catch {
// Fallback: simple CSV parser (e.g., "1,3,5-10")
return toSortedArray(parseCsvFallback(trimmed, clampedMax));
}
}
export function parseSelectionWithDiagnostics(
input: string,
maxPages: number,
options?: { strict?: boolean },
): { pages: number[]; warning?: string } {
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
if (clampedMax === 0) return { pages: [] };
const trimmed = (input || "").trim();
if (trimmed.length === 0) return { pages: [] };
try {
const parser = new ExpressionParser(trimmed, clampedMax);
const resultSet = parser.parse();
return { pages: toSortedArray(resultSet) };
} catch (err) {
if (options?.strict) {
throw err;
}
const pages = toSortedArray(parseCsvFallback(trimmed, clampedMax));
const tokens = trimmed
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const bad = tokens.find((tok) => !/^(\d+\s*-\s*\d+|\d+)$/.test(tok));
const warning = `Malformed expression${bad ? ` at: '${bad}'` : ""}. Falling back to CSV interpretation.`;
return { pages, warning };
}
}
function toSortedArray(set: Set<number>): number[] {
return Array.from(set).sort((a, b) => a - b);
}
function parseCsvFallback(input: string, max: number): Set<number> {
const result = new Set<number>();
const parts = input
.split(",")
.map((p) => p.trim())
.filter(Boolean);
for (const part of parts) {
const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/);
if (rangeMatch) {
const start = clampToRange(parseInt(rangeMatch[1], 10), 1, max);
const end = clampToRange(parseInt(rangeMatch[2], 10), 1, max);
if (Number.isFinite(start) && Number.isFinite(end)) {
const [lo, hi] = start <= end ? [start, end] : [end, start];
for (let i = lo; i <= hi; i++) result.add(i);
}
continue;
}
// Accept only pure positive integers (no signs, no letters)
if (/^\d+$/.test(part)) {
const n = parseInt(part, 10);
if (Number.isFinite(n) && n >= 1 && n <= max) result.add(n);
}
}
return result;
}
function clampToRange(v: number, min: number, max: number): number {
if (!Number.isFinite(v)) return NaN as unknown as number;
return Math.min(Math.max(v, min), max);
}
class ExpressionParser {
private readonly src: string;
private readonly max: number;
private idx: number = 0;
constructor(source: string, maxPages: number) {
this.src = source;
this.max = maxPages;
}
parse(): Set<number> {
this.skipWs();
const set = this.parseDisjunction();
this.skipWs();
// If there are leftover non-space characters, treat as error
if (this.idx < this.src.length) {
throw new Error("Unexpected trailing input");
}
return set;
}
private parseDisjunction(): Set<number> {
let left = this.parseConjunction();
while (true) {
this.skipWs();
const op = this.peekWordOrSymbol();
if (!op) break;
if (op.type === "symbol" && (op.value === "," || op.value === "|")) {
this.consume(op.length);
const right = this.parseConjunction();
left = union(left, right);
continue;
}
if (op.type === "word" && op.value === "or") {
this.consume(op.length);
const right = this.parseConjunction();
left = union(left, right);
continue;
}
break;
}
return left;
}
private parseConjunction(): Set<number> {
let left = this.parseUnary();
while (true) {
this.skipWs();
const op = this.peekWordOrSymbol();
if (!op) break;
if (op.type === "symbol" && op.value === "&") {
this.consume(op.length);
const right = this.parseUnary();
left = intersect(left, right);
continue;
}
if (op.type === "word" && op.value === "and") {
this.consume(op.length);
const right = this.parseUnary();
left = intersect(left, right);
continue;
}
break;
}
return left;
}
private parseUnary(): Set<number> {
this.skipWs();
if (this.peek("!")) {
this.consume(1);
const inner = this.parseUnary();
return complement(inner, this.max);
}
// Word-form NOT
if (this.tryConsumeNot()) {
const inner = this.parseUnary();
return complement(inner, this.max);
}
return this.parsePrimary();
}
private parsePrimary(): Set<number> {
this.skipWs();
// Parenthesized expression: '(' expression ')'
if (this.peek("(")) {
this.consume(1);
const inner = this.parseDisjunction();
this.skipWs();
if (!this.peek(")")) throw new Error("Expected )");
this.consume(1);
return inner;
}
// Keywords: even / odd
const keyword = this.tryReadKeyword();
if (keyword) {
if (keyword === "even") return this.buildEven();
if (keyword === "odd") return this.buildOdd();
}
// Progression: k n ( +/- c )?
const progression = this.tryReadProgression();
if (progression) {
return this.buildProgression(progression.k, progression.c);
}
// Number or Range
const num = this.tryReadNumber();
if (num !== null) {
this.skipWs();
if (this.peek("-")) {
// Range
this.consume(1);
this.skipWs();
const end = this.readRequiredNumber();
return this.buildRange(num, end);
}
return this.buildSingleton(num);
}
// If nothing matched, error
throw new Error("Expected primary");
}
private buildSingleton(n: number): Set<number> {
const set = new Set<number>();
if (n >= 1 && n <= this.max) set.add(n);
return set;
}
private buildRange(a: number, b: number): Set<number> {
const set = new Set<number>();
let start = a,
end = b;
if (!Number.isFinite(start) || !Number.isFinite(end)) return set;
if (start > end) [start, end] = [end, start];
start = Math.max(1, start);
end = Math.min(this.max, end);
for (let i = start; i <= end; i++) set.add(i);
return set;
}
private buildProgression(k: number, c: number): Set<number> {
const set = new Set<number>();
if (!(k >= 1)) return set;
// n starts at 0: k*n + c, for n=0,1,2,... while within [1..max]
for (let n = 0; ; n++) {
const value = k * n + c;
if (value > this.max) break;
if (value >= 1) set.add(value);
}
return set;
}
private buildEven(): Set<number> {
return this.buildProgression(2, 0);
}
private buildOdd(): Set<number> {
return this.buildProgression(2, -1);
}
private tryReadKeyword(): "even" | "odd" | null {
const start = this.idx;
const word = this.readWord();
if (!word) return null;
const lower = word.toLowerCase();
if (lower === "even" || lower === "odd") {
return lower as "even" | "odd";
}
// Not a keyword; rewind
this.idx = start;
return null;
}
private tryReadProgression(): { k: number; c: number } | null {
const start = this.idx;
this.skipWs();
const k = this.tryReadNumber();
if (k === null) {
this.idx = start;
return null;
}
this.skipWs();
// Optional '*'
if (this.peek("*")) this.consume(1);
this.skipWs();
if (!this.peek("n") && !this.peek("N")) {
this.idx = start;
return null;
}
this.consume(1); // consume 'n'
this.skipWs();
// Optional (+|-) c
let c = 0;
if (this.peek("+") || this.peek("-")) {
const sign = this.src[this.idx];
this.consume(1);
this.skipWs();
const cVal = this.tryReadNumber();
if (cVal === null) {
this.idx = start;
return null;
}
c = sign === "-" ? -cVal : cVal;
}
return { k, c };
}
private tryReadNumber(): number | null {
this.skipWs();
const m = this.src.slice(this.idx).match(/^(\d+)/);
if (!m) return null;
this.consume(m[1].length);
const num = parseInt(m[1], 10);
return Number.isFinite(num) ? num : null;
}
private readRequiredNumber(): number {
const n = this.tryReadNumber();
if (n === null) throw new Error("Expected number");
return n;
}
private readWord(): string | null {
this.skipWs();
const m = this.src.slice(this.idx).match(/^([A-Za-z]+)/);
if (!m) return null;
this.consume(m[1].length);
return m[1];
}
private tryConsumeNot(): boolean {
const start = this.idx;
const word = this.readWord();
if (!word) {
this.idx = start;
return false;
}
if (word.toLowerCase() === "not") {
return true;
}
this.idx = start;
return false;
}
private peekWordOrSymbol(): {
type: "word" | "symbol";
value: string;
raw: string;
length: number;
} | null {
this.skipWs();
if (this.idx >= this.src.length) return null;
const ch = this.src[this.idx];
if (/[A-Za-z]/.test(ch)) {
const start = this.idx;
const word = this.readWord();
if (!word) return null;
const lower = word.toLowerCase();
// Always rewind; the caller will consume if it uses this token
const len = word.length;
this.idx = start;
if (lower === "and" || lower === "or") {
return { type: "word", value: lower, raw: word, length: len };
}
return null;
}
if (ch === "&" || ch === "|" || ch === ",") {
return { type: "symbol", value: ch, raw: ch, length: 1 };
}
return null;
}
private skipWs() {
while (this.idx < this.src.length && /\s/.test(this.src[this.idx]))
this.idx++;
}
private peek(s: string): boolean {
return this.src.startsWith(s, this.idx);
}
private consume(n: number) {
this.idx += n;
}
}
function union(a: Set<number>, b: Set<number>): Set<number> {
if (a.size === 0) return new Set(b);
if (b.size === 0) return new Set(a);
const out = new Set<number>(a);
for (const v of b) out.add(v);
return out;
}
function intersect(a: Set<number>, b: Set<number>): Set<number> {
if (a.size === 0 || b.size === 0) return new Set<number>();
const out = new Set<number>();
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
for (const v of small) if (large.has(v)) out.add(v);
return out;
}
function complement(a: Set<number>, max: number): Set<number> {
const out = new Set<number>();
for (let i = 1; i <= max; i++) if (!a.has(i)) out.add(i);
return out;
}
@@ -0,0 +1,149 @@
// Pure helper utilities for building and manipulating bulk page selection expressions
export type LogicalOperator = "and" | "or" | "not" | "even" | "odd";
// Returns a new CSV expression with expr appended.
// If current ends with an operator token, expr is appended directly.
// Otherwise, it is joined with " or ".
export function appendExpression(currentInput: string, expr: string): string {
const current = (currentInput || "").trim();
if (!current) return expr;
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current);
// Add space if operator doesn't already have one
if (endsWithOperator) {
const needsSpace = !current.endsWith(" ");
return `${current}${needsSpace ? " " : ""}${expr}`;
}
return `${current} or ${expr}`;
}
// Smartly inserts/normalizes a logical operator at the end of the current input.
// Produces a trailing space to allow the next token to be typed naturally.
export function insertOperatorSmart(
currentInput: string,
op: LogicalOperator,
): string {
const text = (currentInput || "").trim();
// Handle 'even' and 'odd' as page selection expressions, not logical operators
if (op === "even" || op === "odd") {
if (text.length === 0) return `${op} `;
// If current input ends with a logical operator, append the page selection with proper spacing
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(text);
if (endsWithOperator) {
// Add space if the operator doesn't already have one
const needsSpace = !text.endsWith(" ");
return `${text}${needsSpace ? " " : ""}${op} `;
}
return `${text} or ${op} `;
}
if (text.length === 0) return `${op} `;
// Extract up to the last two operator tokens (words or symbols) from the end
const tokens: string[] = [];
let rest = text;
for (let i = 0; i < 2; i++) {
const m = rest.match(/(?:\s*)(?:(&|\||,|!|\band\b|\bor\b|\bnot\b))\s*$/i);
if (!m || m.index === undefined) break;
const raw = m[1].toLowerCase();
const word =
raw === "&"
? "and"
: raw === "|" || raw === ","
? "or"
: raw === "!"
? "not"
: raw;
tokens.unshift(word);
rest = rest.slice(0, m.index).trimEnd();
}
const emit = (base: string, phrase: string) => `${base} ${phrase} `;
const click = op; // desired operator
if (tokens.length === 0) {
return emit(text, click);
}
// Normalize to allowed set
const phrase = tokens.join(" ");
const allowed = new Set(["and", "or", "not", "and not", "or not"]);
// Helpers for transitions from a single trailing token
const fromSingle = (t: string): string => {
if (t === "and") {
if (click === "and") return "and";
if (click === "or") return "or"; // 'and or' is invalid, so just use 'or'
return "and not";
}
if (t === "or") {
if (click === "and") return "and";
if (click === "or") return "or";
return "or not";
}
// t === 'not'
if (click === "and") return "and";
if (click === "or") return "or";
return "not";
};
// From combined phrase
const fromCombo = (p: string): string => {
if (p === "and not") {
if (click === "not") return "and not";
if (click === "and") return "and";
if (click === "or") return "or"; // 'and not or' is invalid, so just use 'or'
return "and not";
}
if (p === "or not") {
if (click === "not") return "or not";
if (click === "or") return "or";
if (click === "and") return "and"; // 'or not and' is invalid, so just use 'and'
return "or not";
}
// Invalid combos (e.g., 'not and', 'not or', 'or and', 'and or') → collapse to clicked op
return click;
};
const base = rest.trim();
const nextPhrase =
tokens.length === 1 ? fromSingle(tokens[0]) : fromCombo(phrase);
if (!allowed.has(nextPhrase)) {
return emit(base, click);
}
return emit(base, nextPhrase);
}
// Expression builders for Advanced actions
export function firstNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const end = Math.min(maxPages, Math.max(1, Math.floor(n)));
return `1-${end}`;
}
export function lastNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const count = Math.max(1, Math.floor(n));
const start = Math.max(1, maxPages - count + 1);
if (maxPages <= 0) return null;
return `${start}-${maxPages}`;
}
export function everyNthExpression(n: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
return `${Math.max(1, Math.floor(n))}n`;
}
export function rangeExpression(
start: number,
end: number,
maxPages: number,
): string | null {
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
let s = Math.floor(start);
let e = Math.floor(end);
if (s > e) [s, e] = [e, s];
s = Math.max(1, s);
e = maxPages > 0 ? Math.min(maxPages, e) : e;
return `${s}-${e}`;
}
@@ -0,0 +1,34 @@
/**
* Utility functions for handling click events in navigation components
*/
/**
* Determines if a click event is a "special" click that should use browser's default navigation
* instead of SPA navigation. Special clicks include:
* - Ctrl+click (or Cmd+click on Mac)
* - Shift+click
* - Middle mouse button click
*/
export function isSpecialClick(e: React.MouseEvent): boolean {
return e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1;
}
/**
* Handles a click event for SPA navigation, but allows special clicks to use browser defaults
*
* @param e - The click event
* @param handleClick - Function to execute for regular clicks (SPA navigation)
* @returns true if the event was handled as a special click, false if it was handled as regular click
*/
export function handleUnlessSpecialClick(
e: React.MouseEvent,
handleClick: () => void,
): boolean {
if (isSpecialClick(e)) {
return true; // Let browser handle via href
}
e.preventDefault();
handleClick();
return false;
}
@@ -0,0 +1,337 @@
/**
* Unit tests for convertUtils
*/
import { describe, test, expect } from "vitest";
import {
getEndpointName,
getEndpointUrl,
isConversionSupported,
isImageFormat,
} from "@app/utils/convertUtils";
describe("convertUtils", () => {
describe("getEndpointName", () => {
test("should return correct endpoint names for all supported conversions", () => {
// PDF to Image formats
expect(getEndpointName("pdf", "png")).toBe("pdf-to-img");
expect(getEndpointName("pdf", "jpg")).toBe("pdf-to-img");
expect(getEndpointName("pdf", "gif")).toBe("pdf-to-img");
expect(getEndpointName("pdf", "tiff")).toBe("pdf-to-img");
expect(getEndpointName("pdf", "bmp")).toBe("pdf-to-img");
expect(getEndpointName("pdf", "webp")).toBe("pdf-to-img");
// PDF to Office formats
expect(getEndpointName("pdf", "docx")).toBe("pdf-to-word");
expect(getEndpointName("pdf", "odt")).toBe("pdf-to-word");
expect(getEndpointName("pdf", "pptx")).toBe("pdf-to-presentation");
expect(getEndpointName("pdf", "odp")).toBe("pdf-to-presentation");
// PDF to Data formats
expect(getEndpointName("pdf", "csv")).toBe("pdf-to-csv");
expect(getEndpointName("pdf", "txt")).toBe("pdf-to-text");
expect(getEndpointName("pdf", "rtf")).toBe("pdf-to-text");
expect(getEndpointName("pdf", "md")).toBe("pdf-to-markdown");
// PDF to Web formats
expect(getEndpointName("pdf", "html")).toBe("pdf-to-html");
expect(getEndpointName("pdf", "xml")).toBe("pdf-to-xml");
// PDF to PDF/A
expect(getEndpointName("pdf", "pdfa")).toBe("pdf-to-pdfa");
// Office Documents to PDF
expect(getEndpointName("docx", "pdf")).toBe("file-to-pdf");
expect(getEndpointName("doc", "pdf")).toBe("file-to-pdf");
expect(getEndpointName("odt", "pdf")).toBe("file-to-pdf");
// Spreadsheets to PDF
expect(getEndpointName("xlsx", "pdf")).toBe("file-to-pdf");
expect(getEndpointName("xls", "pdf")).toBe("file-to-pdf");
expect(getEndpointName("ods", "pdf")).toBe("file-to-pdf");
// Presentations to PDF
expect(getEndpointName("pptx", "pdf")).toBe("file-to-pdf");
expect(getEndpointName("ppt", "pdf")).toBe("file-to-pdf");
expect(getEndpointName("odp", "pdf")).toBe("file-to-pdf");
// Images to PDF
expect(getEndpointName("jpg", "pdf")).toBe("img-to-pdf");
expect(getEndpointName("jpeg", "pdf")).toBe("img-to-pdf");
expect(getEndpointName("png", "pdf")).toBe("img-to-pdf");
expect(getEndpointName("gif", "pdf")).toBe("img-to-pdf");
expect(getEndpointName("bmp", "pdf")).toBe("img-to-pdf");
expect(getEndpointName("tiff", "pdf")).toBe("img-to-pdf");
expect(getEndpointName("webp", "pdf")).toBe("img-to-pdf");
// Web formats to PDF
expect(getEndpointName("html", "pdf")).toBe("html-to-pdf");
// Markdown to PDF
expect(getEndpointName("md", "pdf")).toBe("markdown-to-pdf");
// Text formats to PDF
expect(getEndpointName("txt", "pdf")).toBe("file-to-pdf");
expect(getEndpointName("rtf", "pdf")).toBe("file-to-pdf");
// Email to PDF (EML and MSG)
expect(getEndpointName("eml", "pdf")).toBe("eml-to-pdf");
expect(getEndpointName("msg", "pdf")).toBe("eml-to-pdf");
});
test("should return empty string for unsupported conversions", () => {
expect(getEndpointName("pdf", "exe")).toBe("");
expect(getEndpointName("wav", "pdf")).toBe("file-to-pdf"); // Try using file to pdf as fallback
expect(getEndpointName("png", "docx")).toBe(""); // Images can't convert to Word docs
});
test("should handle empty or invalid inputs", () => {
expect(getEndpointName("", "")).toBe("");
expect(getEndpointName("pdf", "")).toBe("");
expect(getEndpointName("", "pdf")).toBe("");
expect(getEndpointName("nonexistent", "alsononexistent")).toBe("");
});
});
describe("getEndpointUrl", () => {
test("should return correct endpoint URLs for all supported conversions", () => {
// PDF to Image formats
expect(getEndpointUrl("pdf", "png")).toBe("/api/v1/convert/pdf/img");
expect(getEndpointUrl("pdf", "jpg")).toBe("/api/v1/convert/pdf/img");
expect(getEndpointUrl("pdf", "gif")).toBe("/api/v1/convert/pdf/img");
expect(getEndpointUrl("pdf", "tiff")).toBe("/api/v1/convert/pdf/img");
expect(getEndpointUrl("pdf", "bmp")).toBe("/api/v1/convert/pdf/img");
expect(getEndpointUrl("pdf", "webp")).toBe("/api/v1/convert/pdf/img");
// PDF to Office formats
expect(getEndpointUrl("pdf", "docx")).toBe("/api/v1/convert/pdf/word");
expect(getEndpointUrl("pdf", "odt")).toBe("/api/v1/convert/pdf/word");
expect(getEndpointUrl("pdf", "pptx")).toBe(
"/api/v1/convert/pdf/presentation",
);
expect(getEndpointUrl("pdf", "odp")).toBe(
"/api/v1/convert/pdf/presentation",
);
// PDF to Data formats
expect(getEndpointUrl("pdf", "csv")).toBe("/api/v1/convert/pdf/csv");
expect(getEndpointUrl("pdf", "txt")).toBe("/api/v1/convert/pdf/text");
expect(getEndpointUrl("pdf", "rtf")).toBe("/api/v1/convert/pdf/text");
expect(getEndpointUrl("pdf", "md")).toBe("/api/v1/convert/pdf/markdown");
// PDF to Web formats
expect(getEndpointUrl("pdf", "html")).toBe("/api/v1/convert/pdf/html");
expect(getEndpointUrl("pdf", "xml")).toBe("/api/v1/convert/pdf/xml");
// PDF to PDF/A
expect(getEndpointUrl("pdf", "pdfa")).toBe("/api/v1/convert/pdf/pdfa");
// Office Documents to PDF
expect(getEndpointUrl("docx", "pdf")).toBe("/api/v1/convert/file/pdf");
expect(getEndpointUrl("doc", "pdf")).toBe("/api/v1/convert/file/pdf");
expect(getEndpointUrl("odt", "pdf")).toBe("/api/v1/convert/file/pdf");
// Spreadsheets to PDF
expect(getEndpointUrl("xlsx", "pdf")).toBe("/api/v1/convert/file/pdf");
expect(getEndpointUrl("xls", "pdf")).toBe("/api/v1/convert/file/pdf");
expect(getEndpointUrl("ods", "pdf")).toBe("/api/v1/convert/file/pdf");
// Presentations to PDF
expect(getEndpointUrl("pptx", "pdf")).toBe("/api/v1/convert/file/pdf");
expect(getEndpointUrl("ppt", "pdf")).toBe("/api/v1/convert/file/pdf");
expect(getEndpointUrl("odp", "pdf")).toBe("/api/v1/convert/file/pdf");
// Images to PDF
expect(getEndpointUrl("jpg", "pdf")).toBe("/api/v1/convert/img/pdf");
expect(getEndpointUrl("jpeg", "pdf")).toBe("/api/v1/convert/img/pdf");
expect(getEndpointUrl("png", "pdf")).toBe("/api/v1/convert/img/pdf");
expect(getEndpointUrl("gif", "pdf")).toBe("/api/v1/convert/img/pdf");
expect(getEndpointUrl("bmp", "pdf")).toBe("/api/v1/convert/img/pdf");
expect(getEndpointUrl("tiff", "pdf")).toBe("/api/v1/convert/img/pdf");
expect(getEndpointUrl("webp", "pdf")).toBe("/api/v1/convert/img/pdf");
// Web formats to PDF
expect(getEndpointUrl("html", "pdf")).toBe("/api/v1/convert/html/pdf");
// Markdown to PDF
expect(getEndpointUrl("md", "pdf")).toBe("/api/v1/convert/markdown/pdf");
// Text formats to PDF
expect(getEndpointUrl("txt", "pdf")).toBe("/api/v1/convert/file/pdf");
expect(getEndpointUrl("rtf", "pdf")).toBe("/api/v1/convert/file/pdf");
// Email to PDF (EML and MSG)
expect(getEndpointUrl("eml", "pdf")).toBe("/api/v1/convert/eml/pdf");
expect(getEndpointUrl("msg", "pdf")).toBe("/api/v1/convert/eml/pdf");
});
test("should return empty string for unsupported conversions", () => {
expect(getEndpointUrl("pdf", "exe")).toBe("");
expect(getEndpointUrl("wav", "pdf")).toBe("/api/v1/convert/file/pdf"); // Try using file to pdf as fallback
expect(getEndpointUrl("invalid", "invalid")).toBe("");
});
test("should handle empty inputs", () => {
expect(getEndpointUrl("", "")).toBe("");
expect(getEndpointUrl("pdf", "")).toBe("");
expect(getEndpointUrl("", "pdf")).toBe("");
});
});
describe("isConversionSupported", () => {
test("should return true for all supported conversions", () => {
// PDF to Image formats
expect(isConversionSupported("pdf", "png")).toBe(true);
expect(isConversionSupported("pdf", "jpg")).toBe(true);
expect(isConversionSupported("pdf", "gif")).toBe(true);
expect(isConversionSupported("pdf", "tiff")).toBe(true);
expect(isConversionSupported("pdf", "bmp")).toBe(true);
expect(isConversionSupported("pdf", "webp")).toBe(true);
// PDF to Office formats
expect(isConversionSupported("pdf", "docx")).toBe(true);
expect(isConversionSupported("pdf", "odt")).toBe(true);
expect(isConversionSupported("pdf", "pptx")).toBe(true);
expect(isConversionSupported("pdf", "odp")).toBe(true);
// PDF to Data formats
expect(isConversionSupported("pdf", "csv")).toBe(true);
expect(isConversionSupported("pdf", "txt")).toBe(true);
expect(isConversionSupported("pdf", "rtf")).toBe(true);
expect(isConversionSupported("pdf", "md")).toBe(true);
// PDF to Web formats
expect(isConversionSupported("pdf", "html")).toBe(true);
expect(isConversionSupported("pdf", "xml")).toBe(true);
// PDF to PDF/A
expect(isConversionSupported("pdf", "pdfa")).toBe(true);
// Office Documents to PDF
expect(isConversionSupported("docx", "pdf")).toBe(true);
expect(isConversionSupported("doc", "pdf")).toBe(true);
expect(isConversionSupported("odt", "pdf")).toBe(true);
// Spreadsheets to PDF
expect(isConversionSupported("xlsx", "pdf")).toBe(true);
expect(isConversionSupported("xls", "pdf")).toBe(true);
expect(isConversionSupported("ods", "pdf")).toBe(true);
// Presentations to PDF
expect(isConversionSupported("pptx", "pdf")).toBe(true);
expect(isConversionSupported("ppt", "pdf")).toBe(true);
expect(isConversionSupported("odp", "pdf")).toBe(true);
// Images to PDF
expect(isConversionSupported("jpg", "pdf")).toBe(true);
expect(isConversionSupported("jpeg", "pdf")).toBe(true);
expect(isConversionSupported("png", "pdf")).toBe(true);
expect(isConversionSupported("gif", "pdf")).toBe(true);
expect(isConversionSupported("bmp", "pdf")).toBe(true);
expect(isConversionSupported("tiff", "pdf")).toBe(true);
expect(isConversionSupported("webp", "pdf")).toBe(true);
// Web formats to PDF
expect(isConversionSupported("html", "pdf")).toBe(true);
expect(isConversionSupported("htm", "pdf")).toBe(true);
// Markdown to PDF
expect(isConversionSupported("md", "pdf")).toBe(true);
// Text formats to PDF
expect(isConversionSupported("txt", "pdf")).toBe(true);
expect(isConversionSupported("rtf", "pdf")).toBe(true);
// Email to PDF (EML and MSG)
expect(isConversionSupported("eml", "pdf")).toBe(true);
expect(isConversionSupported("msg", "pdf")).toBe(true);
});
test("should return false for unsupported conversions", () => {
expect(isConversionSupported("pdf", "exe")).toBe(false);
expect(isConversionSupported("wav", "pdf")).toBe(true); // Fallback to file to pdf
expect(isConversionSupported("png", "docx")).toBe(false);
expect(isConversionSupported("nonexistent", "alsononexistent")).toBe(
false,
);
});
test("should handle empty inputs", () => {
expect(isConversionSupported("", "")).toBe(false);
expect(isConversionSupported("pdf", "")).toBe(false);
expect(isConversionSupported("", "pdf")).toBe(false);
});
});
describe("isImageFormat", () => {
test("should return true for image formats", () => {
expect(isImageFormat("png")).toBe(true);
expect(isImageFormat("jpg")).toBe(true);
expect(isImageFormat("jpeg")).toBe(true);
expect(isImageFormat("gif")).toBe(true);
expect(isImageFormat("tiff")).toBe(true);
expect(isImageFormat("bmp")).toBe(true);
expect(isImageFormat("webp")).toBe(true);
});
test("should return false for non-image formats", () => {
expect(isImageFormat("pdf")).toBe(false);
expect(isImageFormat("docx")).toBe(false);
expect(isImageFormat("txt")).toBe(false);
expect(isImageFormat("csv")).toBe(false);
expect(isImageFormat("html")).toBe(false);
expect(isImageFormat("xml")).toBe(false);
});
test("should handle case insensitivity", () => {
expect(isImageFormat("PNG")).toBe(true);
expect(isImageFormat("JPG")).toBe(true);
expect(isImageFormat("JPEG")).toBe(true);
expect(isImageFormat("Png")).toBe(true);
expect(isImageFormat("JpG")).toBe(true);
});
test("should handle empty and invalid inputs", () => {
expect(isImageFormat("")).toBe(false);
expect(isImageFormat("invalid")).toBe(false);
expect(isImageFormat("123")).toBe(false);
expect(isImageFormat(".")).toBe(false);
});
test("should handle mixed case and edge cases", () => {
expect(isImageFormat("webP")).toBe(true);
expect(isImageFormat("WEBP")).toBe(true);
expect(isImageFormat("tIFf")).toBe(true);
expect(isImageFormat("bMp")).toBe(true);
});
});
describe("Edge Cases and Error Handling", () => {
test("should handle null and undefined inputs gracefully", () => {
// Note: TypeScript prevents these, but test runtime behavior for robustness
// The current implementation handles these gracefully by returning falsy values
expect(getEndpointName(null as any, null as any)).toBe("");
expect(getEndpointUrl(undefined as any, undefined as any)).toBe("");
expect(isConversionSupported(null as any, null as any)).toBe(false);
// isImageFormat will throw because it calls toLowerCase() on null/undefined
expect(() => isImageFormat(null as any)).toThrow();
expect(() => isImageFormat(undefined as any)).toThrow();
});
test("should handle special characters in file extensions", () => {
expect(isImageFormat("png@")).toBe(false);
expect(isImageFormat("jpg#")).toBe(false);
expect(isImageFormat("png.")).toBe(false);
expect(getEndpointName("pdf@", "png")).toBe("");
expect(getEndpointName("pdf", "png#")).toBe("");
});
test("should handle very long extension names", () => {
const longExtension = "a".repeat(100);
expect(isImageFormat(longExtension)).toBe(false);
expect(getEndpointName("pdf", longExtension)).toBe("");
expect(getEndpointName(longExtension, "pdf")).toBe("file-to-pdf"); // Fallback to file to pdf
});
});
});
@@ -0,0 +1,125 @@
import {
CONVERSION_ENDPOINTS,
ENDPOINT_NAMES,
EXTENSION_TO_ENDPOINT,
CONVERSION_MATRIX,
TO_FORMAT_OPTIONS,
} from "@app/constants/convertConstants";
/**
* Resolves the endpoint name for a given conversion
*/
export const getEndpointName = (
fromExtension: string,
toExtension: string,
): string => {
if (!fromExtension || !toExtension) return "";
let endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension];
// If no explicit mapping exists and we're converting to PDF,
// fall back to 'any' which uses file-to-pdf endpoint
if (!endpointKey && toExtension === "pdf" && fromExtension !== "any") {
endpointKey = EXTENSION_TO_ENDPOINT["any"]?.[toExtension];
}
return endpointKey || "";
};
/**
* Resolves the full endpoint URL for a given conversion
*/
export const getEndpointUrl = (
fromExtension: string,
toExtension: string,
): string => {
const endpointName = getEndpointName(fromExtension, toExtension);
if (!endpointName) return "";
// Find the endpoint URL from CONVERSION_ENDPOINTS using the endpoint name
for (const [key, endpoint] of Object.entries(CONVERSION_ENDPOINTS)) {
if (ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES] === endpointName) {
return endpoint;
}
}
return "";
};
/**
* Checks if a conversion is supported
*/
export const isConversionSupported = (
fromExtension: string,
toExtension: string,
): boolean => {
return getEndpointName(fromExtension, toExtension) !== "";
};
/**
* Checks if the given extension is an image format
*/
export const isImageFormat = (extension: string): boolean => {
return ["png", "jpg", "jpeg", "gif", "tiff", "bmp", "webp"].includes(
extension.toLowerCase(),
);
};
export const isSvgFormat = (extension: string): boolean => {
return extension.toLowerCase() === "svg";
};
/**
* Checks if the given extension is a web format
*/
export const isWebFormat = (extension: string): boolean => {
return ["html", "zip"].includes(extension.toLowerCase());
};
/**
* Checks if the given extension is an office format (Word, Excel, PowerPoint, OpenOffice)
* These formats use LibreOffice for conversion and require individual file processing
*/
export const isOfficeFormat = (extension: string): boolean => {
return [
"docx",
"doc",
"odt", // Word processors
"xlsx",
"xls",
"ods", // Spreadsheets
"pptx",
"ppt",
"odp", // Presentations
].includes(extension.toLowerCase());
};
/**
* Gets available target extensions for a given source extension
* Extracted from useConvertParameters to be reusable in automation settings
*/
export const getAvailableToExtensions = (
fromExtension: string,
): Array<{ value: string; label: string; group: string }> => {
if (!fromExtension) return [];
// Handle dynamic format identifiers (file-<extension>)
if (fromExtension.startsWith("file-")) {
// Dynamic format - use 'any' conversion options (file-to-pdf)
const supportedExtensions = CONVERSION_MATRIX["any"] || [];
return TO_FORMAT_OPTIONS.filter((option) =>
supportedExtensions.includes(option.value),
);
}
let supportedExtensions = CONVERSION_MATRIX[fromExtension] || [];
// If no explicit conversion exists, but file-to-pdf might be available,
// fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf)
if (supportedExtensions.length === 0 && fromExtension !== "any") {
supportedExtensions = CONVERSION_MATRIX["any"] || [];
}
return TO_FORMAT_OPTIONS.filter((option) =>
supportedExtensions.includes(option.value),
);
};
@@ -0,0 +1,228 @@
/**
* Utility functions for crop coordinate conversion and PDF bounds handling
*/
export interface PDFBounds {
/** PDF width in points (actual PDF dimensions) */
actualWidth: number;
/** PDF height in points (actual PDF dimensions) */
actualHeight: number;
/** Thumbnail display width in pixels */
thumbnailWidth: number;
/** Thumbnail display height in pixels */
thumbnailHeight: number;
/** Horizontal offset for centering thumbnail in container */
offsetX: number;
/** Vertical offset for centering thumbnail in container */
offsetY: number;
/** Scale factor: thumbnailSize / actualSize */
scale: number;
}
export interface Rectangle {
/** X coordinate */
x: number;
/** Y coordinate */
y: number;
/** Width */
width: number;
/** Height */
height: number;
}
/** Runtime type guard */
export function isRectangle(value: unknown): value is Rectangle {
if (value === null || typeof value !== "object") return false;
const r = value as Record<string, unknown>;
const isNum = (n: unknown): n is number =>
typeof n === "number" && Number.isFinite(n);
return (
isNum(r.x) &&
isNum(r.y) &&
isNum(r.width) &&
isNum(r.height) &&
r.width >= 0 &&
r.height >= 0
);
}
/**
* Calculate PDF bounds for coordinate conversion based on thumbnail dimensions
*/
export const calculatePDFBounds = (
actualPDFWidth: number,
actualPDFHeight: number,
containerWidth: number,
containerHeight: number,
): PDFBounds => {
// Calculate scale to fit PDF within container while maintaining aspect ratio
const scaleX = containerWidth / actualPDFWidth;
const scaleY = containerHeight / actualPDFHeight;
const scale = Math.min(scaleX, scaleY);
// Calculate actual thumbnail display size
const thumbnailWidth = actualPDFWidth * scale;
const thumbnailHeight = actualPDFHeight * scale;
// Calculate centering offsets - these represent where the thumbnail is positioned within the container
const offsetX = (containerWidth - thumbnailWidth) / 2;
const offsetY = (containerHeight - thumbnailHeight) / 2;
return {
actualWidth: actualPDFWidth,
actualHeight: actualPDFHeight,
thumbnailWidth,
thumbnailHeight,
offsetX,
offsetY,
scale,
};
};
/**
* Convert DOM coordinates (relative to container) to PDF coordinates
* Handles coordinate system conversion (DOM uses top-left, PDF uses bottom-left origin)
*/
export const domToPDFCoordinates = (
domRect: Rectangle,
pdfBounds: PDFBounds,
): Rectangle => {
// Convert DOM coordinates to thumbnail-relative coordinates
const thumbX = domRect.x - pdfBounds.offsetX;
const thumbY = domRect.y - pdfBounds.offsetY;
// Convert to PDF coordinates (scale and flip Y-axis)
const pdfX = thumbX / pdfBounds.scale;
const pdfY =
pdfBounds.actualHeight - (thumbY + domRect.height) / pdfBounds.scale;
const pdfWidth = domRect.width / pdfBounds.scale;
const pdfHeight = domRect.height / pdfBounds.scale;
return {
x: pdfX,
y: pdfY,
width: pdfWidth,
height: pdfHeight,
};
};
/**
* Convert PDF coordinates to DOM coordinates (relative to container)
*/
export const pdfToDOMCoordinates = (
cropArea: Rectangle,
pdfBounds: PDFBounds,
): Rectangle => {
// Convert PDF coordinates to thumbnail coordinates (scale and flip Y-axis)
const thumbX = cropArea.x * pdfBounds.scale;
const thumbY =
(pdfBounds.actualHeight - cropArea.y - cropArea.height) * pdfBounds.scale;
const thumbWidth = cropArea.width * pdfBounds.scale;
const thumbHeight = cropArea.height * pdfBounds.scale;
// Add container offsets to get DOM coordinates
return {
x: thumbX + pdfBounds.offsetX,
y: thumbY + pdfBounds.offsetY,
width: thumbWidth,
height: thumbHeight,
};
};
/**
* Constrain a crop area to stay within PDF bounds
*/
export const constrainCropAreaToPDF = (
cropArea: Rectangle,
pdfBounds: PDFBounds,
): Rectangle => {
// Ensure crop area doesn't extend beyond PDF boundaries
const maxX = Math.max(0, pdfBounds.actualWidth - cropArea.width);
const maxY = Math.max(0, pdfBounds.actualHeight - cropArea.height);
return {
x: Math.max(0, Math.min(cropArea.x, maxX)),
y: Math.max(0, Math.min(cropArea.y, maxY)),
width: Math.min(
cropArea.width,
pdfBounds.actualWidth - Math.max(0, cropArea.x),
),
height: Math.min(
cropArea.height,
pdfBounds.actualHeight - Math.max(0, cropArea.y),
),
};
};
/**
* Constrain DOM coordinates to stay within thumbnail bounds
*/
export const constrainDOMRectToThumbnail = (
domRect: Rectangle,
pdfBounds: PDFBounds,
): Rectangle => {
const thumbnailLeft = pdfBounds.offsetX;
const thumbnailTop = pdfBounds.offsetY;
const thumbnailRight = pdfBounds.offsetX + pdfBounds.thumbnailWidth;
const thumbnailBottom = pdfBounds.offsetY + pdfBounds.thumbnailHeight;
// Constrain position
const maxX = Math.max(thumbnailLeft, thumbnailRight - domRect.width);
const maxY = Math.max(thumbnailTop, thumbnailBottom - domRect.height);
const constrainedX = Math.max(thumbnailLeft, Math.min(domRect.x, maxX));
const constrainedY = Math.max(thumbnailTop, Math.min(domRect.y, maxY));
// Constrain size to fit within thumbnail bounds from current position
const maxWidth = thumbnailRight - constrainedX;
const maxHeight = thumbnailBottom - constrainedY;
return {
x: constrainedX,
y: constrainedY,
width: Math.min(domRect.width, maxWidth),
height: Math.min(domRect.height, maxHeight),
};
};
/**
* Check if a point is within the thumbnail area (not just the container)
*/
export const isPointInThumbnail = (
x: number,
y: number,
pdfBounds: PDFBounds,
): boolean => {
return (
x >= pdfBounds.offsetX &&
x <= pdfBounds.offsetX + pdfBounds.thumbnailWidth &&
y >= pdfBounds.offsetY &&
y <= pdfBounds.offsetY + pdfBounds.thumbnailHeight
);
};
/**
* Create a default crop area that covers the entire PDF
*/
export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => {
return {
x: 0,
y: 0,
width: pdfBounds.actualWidth,
height: pdfBounds.actualHeight,
};
};
/**
* Round crop coordinates to reasonable precision (0.1 point)
*/
export const roundCropArea = (cropArea: Rectangle): Rectangle => {
return {
x: Math.round(cropArea.x * 10) / 10,
y: Math.round(cropArea.y * 10) / 10,
width: Math.round(cropArea.width * 10) / 10,
height: Math.round(cropArea.height * 10) / 10,
};
};
@@ -0,0 +1,154 @@
import { StirlingFileStub } from "@app/types/fileContext";
import { fileStorage } from "@app/services/fileStorage";
import { zipFileService } from "@app/services/zipFileService";
import { downloadFile } from "@app/services/downloadService";
/**
* Downloads a blob as a file using browser download API
* @param blob - The blob to download
* @param filename - The filename for the download
*/
export function downloadBlob(blob: Blob, filename: string): void {
void downloadFile({ data: blob, filename });
}
/**
* Downloads a single file from IndexedDB storage
* @param file - The file object with storage information
* @throws Error if file cannot be retrieved from storage
*/
export async function downloadFileFromStorage(
file: StirlingFileStub,
): Promise<void> {
const lookupKey = file.id;
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
if (!stirlingFile) {
throw new Error(`File "${file.name}" not found in storage`);
}
await downloadFile({
data: stirlingFile,
filename: stirlingFile.name,
localPath: file.localFilePath,
});
}
/**
* Downloads multiple files as individual downloads
* @param files - Array of files to download
*/
export async function downloadMultipleFiles(
files: StirlingFileStub[],
): Promise<void> {
for (const file of files) {
await downloadFileFromStorage(file);
}
}
/**
* Downloads multiple files as a single ZIP archive
* @param files - Array of files to include in ZIP
* @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
*/
export async function downloadFilesAsZip(
files: StirlingFileStub[],
zipFilename?: string,
): Promise<void> {
if (files.length === 0) {
throw new Error("No files provided for ZIP download");
}
// Convert stored files to File objects
const filesToZip: File[] = [];
for (const fileWithUrl of files) {
const lookupKey = fileWithUrl.id;
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
if (stirlingFile) {
// StirlingFile is already a File object!
filesToZip.push(stirlingFile);
}
}
if (filesToZip.length === 0) {
throw new Error("No valid files found in storage for ZIP download");
}
// Generate default filename if not provided
const finalZipFilename =
zipFilename ||
`files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, "")}.zip`;
// Create and download ZIP
const { zipFile } = await zipFileService.createZipFromFiles(
filesToZip,
finalZipFilename,
);
await downloadFile({ data: zipFile, filename: finalZipFilename });
}
/**
* Smart download function that handles single or multiple files appropriately
* - Single file: Downloads directly
* - Multiple files: Downloads as ZIP
* @param files - Array of files to download
* @param options - Download options
*/
export async function downloadFiles(
files: StirlingFileStub[],
options: {
forceZip?: boolean;
zipFilename?: string;
multipleAsIndividual?: boolean;
} = {},
): Promise<void> {
if (files.length === 0) {
throw new Error("No files provided for download");
}
if (files.length === 1 && !options.forceZip) {
// Single file download
await downloadFileFromStorage(files[0]);
} else if (options.multipleAsIndividual) {
// Multiple individual downloads
await downloadMultipleFiles(files);
} else {
// ZIP download (default for multiple files)
await downloadFilesAsZip(files, options.zipFilename);
}
}
/**
* Downloads a File object directly (for files already in memory)
* @param file - The File object to download
* @param filename - Optional custom filename
*/
export function downloadFileObject(file: File, filename?: string): void {
void downloadFile({ data: file, filename: filename || file.name });
}
/**
* Downloads text content as a file
* @param content - Text content to download
* @param filename - Filename for the download
* @param mimeType - MIME type (defaults to text/plain)
*/
export function downloadTextAsFile(
content: string,
filename: string,
mimeType: string = "text/plain",
): void {
const blob = new Blob([content], { type: mimeType });
void downloadFile({ data: blob, filename });
}
/**
* Downloads JSON data as a file
* @param data - Data to serialize and download
* @param filename - Filename for the download
*/
export function downloadJsonAsFile(data: any, filename: string): void {
const content = JSON.stringify(data, null, 2);
downloadTextAsFile(content, filename, "application/json");
}
@@ -0,0 +1,58 @@
export interface BookmarkPayload {
title: string;
pageNumber: number;
children?: BookmarkPayload[];
}
export interface BookmarkNode {
id: string;
title: string;
pageNumber: number;
children: BookmarkNode[];
expanded: boolean;
}
const createBookmarkId = () => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `bookmark-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
};
export const createBookmarkNode = (
bookmark?: Partial<BookmarkNode>,
): BookmarkNode => ({
id: bookmark?.id ?? createBookmarkId(),
title: bookmark?.title ?? "",
pageNumber: bookmark?.pageNumber ?? 1,
children: bookmark?.children
? bookmark.children.map((child) => createBookmarkNode(child))
: [],
expanded: bookmark?.expanded ?? true,
});
export const hydrateBookmarkPayload = (
payload: BookmarkPayload[] = [],
): BookmarkNode[] => {
return payload.map((item) => ({
id: createBookmarkId(),
title: item.title ?? "",
pageNumber:
typeof item.pageNumber === "number" && item.pageNumber > 0
? item.pageNumber
: 1,
expanded: true,
children: item.children ? hydrateBookmarkPayload(item.children) : [],
}));
};
export const serializeBookmarkNodes = (
bookmarks: BookmarkNode[],
): BookmarkPayload[] => {
return bookmarks.map((bookmark) => ({
title: bookmark.title,
pageNumber: bookmark.pageNumber,
children: serializeBookmarkNodes(bookmark.children),
}));
};
@@ -0,0 +1,18 @@
export function getDocumentFileDialogFilter() {
return [
{
name: "Documents",
extensions: [
"pdf",
"jpg",
"jpeg",
"png",
"gif",
"tiff",
"bmp",
"html",
"zip",
],
},
];
}
+137
View File
@@ -0,0 +1,137 @@
/**
* File hashing utilities for cache key generation
*/
export class FileHasher {
private static readonly CHUNK_SIZE = 64 * 1024; // 64KB chunks for hashing
/**
* Generate a content-based hash for a file
* Uses first + last + middle chunks to create a reasonably unique hash
* without reading the entire file (which would be expensive for large files)
*/
static async generateContentHash(file: File): Promise<string> {
const chunks = await this.getFileChunks(file);
const combined = await this.combineChunks(chunks);
return await this.hashArrayBuffer(combined);
}
/**
* Generate a fast hash based on file metadata
* Faster but less collision-resistant than content hash
*/
static generateMetadataHash(file: File): string {
const data = `${file.name}-${file.size}-${file.lastModified}-${file.type}`;
return this.simpleHash(data);
}
/**
* Generate a hybrid hash that balances speed and uniqueness
* Uses metadata + small content sample
*/
static async generateHybridHash(file: File): Promise<string> {
const metadataHash = this.generateMetadataHash(file);
// For small files, use full content hash
if (file.size <= 1024 * 1024) {
// 1MB
const contentHash = await this.generateContentHash(file);
return `${metadataHash}-${contentHash}`;
}
// For large files, use first chunk only
const firstChunk = file.slice(0, this.CHUNK_SIZE);
const firstChunkBuffer = await firstChunk.arrayBuffer();
const firstChunkHash = await this.hashArrayBuffer(firstChunkBuffer);
return `${metadataHash}-${firstChunkHash}`;
}
private static async getFileChunks(file: File): Promise<ArrayBuffer[]> {
const chunks: ArrayBuffer[] = [];
// First chunk
if (file.size > 0) {
const firstChunk = file.slice(0, Math.min(this.CHUNK_SIZE, file.size));
chunks.push(await firstChunk.arrayBuffer());
}
// Middle chunk (if file is large enough)
if (file.size > this.CHUNK_SIZE * 2) {
const middleStart =
Math.floor(file.size / 2) - Math.floor(this.CHUNK_SIZE / 2);
const middleEnd = middleStart + this.CHUNK_SIZE;
const middleChunk = file.slice(middleStart, middleEnd);
chunks.push(await middleChunk.arrayBuffer());
}
// Last chunk (if file is large enough and different from first)
if (file.size > this.CHUNK_SIZE) {
const lastStart = Math.max(file.size - this.CHUNK_SIZE, this.CHUNK_SIZE);
const lastChunk = file.slice(lastStart);
chunks.push(await lastChunk.arrayBuffer());
}
return chunks;
}
private static async combineChunks(
chunks: ArrayBuffer[],
): Promise<ArrayBuffer> {
const totalLength = chunks.reduce(
(sum, chunk) => sum + chunk.byteLength,
0,
);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
combined.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
}
return combined.buffer;
}
private static async hashArrayBuffer(buffer: ArrayBuffer): Promise<string> {
// Use Web Crypto API for proper hashing
if (crypto.subtle) {
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
// Fallback for environments without crypto.subtle
return this.simpleHash(Array.from(new Uint8Array(buffer)).join(""));
}
private static simpleHash(str: string): string {
let hash = 0;
if (str.length === 0) return hash.toString();
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash).toString(16);
}
/**
* Validate that a file matches its expected hash
* Useful for detecting file corruption or changes
*/
static async validateFileHash(
file: File,
expectedHash: string,
): Promise<boolean> {
try {
const actualHash = await this.generateHybridHash(file);
return actualHash === expectedHash;
} catch (error) {
console.error("Hash validation failed:", error);
return false;
}
}
}
@@ -0,0 +1,92 @@
/**
* File History Utilities
*
* Helper functions for IndexedDB-based file history management.
* Handles file history operations and lineage tracking.
*/
import { StirlingFileStub } from "@app/types/fileContext";
/**
* Group files by processing branches - each branch ends in a leaf file
* Returns Map<fileId, lineagePath[]> where fileId is the leaf and lineagePath is the path back to original
*/
export function groupFilesByOriginal(
StirlingFileStubs: StirlingFileStub[],
): Map<string, StirlingFileStub[]> {
const groups = new Map<string, StirlingFileStub[]>();
// Create a map for quick lookups
const fileMap = new Map<string, StirlingFileStub>();
for (const record of StirlingFileStubs) {
fileMap.set(record.id, record);
}
// Find leaf files (files that are not parents of any other files AND have version history)
// Original files (v0) should only be leaves if they have no processed versions at all
const leafFiles = StirlingFileStubs.filter((stub) => {
const isParentOfOthers = StirlingFileStubs.some(
(otherStub) => otherStub.parentFileId === stub.id,
);
const isOriginalOfOthers = StirlingFileStubs.some(
(otherStub) => otherStub.originalFileId === stub.id,
);
// A file is a leaf if:
// 1. It's not a parent of any other files, AND
// 2. It has processing history (versionNumber > 0) OR it's not referenced as original by others
return (
!isParentOfOthers &&
((stub.versionNumber && stub.versionNumber > 0) || !isOriginalOfOthers)
);
});
// For each leaf file, build its complete lineage path back to original
for (const leafFile of leafFiles) {
const lineagePath: StirlingFileStub[] = [];
let currentFile: StirlingFileStub | undefined = leafFile;
// Trace back through parentFileId chain to build this specific branch
while (currentFile) {
lineagePath.push(currentFile);
// Move to parent file in this branch
let nextFile: StirlingFileStub | undefined = undefined;
if (currentFile.parentFileId) {
nextFile = fileMap.get(currentFile.parentFileId);
} else if (
currentFile.originalFileId &&
currentFile.originalFileId !== currentFile.id
) {
// For v1 files, the original file might be referenced by originalFileId
nextFile = fileMap.get(currentFile.originalFileId);
}
// Check for infinite loops before moving to next
if (nextFile && lineagePath.some((file) => file.id === nextFile!.id)) {
break;
}
currentFile = nextFile;
}
// Sort lineage with latest version first (leaf at top)
lineagePath.sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
// Use leaf file ID as the group key - each branch gets its own group
groups.set(leafFile.id, lineagePath);
}
return groups;
}
/**
* Check if a file has version history
*/
export function hasVersionHistory(fileStub: StirlingFileStub): boolean {
return !!(
fileStub.originalFileId &&
fileStub.versionNumber &&
fileStub.versionNumber > 0
);
}
@@ -0,0 +1,13 @@
/**
* Runtime validation utilities for FileId safety
*/
import { FileId } from "@app/types/fileContext";
// Validate that a string is a proper FileId (has UUID format)
export function isValidFileId(id: string): id is FileId {
// Check UUID v4 format: 8-4-4-4-12 hex digits
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(id);
}
@@ -0,0 +1,171 @@
/**
* Unit tests for file response utility functions
*/
import { describe, test, expect } from "vitest";
import {
getFilenameFromHeaders,
createFileFromApiResponse,
} from "@app/utils/fileResponseUtils";
describe("fileResponseUtils", () => {
describe("getFilenameFromHeaders", () => {
test("should extract filename from content-disposition header", () => {
const contentDisposition = 'attachment; filename="document.pdf"';
const filename = getFilenameFromHeaders(contentDisposition);
expect(filename).toBe("document.pdf");
});
test("should extract filename without quotes", () => {
const contentDisposition = "attachment; filename=document.pdf";
const filename = getFilenameFromHeaders(contentDisposition);
expect(filename).toBe("document.pdf");
});
test("should handle single quotes", () => {
const contentDisposition = "attachment; filename='document.pdf'";
const filename = getFilenameFromHeaders(contentDisposition);
expect(filename).toBe("document.pdf");
});
test("should return null for malformed header", () => {
const contentDisposition = "attachment; invalid=format";
const filename = getFilenameFromHeaders(contentDisposition);
expect(filename).toBe(null);
});
test("should return null for empty header", () => {
const filename = getFilenameFromHeaders("");
expect(filename).toBe(null);
});
test("should return null for undefined header", () => {
const filename = getFilenameFromHeaders();
expect(filename).toBe(null);
});
test("should handle complex filenames with spaces and special chars", () => {
const contentDisposition = 'attachment; filename="My Document (1).pdf"';
const filename = getFilenameFromHeaders(contentDisposition);
expect(filename).toBe("My Document (1).pdf");
});
test("should handle filename with extension when downloadHtml is enabled", () => {
const contentDisposition = 'attachment; filename="email_content.html"';
const filename = getFilenameFromHeaders(contentDisposition);
expect(filename).toBe("email_content.html");
});
});
describe("createFileFromApiResponse", () => {
test("should create file using header filename when available", () => {
const responseData = new Uint8Array([1, 2, 3, 4]);
const headers = {
"content-type": "application/pdf",
"content-disposition": 'attachment; filename="server_filename.pdf"',
};
const fallbackFilename = "fallback.pdf";
const file = createFileFromApiResponse(
responseData,
headers,
fallbackFilename,
);
expect(file.name).toBe("server_filename.pdf");
expect(file.type).toBe("application/pdf");
expect(file.size).toBe(4);
});
test("should use fallback filename when no header filename", () => {
const responseData = new Uint8Array([1, 2, 3, 4]);
const headers = {
"content-type": "application/pdf",
};
const fallbackFilename = "converted_file.pdf";
const file = createFileFromApiResponse(
responseData,
headers,
fallbackFilename,
);
expect(file.name).toBe("converted_file.pdf");
expect(file.type).toBe("application/pdf");
});
test("should handle HTML response when downloadHtml is enabled", () => {
const responseData = "<html><body>Test</body></html>";
const headers = {
"content-type": "text/html",
"content-disposition": 'attachment; filename="email_content.html"',
};
const fallbackFilename = "fallback.pdf";
const file = createFileFromApiResponse(
responseData,
headers,
fallbackFilename,
);
expect(file.name).toBe("email_content.html");
expect(file.type).toBe("text/html");
});
test("should handle ZIP response", () => {
const responseData = new Uint8Array([80, 75, 3, 4]); // ZIP file signature
const headers = {
"content-type": "application/zip",
"content-disposition": 'attachment; filename="converted_files.zip"',
};
const fallbackFilename = "fallback.pdf";
const file = createFileFromApiResponse(
responseData,
headers,
fallbackFilename,
);
expect(file.name).toBe("converted_files.zip");
expect(file.type).toBe("application/zip");
});
test("should use default content-type when none provided", () => {
const responseData = new Uint8Array([1, 2, 3, 4]);
const headers = {};
const fallbackFilename = "test.bin";
const file = createFileFromApiResponse(
responseData,
headers,
fallbackFilename,
);
expect(file.name).toBe("test.bin");
expect(file.type).toBe("application/octet-stream");
});
test("should handle null/undefined headers gracefully", () => {
const responseData = new Uint8Array([1, 2, 3, 4]);
const headers = null;
const fallbackFilename = "test.bin";
const file = createFileFromApiResponse(
responseData,
headers,
fallbackFilename,
);
expect(file.name).toBe("test.bin");
expect(file.type).toBe("application/octet-stream");
});
});
});
@@ -0,0 +1,50 @@
/**
* Generic utility functions for handling file responses from API endpoints
*/
/**
* Extracts filename from Content-Disposition header
* @param contentDisposition - Content-Disposition header value
* @returns Filename if found, null otherwise
*/
export const getFilenameFromHeaders = (
contentDisposition: string = "",
): string | null => {
const match = contentDisposition.match(
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/,
);
if (match && match[1]) {
const filename = match[1].replace(/['"]/g, "");
// Decode URL-encoded characters (e.g., %20 -> space)
try {
return decodeURIComponent(filename);
} catch {
// If decoding fails, return the original filename
return filename;
}
}
return null;
};
/**
* Creates a File object from API response using the filename from headers
* @param responseData - The response data (blob/arraybuffer/string)
* @param headers - Response headers object
* @param fallbackFilename - Filename to use if none provided in headers
* @returns File object
*/
export const createFileFromApiResponse = (
responseData: any,
headers: any,
fallbackFilename: string,
): File => {
const contentType = headers?.["content-type"] || "application/octet-stream";
const contentDisposition = headers?.["content-disposition"] || "";
const filename =
getFilenameFromHeaders(contentDisposition) || fallbackFilename;
const blob = new Blob([responseData], { type: contentType });
return new File([blob], filename, { type: contentType });
};
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
isPdfFile,
detectFileExtension,
formatFileSize,
} from "@app/utils/fileUtils";
describe("fileUtils", () => {
describe("isPdfFile", () => {
it("should return true for PDF files with correct MIME type", () => {
const pdfFile = new File(["content"], "document.pdf", {
type: "application/pdf",
});
expect(isPdfFile(pdfFile)).toBe(true);
});
it("should return true for PDF files with .pdf extension even without MIME type", () => {
const pdfFile = new File(["content"], "document.pdf", { type: "" });
expect(isPdfFile(pdfFile)).toBe(true);
});
it("should return false for non-PDF files", () => {
const txtFile = new File(["content"], "document.txt", {
type: "text/plain",
});
expect(isPdfFile(txtFile)).toBe(false);
});
it("should return false for image files", () => {
const imageFile = new File(["content"], "image.png", {
type: "image/png",
});
expect(isPdfFile(imageFile)).toBe(false);
});
it("should return false for null", () => {
expect(isPdfFile(null)).toBe(false);
});
it("should return false for undefined", () => {
expect(isPdfFile(undefined)).toBe(false);
});
it("should handle file-like objects with name and type", () => {
const fileLike = { name: "test.pdf", type: "application/pdf" };
expect(isPdfFile(fileLike)).toBe(true);
});
it("should handle file-like objects with PDF extension but no type", () => {
const fileLike = { name: "test.pdf", type: "" };
expect(isPdfFile(fileLike)).toBe(true);
});
});
describe("detectFileExtension", () => {
it("should detect PDF extension", () => {
expect(detectFileExtension("document.pdf")).toBe("pdf");
});
it("should detect extension in uppercase", () => {
expect(detectFileExtension("document.PDF")).toBe("pdf");
});
it("should return empty string for files without extension", () => {
expect(detectFileExtension("document")).toBe("");
});
it("should handle multiple dots in filename", () => {
expect(detectFileExtension("my.document.pdf")).toBe("pdf");
});
it("should normalize jpeg to jpg", () => {
expect(detectFileExtension("image.jpeg")).toBe("jpg");
});
});
describe("formatFileSize", () => {
it("should format bytes", () => {
expect(formatFileSize(0)).toBe("0 B");
expect(formatFileSize(500)).toBe("500 B");
});
it("should format kilobytes", () => {
expect(formatFileSize(1024)).toBe("1 KB");
expect(formatFileSize(2048)).toBe("2 KB");
});
it("should format megabytes", () => {
expect(formatFileSize(1024 * 1024)).toBe("1 MB");
expect(formatFileSize(5 * 1024 * 1024)).toBe("5 MB");
});
it("should format gigabytes", () => {
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1 GB");
});
});
});
+170
View File
@@ -0,0 +1,170 @@
// Pure utility functions for file operations
/**
* Consolidated file size formatting utility
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
/**
* Get file date as string
*/
export function getFileDate(file: File | { lastModified: number }): string {
if (file.lastModified) {
return new Date(file.lastModified).toLocaleString();
}
return "Unknown";
}
/**
* Get file size as string (legacy method for backward compatibility)
*/
export function getFileSize(file: File | { size: number }): string {
if (!file.size) return "Unknown";
return formatFileSize(file.size);
}
/**
* Detects and normalizes file extension from filename
* @param filename - The filename to extract extension from
* @returns Normalized file extension in lowercase, empty string if no extension
*/
export function detectFileExtension(filename: string): string {
if (!filename || typeof filename !== "string") return "";
const parts = filename.split(".");
// If there's no extension (no dots or only one part), return empty string
if (parts.length <= 1) return "";
// Get the last part (extension) in lowercase
let extension = parts[parts.length - 1].toLowerCase();
// Normalize common extension variants
if (extension === "jpeg") extension = "jpg";
return extension;
}
/**
* Removes the file extension from a filename
* @param filename - The filename to process
* @param options - Options for processing
* @param options.preserveCase - If true, preserves original case. If false (default), converts to lowercase
* @returns Filename without extension
* @example
* getFilenameWithoutExtension('document.pdf') // 'document'
* getFilenameWithoutExtension('my.file.name.txt') // 'my.file.name'
* getFilenameWithoutExtension('REPORT.PDF', { preserveCase: true }) // 'REPORT'
*/
export function getFilenameWithoutExtension(
filename: string,
options: { preserveCase?: boolean } = {},
): string {
if (!filename || typeof filename !== "string") return "";
const { preserveCase = false } = options;
const withoutExtension = filename.replace(/\.[^.]+$/, "");
return preserveCase ? withoutExtension : withoutExtension.toLowerCase();
}
/**
* Checks if a file is a PDF based on extension and MIME type
* @param file - File or file-like object with name and type properties
* @returns true if the file appears to be a PDF
*/
export function isPdfFile(
file: { name?: string; type?: string } | File | Blob | null | undefined,
): boolean {
if (!file) return false;
const name = "name" in file ? file.name : undefined;
const type = file.type;
// Check MIME type first (most reliable)
if (type === "application/pdf") return true;
// Check file extension as fallback
if (name) {
const ext = detectFileExtension(name);
if (ext === "pdf") return true;
}
return false;
}
export type NonPdfFileType =
| "image"
| "csv"
| "json"
| "text"
| "markdown"
| "html"
| "unknown";
export const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"svg",
"tiff",
"tif",
"webp",
]);
const CSV_EXTENSIONS = new Set(["csv", "tsv"]);
const JSON_EXTENSIONS = new Set(["json"]);
const TEXT_EXTENSIONS = new Set(["txt"]);
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown"]);
const HTML_EXTENSIONS = new Set(["html", "htm"]);
/** All file extensions that the built-in viewer can render. */
export const VIEWER_SUPPORTED_EXTENSIONS: string[] = [
"pdf",
...IMAGE_EXTENSIONS,
...CSV_EXTENSIONS,
...JSON_EXTENSIONS,
...TEXT_EXTENSIONS,
...MARKDOWN_EXTENSIONS,
...HTML_EXTENSIONS,
];
/**
* Detects the non-PDF file type category for viewer routing.
* Returns 'unknown' for PDFs or unrecognized formats.
*/
export function detectNonPdfFileType(
file: { name?: string; type?: string } | File | null | undefined,
): NonPdfFileType {
if (!file) return "unknown";
const name = "name" in file ? file.name : undefined;
const mimeType = file.type ?? "";
// Check MIME type first
if (mimeType.startsWith("image/")) return "image";
if (mimeType === "text/csv") return "csv";
if (mimeType === "text/tab-separated-values") return "csv";
if (mimeType === "application/json") return "json";
if (mimeType === "text/html") return "html";
if (mimeType === "text/markdown") return "markdown";
// Fall back to extension
if (name) {
const ext = detectFileExtension(name);
if (IMAGE_EXTENSIONS.has(ext)) return "image";
if (CSV_EXTENSIONS.has(ext)) return "csv";
if (JSON_EXTENSIONS.has(ext)) return "json";
if (MARKDOWN_EXTENSIONS.has(ext)) return "markdown";
if (TEXT_EXTENSIONS.has(ext)) return "text";
if (HTML_EXTENSIONS.has(ext)) return "html";
}
return "unknown";
}
@@ -0,0 +1,134 @@
// Lightweight fuzzy search helpers without external deps
// Provides diacritics-insensitive normalization and Levenshtein distance scoring
function normalizeText(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}+/gu, "")
.trim();
}
// Basic Levenshtein distance (iterative with two rows)
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
const aLen = a.length;
const bLen = b.length;
if (aLen === 0) return bLen;
if (bLen === 0) return aLen;
const prev = new Array(bLen + 1);
const curr = new Array(bLen + 1);
for (let j = 0; j <= bLen; j++) prev[j] = j;
for (let i = 1; i <= aLen; i++) {
curr[0] = i;
const aChar = a.charCodeAt(i - 1);
for (let j = 1; j <= bLen; j++) {
const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1;
curr[j] = Math.min(
prev[j] + 1, // deletion
curr[j - 1] + 1, // insertion
prev[j - 1] + cost, // substitution
);
}
for (let j = 0; j <= bLen; j++) prev[j] = curr[j];
}
return curr[bLen];
}
// Compute a heuristic match score (higher is better)
// 1) Exact/substring hits get high base; 2) otherwise use normalized Levenshtein distance
export function scoreMatch(queryRaw: string, targetRaw: string): number {
const query = normalizeText(queryRaw);
const target = normalizeText(targetRaw);
if (!query) return 0;
if (target.includes(query)) {
// Reward earlier/shorter substring matches
const pos = target.indexOf(query);
return 100 - pos - Math.max(0, target.length - query.length);
}
// Token-aware: check each word token too, but require better similarity
const tokens = target.split(/[^a-z0-9]+/g).filter(Boolean);
for (const token of tokens) {
if (token.includes(query)) {
// Only give high score if the match is substantial (not just "and" matching)
const similarity = query.length / Math.max(query.length, token.length);
if (similarity >= 0.6) {
// Require at least 60% similarity
return 80 - Math.abs(token.length - query.length);
}
}
}
const distance = levenshtein(
query,
target.length > 64 ? target.slice(0, 64) : target,
);
const maxLen = Math.max(query.length, target.length, 1);
const similarity = 1 - distance / maxLen; // 0..1
return Math.floor(similarity * 60); // scale below substring scores
}
export function minScoreForQuery(query: string): number {
const len = normalizeText(query).length;
if (len <= 3) return 40;
if (len <= 6) return 30;
return 25;
}
// Decide if a target matches a query based on a threshold
export function isFuzzyMatch(
query: string,
target: string,
minScore?: number,
): boolean {
const threshold =
typeof minScore === "number" ? minScore : minScoreForQuery(query);
return scoreMatch(query, target) >= threshold;
}
// Convenience: rank a list of items by best score across provided getters
export function rankByFuzzy<T>(
items: T[],
query: string,
getters: Array<(item: T) => string>,
minScore?: number,
): Array<{ item: T; score: number; matchedText?: string }> {
const results: Array<{ item: T; score: number; matchedText?: string }> = [];
const threshold =
typeof minScore === "number" ? minScore : minScoreForQuery(query);
for (const item of items) {
let best = 0;
let matchedText = "";
for (const get of getters) {
const value = get(item);
if (!value) continue;
const s = scoreMatch(query, value);
if (s > best) {
best = s;
matchedText = value;
}
if (best >= 95) {
break;
}
}
if (best >= threshold) results.push({ item, score: best, matchedText });
}
results.sort((a, b) => b.score - a.score);
return results;
}
export function normalizeForSearch(text: string): string {
return normalizeText(text);
}
// Convert ids like "addPassword", "add-password", "add_password" to words for matching
export function idToWords(id: string): string {
const spaced = id
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[._-]+/g, " ");
return normalizeText(spaced);
}
@@ -0,0 +1,45 @@
/**
* DOM utility functions for common operations
*/
/**
* Clamps a value between a minimum and maximum
* @param value - The value to clamp
* @param min - The minimum allowed value
* @param max - The maximum allowed value
* @returns The clamped value
*/
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* Safely adds an event listener with proper cleanup
* @param target - The target element or window/document
* @param event - The event type
* @param handler - The event handler function
* @param options - Event listener options
* @returns A cleanup function to remove the listener
*/
export function addEventListenerWithCleanup(
target: EventTarget,
event: string,
handler: EventListener,
options?: boolean | AddEventListenerOptions,
): () => void {
target.addEventListener(event, handler, options);
return () => target.removeEventListener(event, handler, options);
}
/**
* Checks if a click event occurred outside of a specified element
* @param event - The click event
* @param element - The element to check against
* @returns True if the click was outside the element
*/
export function isClickOutside(
event: MouseEvent,
element: HTMLElement | null,
): boolean {
return element ? !element.contains(event.target as Node) : true;
}
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
import type { WorkbenchType } from "@app/types/workbench";
describe("getStartupNavigationAction", () => {
it("returns viewer + active index for 0->1 transition when not in fileEditor", () => {
expect(
getStartupNavigationAction(0, 1, null, "viewer" as WorkbenchType),
).toEqual({
workbench: "viewer",
activeFileIndex: 0,
});
expect(
getStartupNavigationAction(0, 1, null, "pageEditor" as WorkbenchType),
).toEqual({
workbench: "viewer",
activeFileIndex: 0,
});
});
it("returns fileEditor for 0->2+ transition when not in fileEditor", () => {
expect(
getStartupNavigationAction(0, 2, null, "viewer" as WorkbenchType),
).toEqual({
workbench: "fileEditor",
});
});
it("does not force navigation for pdfTextEditor or multiTool", () => {
expect(
getStartupNavigationAction(
0,
1,
"pdfTextEditor",
"viewer" as WorkbenchType,
),
).toBeNull();
expect(
getStartupNavigationAction(
0,
3,
"pdfTextEditor",
"viewer" as WorkbenchType,
),
).toBeNull();
expect(
getStartupNavigationAction(
0,
1,
"multiTool",
"pageEditor" as WorkbenchType,
),
).toBeNull();
expect(
getStartupNavigationAction(
0,
3,
"multiTool",
"pageEditor" as WorkbenchType,
),
).toBeNull();
});
it("does not navigate when file count decreases", () => {
expect(
getStartupNavigationAction(2, 1, null, "viewer" as WorkbenchType),
).toBeNull();
expect(
getStartupNavigationAction(3, 1, null, "fileEditor" as WorkbenchType),
).toBeNull();
});
it("navigates to last file when already in viewer and files are added", () => {
expect(
getStartupNavigationAction(1, 2, null, "viewer" as WorkbenchType),
).toEqual({
workbench: "viewer",
activeFileIndex: 1,
});
expect(
getStartupNavigationAction(3, 5, null, "viewer" as WorkbenchType),
).toEqual({
workbench: "viewer",
activeFileIndex: 4,
});
});
it("does not navigate when adding files in non-viewer workbenches", () => {
expect(
getStartupNavigationAction(1, 2, null, "fileEditor" as WorkbenchType),
).toBeNull();
expect(
getStartupNavigationAction(3, 4, null, "fileEditor" as WorkbenchType),
).toBeNull();
expect(
getStartupNavigationAction(1, 3, null, "pageEditor" as WorkbenchType),
).toBeNull();
});
it("handles all workbench types consistently for 0→N transitions", () => {
// 0→1 always goes to viewer regardless of current workbench (since default is viewer)
expect(
getStartupNavigationAction(0, 1, null, "viewer" as WorkbenchType),
).toEqual({
workbench: "viewer",
activeFileIndex: 0,
});
expect(
getStartupNavigationAction(0, 1, null, "fileEditor" as WorkbenchType),
).toEqual({
workbench: "viewer",
activeFileIndex: 0,
});
expect(
getStartupNavigationAction(0, 1, null, "pageEditor" as WorkbenchType),
).toEqual({
workbench: "viewer",
activeFileIndex: 0,
});
expect(
getStartupNavigationAction(
0,
1,
null,
"custom:formFill" as WorkbenchType,
),
).toEqual({
workbench: "viewer",
activeFileIndex: 0,
});
// 0→N (N>1) always goes to fileEditor
expect(
getStartupNavigationAction(0, 3, null, "viewer" as WorkbenchType),
).toEqual({
workbench: "fileEditor",
});
expect(
getStartupNavigationAction(0, 3, null, "custom:myTool" as WorkbenchType),
).toEqual({
workbench: "fileEditor",
});
});
});
@@ -0,0 +1,42 @@
import type { WorkbenchType } from "@app/types/workbench";
export type StartupWorkbench = "viewer" | "fileEditor";
export interface StartupNavigationAction {
workbench: StartupWorkbench;
activeFileIndex?: number;
}
export function getStartupNavigationAction(
previousFileCount: number,
currentFileCount: number,
selectedToolKey: string | null,
currentWorkbench: WorkbenchType,
): StartupNavigationAction | null {
// These tools manage their own state when files are added and should not be
// auto-navigated away from their workbench.
if (selectedToolKey === "pdfTextEditor" || selectedToolKey === "multiTool") {
return null;
}
// Already actively viewing in the viewer → update to the latest file
if (
previousFileCount > 0 &&
currentWorkbench === "viewer" &&
currentFileCount > previousFileCount
) {
return { workbench: "viewer", activeFileIndex: currentFileCount - 1 };
}
// From landing page (no prior files)
if (previousFileCount === 0) {
if (currentFileCount === 1) {
return { workbench: "viewer", activeFileIndex: 0 };
}
if (currentFileCount > 1) {
return { workbench: "fileEditor" };
}
}
return null;
}
+210
View File
@@ -0,0 +1,210 @@
import { KeyboardEvent as ReactKeyboardEvent } from "react";
export interface HotkeyBinding {
code: string;
alt?: boolean;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}
const MODIFIER_CODES = new Set([
"ShiftLeft",
"ShiftRight",
"ControlLeft",
"ControlRight",
"AltLeft",
"AltRight",
"MetaLeft",
"MetaRight",
]);
const CODE_LABEL_MAP: Record<string, string> = {
Minus: "-",
Equal: "=",
Backquote: "`",
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
IntlBackslash: "\\",
Semicolon: ";",
Quote: "'",
Comma: ",",
Period: ".",
Slash: "/",
Space: "Space",
Tab: "Tab",
Escape: "Esc",
Enter: "Enter",
NumpadEnter: "Num Enter",
NumpadAdd: "Num +",
NumpadSubtract: "Num -",
NumpadMultiply: "Num *",
NumpadDivide: "Num /",
NumpadDecimal: "Num .",
NumpadComma: "Num ,",
NumpadEqual: "Num =",
};
export const isMacLike = (): boolean => {
if (typeof navigator === "undefined") {
return false;
}
const platform = navigator.platform?.toLowerCase() ?? "";
const userAgent = navigator.userAgent?.toLowerCase() ?? "";
return (
/mac|iphone|ipad|ipod/.test(platform) ||
/mac|iphone|ipad|ipod/.test(userAgent)
);
};
export const isModifierCode = (code: string): boolean =>
MODIFIER_CODES.has(code);
const isFunctionKey = (code: string): boolean => /^F\d{1,2}$/.test(code);
export const bindingEquals = (
a?: HotkeyBinding | null,
b?: HotkeyBinding | null,
): boolean => {
if (!a && !b) return true;
if (!a || !b) return false;
return (
a.code === b.code &&
Boolean(a.alt) === Boolean(b.alt) &&
Boolean(a.ctrl) === Boolean(b.ctrl) &&
Boolean(a.meta) === Boolean(b.meta) &&
Boolean(a.shift) === Boolean(b.shift)
);
};
export const bindingMatchesEvent = (
binding: HotkeyBinding,
event: KeyboardEvent,
): boolean => {
return (
event.code === binding.code &&
event.altKey === Boolean(binding.alt) &&
event.ctrlKey === Boolean(binding.ctrl) &&
event.metaKey === Boolean(binding.meta) &&
event.shiftKey === Boolean(binding.shift)
);
};
export const eventToBinding = (
event: KeyboardEvent | ReactKeyboardEvent,
): HotkeyBinding | null => {
const code = event.code;
if (!code || isModifierCode(code)) {
return null;
}
const binding: HotkeyBinding = {
code,
alt: event.altKey,
ctrl: event.ctrlKey,
meta: event.metaKey,
shift: event.shiftKey,
};
// Require at least one modifier to avoid clashing with text input
if (!binding.alt && !binding.ctrl && !binding.meta) {
return null;
}
return binding;
};
const getKeyLabel = (code: string): string => {
if (CODE_LABEL_MAP[code]) {
return CODE_LABEL_MAP[code];
}
if (code.startsWith("Key")) {
return code.slice(3);
}
if (code.startsWith("Digit")) {
return code.slice(5);
}
if (code.startsWith("Numpad")) {
const remainder = code.slice(6);
if (/^[0-9]$/.test(remainder)) {
return `Num ${remainder}`;
}
return `Num ${remainder}`;
}
// Match function keys (F1-F12)
if (isFunctionKey(code)) {
return code;
}
switch (code) {
case "ArrowUp":
return "↑";
case "ArrowDown":
return "↓";
case "ArrowLeft":
return "←";
case "ArrowRight":
return "→";
default:
return code;
}
};
export const getDisplayParts = (
binding: HotkeyBinding | null | undefined,
macLike: boolean,
): string[] => {
if (!binding) return [];
const parts: string[] = [];
if (binding.meta) {
parts.push(macLike ? "⌘" : "Win");
}
if (binding.ctrl) {
parts.push(macLike ? "⌃" : "Ctrl");
}
if (binding.alt) {
parts.push(macLike ? "⌥" : "Alt");
}
if (binding.shift) {
parts.push(macLike ? "⇧" : "Shift");
}
parts.push(getKeyLabel(binding.code));
return parts;
};
export const serializeBindings = (
bindings: Record<string, HotkeyBinding>,
): string => {
return JSON.stringify(bindings);
};
export const deserializeBindings = (
value: string | null | undefined,
): Record<string, HotkeyBinding> => {
if (!value) {
return {};
}
try {
const parsed = JSON.parse(value) as Record<string, HotkeyBinding>;
if (typeof parsed !== "object" || parsed === null) {
return {};
}
return parsed;
} catch (error) {
console.warn("Failed to parse stored hotkey bindings", error);
return {};
}
};
export const normalizeBinding = (binding: HotkeyBinding): HotkeyBinding => ({
code: binding.code,
alt: Boolean(binding.alt),
ctrl: Boolean(binding.ctrl),
meta: Boolean(binding.meta),
shift: Boolean(binding.shift),
});
@@ -0,0 +1,304 @@
import { getPdfiumModule, saveRawDocument } from "@app/services/pdfiumService";
import { copyRgbaToBgraHeap } from "@app/utils/pdfiumBitmapUtils";
export interface ImageToPdfOptions {
imageResolution?: "full" | "reduced";
pageFormat?: "keep" | "A4" | "letter";
stretchToFit?: boolean;
}
// Standard page sizes in PDF points (72 dpi)
const PAGE_SIZES = {
A4: [595.276, 841.89] as [number, number],
Letter: [612, 792] as [number, number],
};
/**
* Convert an image file to a PDF file using PDFium WASM.
*/
export async function convertImageToPdf(
imageFile: File,
options: ImageToPdfOptions = {},
): Promise<File> {
const {
imageResolution = "full",
pageFormat = "A4",
stretchToFit = false,
} = options;
try {
const m = await getPdfiumModule();
// Read the image file
let imageBlob: Blob = imageFile;
// Apply image resolution reduction if requested
if (imageResolution === "reduced") {
imageBlob = await reduceImageResolution(imageFile, 1200);
}
// Decode image to RGBA pixels via canvas
const decoded = await decodeImageToRgba(imageBlob);
if (!decoded) {
throw new Error("Failed to decode image");
}
const { rgba, width: imageWidth, height: imageHeight } = decoded;
// Determine page dimensions
let pageWidth: number;
let pageHeight: number;
if (pageFormat === "keep") {
pageWidth = imageWidth;
pageHeight = imageHeight;
} else if (pageFormat === "letter") {
[pageWidth, pageHeight] = PAGE_SIZES.Letter;
} else {
[pageWidth, pageHeight] = PAGE_SIZES.A4;
}
// Adjust orientation to match image
if (pageFormat !== "keep") {
const imageIsLandscape = imageWidth > imageHeight;
const pageIsLandscape = pageWidth > pageHeight;
if (imageIsLandscape !== pageIsLandscape) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
}
// Calculate image placement
let drawX: number;
let drawY: number;
let drawWidth: number;
let drawHeight: number;
if (stretchToFit || pageFormat === "keep") {
drawX = 0;
drawY = 0;
drawWidth = pageWidth;
drawHeight = pageHeight;
} else {
const imageAspectRatio = imageWidth / imageHeight;
const pageAspectRatio = pageWidth / pageHeight;
if (imageAspectRatio > pageAspectRatio) {
drawWidth = pageWidth;
drawHeight = pageWidth / imageAspectRatio;
drawX = 0;
drawY = (pageHeight - drawHeight) / 2;
} else {
drawHeight = pageHeight;
drawWidth = pageHeight * imageAspectRatio;
drawY = 0;
drawX = (pageWidth - drawWidth) / 2;
}
}
// Create new PDF document
const docPtr = m.FPDF_CreateNewDocument();
if (!docPtr) throw new Error("PDFium: failed to create document");
try {
// Create a page
const pagePtr = m.FPDFPage_New(docPtr, 0, pageWidth, pageHeight);
if (!pagePtr) throw new Error("PDFium: failed to create page");
// Create bitmap from RGBA data (PDFium uses BGRA)
const bitmapPtr = m.FPDFBitmap_Create(imageWidth, imageHeight, 1);
if (!bitmapPtr) throw new Error("PDFium: failed to create bitmap");
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
// Bulk RGBA → BGRA copy via shared utility
copyRgbaToBgraHeap(m, rgba, bufferPtr, imageWidth, imageHeight, stride);
// Create image page object
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
if (!imageObjPtr) {
m.FPDFBitmap_Destroy(bitmapPtr);
throw new Error("PDFium: failed to create image object");
}
const setBitmapOk = m.FPDFImageObj_SetBitmap(
pagePtr,
0,
imageObjPtr,
bitmapPtr,
);
m.FPDFBitmap_Destroy(bitmapPtr);
if (!setBitmapOk) {
m.FPDFPageObj_Destroy(imageObjPtr);
throw new Error("PDFium: failed to set bitmap on image object");
}
// Set transformation matrix: scale + translate
// FS_MATRIX: {a, b, c, d, e, f} — 6 floats
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
m.pdfium.setValue(matrixPtr, drawWidth, "float"); // a = scaleX
m.pdfium.setValue(matrixPtr + 4, 0, "float"); // b
m.pdfium.setValue(matrixPtr + 8, 0, "float"); // c
m.pdfium.setValue(matrixPtr + 12, drawHeight, "float"); // d = scaleY
m.pdfium.setValue(matrixPtr + 16, drawX, "float"); // e = translateX
m.pdfium.setValue(matrixPtr + 20, drawY, "float"); // f = translateY
const setMatrixOk = m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr);
m.pdfium.wasmExports.free(matrixPtr);
if (!setMatrixOk) {
m.FPDFPageObj_Destroy(imageObjPtr);
throw new Error("PDFium: failed to set image matrix");
}
// Insert image into page
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
// Generate page content stream
m.FPDFPage_GenerateContent(pagePtr);
m.FPDF_ClosePage(pagePtr);
// Save document
const pdfBytes = await saveRawDocument(docPtr);
const pdfFilename = imageFile.name.replace(/\.[^.]+$/, ".pdf");
return new File([pdfBytes], pdfFilename, { type: "application/pdf" });
} finally {
m.FPDF_CloseDocument(docPtr);
}
} catch (error) {
console.error("Error converting image to PDF:", error);
throw new Error(
`Failed to convert image to PDF: ${error instanceof Error ? error.message : "Unknown error"}`,
{
cause: error,
},
);
}
}
/**
* Decode an image Blob to RGBA pixel data via canvas.
*/
function decodeImageToRgba(
imageBlob: Blob,
): Promise<{ rgba: Uint8Array; width: number; height: number } | null> {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(imageBlob);
img.onload = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
resolve(null);
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
resolve({
rgba: new Uint8Array(imageData.data.buffer),
width: canvas.width,
height: canvas.height,
});
} catch {
URL.revokeObjectURL(url);
resolve(null);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
img.src = url;
});
}
/**
* Reduce image resolution to a maximum dimension
*/
async function reduceImageResolution(
imageFile: File,
maxDimension: number,
): Promise<File> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(imageFile);
img.onload = () => {
try {
const { width, height } = img;
if (width <= maxDimension && height <= maxDimension) {
URL.revokeObjectURL(url);
resolve(imageFile);
return;
}
let newWidth: number;
let newHeight: number;
if (width > height) {
newWidth = maxDimension;
newHeight = (height / width) * maxDimension;
} else {
newHeight = maxDimension;
newWidth = (width / height) * maxDimension;
}
const canvas = document.createElement("canvas");
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to get canvas context");
ctx.drawImage(img, 0, 0, newWidth, newHeight);
const outputType = imageFile.type.startsWith("image/")
? imageFile.type
: "image/jpeg";
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("Failed to convert canvas to blob"));
return;
}
const reducedFile = new File([blob], imageFile.name, {
type: outputType,
});
URL.revokeObjectURL(url);
resolve(reducedFile);
},
outputType,
0.9,
);
} catch (error) {
URL.revokeObjectURL(url);
reject(error);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
};
img.src = url;
});
}
/**
* Check if a file is an image
*/
export function isImageFile(file: File): boolean {
return file.type.startsWith("image/");
}
@@ -0,0 +1,145 @@
export interface TransparencyOptions {
lowerBound?: { r: number; g: number; b: number };
upperBound?: { r: number; g: number; b: number };
autoDetectCorner?: boolean;
tolerance?: number;
}
const DEFAULT_LOWER_BOUND = { r: 200, g: 200, b: 200 };
const DEFAULT_UPPER_BOUND = { r: 255, g: 255, b: 255 }; // #FFFFFF
export async function removeWhiteBackground(
imageFile: File | string,
options: TransparencyOptions = {},
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
try {
const result = processImageTransparency(img, options);
resolve(result);
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error("Failed to load image"));
};
if (typeof imageFile === "string") {
img.src = imageFile;
} else {
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target?.result as string;
};
reader.onerror = () => {
reject(new Error("Failed to read image file"));
};
reader.readAsDataURL(imageFile);
}
});
}
function processImageTransparency(
img: HTMLImageElement,
options: TransparencyOptions,
): string {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let lowerBound = options.lowerBound || DEFAULT_LOWER_BOUND;
let upperBound = options.upperBound || DEFAULT_UPPER_BOUND;
if (options.autoDetectCorner) {
const cornerColor = detectCornerColor(imageData);
const tolerance = options.tolerance || 10;
lowerBound = {
r: Math.max(0, cornerColor.r - tolerance),
g: Math.max(0, cornerColor.g - tolerance),
b: Math.max(0, cornerColor.b - tolerance),
};
upperBound = {
r: Math.min(255, cornerColor.r + tolerance),
g: Math.min(255, cornerColor.g + tolerance),
b: Math.min(255, cornerColor.b + tolerance),
};
}
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if (
r >= lowerBound.r &&
r <= upperBound.r &&
g >= lowerBound.g &&
g <= upperBound.g &&
b >= lowerBound.b &&
b <= upperBound.b
) {
data[i + 3] = 0;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
}
function detectCornerColor(imageData: ImageData): {
r: number;
g: number;
b: number;
} {
const { width, height, data } = imageData;
const sampleSize = 5;
const corners = [
{ x: 0, y: 0 }, // top-left
{ x: width - sampleSize, y: 0 }, // top-right
{ x: 0, y: height - sampleSize }, // bottom-left
{ x: width - sampleSize, y: height - sampleSize }, // bottom-right
];
let totalR = 0,
totalG = 0,
totalB = 0;
let samples = 0;
corners.forEach((corner) => {
for (let dy = 0; dy < sampleSize; dy++) {
for (let dx = 0; dx < sampleSize; dx++) {
const x = Math.min(width - 1, Math.max(0, corner.x + dx));
const y = Math.min(height - 1, Math.max(0, corner.y + dy));
const i = (y * width + x) * 4;
totalR += data[i];
totalG += data[i + 1];
totalB += data[i + 2];
samples++;
}
}
});
return {
r: Math.round(totalR / samples),
g: Math.round(totalG / samples),
b: Math.round(totalB / samples),
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
declare global {
interface Window {
cv?: any;
jscanify?: any;
}
}
const OPENCV_SRC = "/vendor/jscanify/opencv.js";
const JSCANIFY_SRC = "/vendor/jscanify/jscanify.js";
let loadPromise: Promise<void> | null = null;
function injectScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
// data-loaded: load event fired. data-loading: in flight.
// Neither: foreign tag (e.g. HMR-preserved); trust the downstream
// global check rather than wait on a load event that may already
// have dispatched.
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${src}"]`,
);
if (existing) {
if (existing.dataset.loaded === "true") {
resolve();
return;
}
if (existing.dataset.loading === "true") {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener(
"error",
() => reject(new Error(`Failed to load ${src}`)),
{ once: true },
);
return;
}
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.loading = "true";
script.onload = () => {
script.dataset.loaded = "true";
delete script.dataset.loading;
resolve();
};
script.onerror = () => {
delete script.dataset.loading;
reject(new Error(`Failed to load ${src}`));
};
document.head.appendChild(script);
});
}
function waitForGlobal(
check: () => boolean,
name: string,
timeoutMs: number,
onProgress?: (elapsedMs: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now();
const tick = () => {
if (check()) {
resolve();
return;
}
const elapsed = Date.now() - start;
if (elapsed > timeoutMs) {
reject(new Error(`Timed out waiting for ${name}`));
return;
}
onProgress?.(elapsed);
setTimeout(tick, 100);
};
tick();
});
}
export interface LoadJscanifyOptions {
onStatus?: (status: string) => void;
}
export function loadJscanify(options: LoadJscanifyOptions = {}): Promise<void> {
const { onStatus } = options;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
onStatus?.("Loading OpenCV...");
await injectScript(OPENCV_SRC);
// OpenCV's script load event fires before the WASM runtime is ready.
await waitForGlobal(
() => !!window.cv && !!window.cv.Mat,
"OpenCV runtime (cv.Mat)",
15000,
(elapsed) => {
if (elapsed > 2000) onStatus?.("Initializing OpenCV runtime...");
},
);
onStatus?.("Loading jscanify...");
await injectScript(JSCANIFY_SRC);
await waitForGlobal(() => !!window.jscanify, "jscanify global", 5000);
onStatus?.("Scanner ready");
})().catch((err) => {
loadPromise = null;
throw err;
});
return loadPromise;
}
@@ -0,0 +1,53 @@
import {
ProcessedFileMetadata,
ProcessedFilePage,
StirlingFileStub,
} from "@app/types/fileContext";
export interface PageDimensions {
width: number | null;
height: number | null;
}
export function getPageDimensions(
page?: ProcessedFilePage | null,
): PageDimensions {
const width =
typeof page?.width === "number" && page.width > 0 ? page.width : null;
const height =
typeof page?.height === "number" && page.height > 0 ? page.height : null;
return { width, height };
}
export function getFirstPageDimensionsFromMetadata(
metadata?: ProcessedFileMetadata | null,
): PageDimensions {
if (!metadata?.pages?.length) {
return { width: null, height: null };
}
return getPageDimensions(metadata.pages[0]);
}
export function getFirstPageDimensionsFromStub(
file?: StirlingFileStub,
): PageDimensions {
return getFirstPageDimensionsFromMetadata(file?.processedFile);
}
export function getFirstPageAspectRatioFromMetadata(
metadata?: ProcessedFileMetadata | null,
): number | null {
const { width, height } = getFirstPageDimensionsFromMetadata(metadata);
if (width && height) {
return height / width;
}
return null;
}
export function getFirstPageAspectRatioFromStub(
file?: StirlingFileStub,
): number | null {
return getFirstPageAspectRatioFromMetadata(file?.processedFile);
}
@@ -0,0 +1,23 @@
export const validatePageNumbers = (pageNumbers: string): boolean => {
if (!pageNumbers.trim()) return false;
// Normalize input for validation: remove spaces around commas and other spaces
const normalized = pageNumbers.replace(/\s*,\s*/g, ",").replace(/\s+/g, "");
const parts = normalized.split(",");
// Regular expressions for different page number formats
const allToken = /^all$/i; // Select all pages
const singlePageRegex = /^[1-9]\d*$/; // Single page: positive integers only (no 0)
const rangeRegex = /^[1-9]\d*-(?:[1-9]\d*)?$/; // Range: 1-5 or open range 10-
const mathRegex = /^(?=.*n)[0-9n+\-*/() ]+$/; // Mathematical expressions with n and allowed chars
return parts.every((part) => {
if (!part) return false;
return (
allToken.test(part) ||
singlePageRegex.test(part) ||
rangeRegex.test(part) ||
mathRegex.test(part)
);
});
};
@@ -0,0 +1,99 @@
// Browser page translators (Edge, Chrome, extensions) wrap text nodes in
// injected <font> elements, reparenting nodes React is holding. React's commit
// phase then throws NotFoundError on removeChild/insertBefore and the
// ErrorBoundary unmounts the app. https://github.com/facebook/react/issues/11538
//
// We watch for translator fingerprints (Google Translate's translated-* class
// on <html>, or any injected <font>) and install guards on Node.prototype only
// once one appears, so native DOM semantics are preserved when no translator
// is active.
declare global {
interface Node {
__stirlingTranslatorPatched?: boolean;
}
}
let patchApplied = false;
function isGoogleTranslateActive(): boolean {
const cls = document.documentElement.classList;
return cls.contains("translated-ltr") || cls.contains("translated-rtl");
}
function applyDomPatch(trigger: string): void {
if (patchApplied) return;
if (typeof Node === "undefined" || !Node.prototype) return;
if (Node.prototype.__stirlingTranslatorPatched) return;
patchApplied = true;
Node.prototype.__stirlingTranslatorPatched = true;
console.warn(
`[dom-patch] Browser page translator detected (${trigger}). ` +
"Installing removeChild/insertBefore guards to prevent React crashes. " +
"The UI may show minor glitches while the translator is active.",
);
const originalRemoveChild = Node.prototype.removeChild;
Node.prototype.removeChild = function patchedRemoveChild<T extends Node>(
this: Node,
child: T,
): T {
if (child.parentNode !== this) return child;
return originalRemoveChild.call(this, child) as T;
} as typeof Node.prototype.removeChild;
const originalInsertBefore = Node.prototype.insertBefore;
Node.prototype.insertBefore = function patchedInsertBefore<T extends Node>(
this: Node,
newNode: T,
referenceNode: Node | null,
): T {
if (referenceNode && referenceNode.parentNode !== this) return newNode;
return originalInsertBefore.call(this, newNode, referenceNode) as T;
} as typeof Node.prototype.insertBefore;
}
export function armTranslatorDetector(): void {
if (typeof window === "undefined" || typeof MutationObserver === "undefined")
return;
if (typeof document === "undefined" || !document.documentElement) return;
// Edge case: class already set (e.g., bfcache restore).
if (isGoogleTranslateActive()) {
applyDomPatch("html class was already translated-* on arm");
return;
}
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (
m.type === "attributes" &&
m.target === document.documentElement &&
isGoogleTranslateActive()
) {
applyDomPatch("<html> translated-* class appeared");
observer.disconnect();
return;
}
if (m.type === "childList") {
for (const n of m.addedNodes) {
if (n.nodeName === "FONT") {
applyDomPatch("<font> element injected into DOM");
observer.disconnect();
return;
}
}
}
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
childList: true,
subtree: true,
});
}
armTranslatorDetector();
@@ -0,0 +1,366 @@
/**
* pdfLinkUtils — Create, modify, and extract link annotations in PDF documents.
*
* Migrated from @cantoo/pdf-lib to @embedpdf/pdfium WASM.
* All operations are performed via PDFium C API wrappers.
*/
import {
getPdfiumModule,
openRawDocumentSafe,
closeDocAndFreeBuffer,
saveRawDocument,
readUtf16,
writeUtf16,
readAnnotRectAdjusted,
parseRectToCss,
} from "@app/services/pdfiumService";
import {
FPDF_ANNOT_LINK,
PDFACTION_GOTO,
PDFACTION_URI,
} from "@app/utils/pdfiumBitmapUtils";
export type LinkType = "internal" | "external" | "unknown";
export type LinkBorderStyle =
| "solid"
| "dashed"
| "beveled"
| "inset"
| "underline";
export type LinkHighlightMode = "none" | "invert" | "outline" | "push";
export interface PdfLibLink {
id: string;
/** Index of this annotation in the page's /Annots array (used for deletion matching). */
annotIndex: number;
/** Rectangle in PDF-page coordinate space (top-left origin, unscaled). */
rect: { x: number; y: number; width: number; height: number };
type: LinkType;
/** 0-based target page index (internal links). */
targetPage?: number;
/** URI for external links. */
uri?: string;
/** Tooltip / alt text from the /Contents entry. */
title?: string;
/** RGB color of the link annotation border (each component 01). */
color?: [number, number, number];
/** Border width and style. */
borderStyle?: { width: number; style: LinkBorderStyle };
/** Visual feedback when the link is clicked. */
highlightMode?: LinkHighlightMode;
}
export interface CreateLinkOptions {
/** 0-based page index. */
pageIndex: number;
/** Link rectangle in CSS (top-left origin) coordinate space. */
rect: { x: number; y: number; width: number; height: number };
/** External URL (mutually exclusive with destinationPage). */
url?: string;
/** Internal destination page index, 0-based (mutually exclusive with url). */
destinationPage?: number;
/** Tooltip text shown on hover (stored in /Contents). */
title?: string;
/** RGB colour for the border, each component 01. Defaults to blue. */
color?: [number, number, number];
/** Border width in points. 0 = invisible (PDF convention). */
borderWidth?: number;
/** Border line style. */
borderStyle?: LinkBorderStyle;
/** Visual feedback when the link is clicked. */
highlightMode?: LinkHighlightMode;
}
/**
* Create a link annotation on a PDF page.
* Mutates the document in-place and returns the updated PDF bytes.
*/
export async function createLinkAnnotation(
data: ArrayBuffer | Uint8Array,
options: CreateLinkOptions,
password?: string,
): Promise<ArrayBuffer> {
const {
pageIndex,
rect,
url,
destinationPage,
title,
color = [0, 0, 1],
borderWidth = 0,
} = options;
if (!url && destinationPage === undefined) {
throw new Error(
"createLinkAnnotation: must provide either url or destinationPage",
);
}
if (url && destinationPage !== undefined) {
throw new Error(
"createLinkAnnotation: url and destinationPage are mutually exclusive",
);
}
if (rect.width <= 0 || rect.height <= 0) {
throw new Error("createLinkAnnotation: rect dimensions must be positive");
}
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(data, password);
try {
const pageCount = m.FPDF_GetPageCount(docPtr);
if (
destinationPage !== undefined &&
(destinationPage < 0 || destinationPage >= pageCount)
) {
throw new RangeError(
`createLinkAnnotation: destinationPage ${destinationPage} out of range [0, ${pageCount})`,
);
}
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) throw new Error(`Failed to load page ${pageIndex}`);
try {
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
const annotPtr = m.FPDFPage_CreateAnnot(pagePtr, FPDF_ANNOT_LINK);
if (!annotPtr) {
throw new Error("Failed to create link annotation");
}
try {
// Set rect (convert from CSS top-left to PDF bottom-left origin)
// FS_RECTF layout: { left, top, right, bottom } where top > bottom in PDF coords
const pdfLeft = rect.x;
const pdfTop = pageHeight - rect.y; // CSS y=0 → PDF top
const pdfRight = rect.x + rect.width;
const pdfBottom = pageHeight - rect.y - rect.height; // CSS bottom → PDF bottom
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
m.pdfium.setValue(rectBuf, pdfLeft, "float"); // offset 0: left
m.pdfium.setValue(rectBuf + 4, pdfTop, "float"); // offset 4: top (larger y)
m.pdfium.setValue(rectBuf + 8, pdfRight, "float"); // offset 8: right
m.pdfium.setValue(rectBuf + 12, pdfBottom, "float"); // offset 12: bottom (smaller y)
m.FPDFAnnot_SetRect(annotPtr, rectBuf);
m.pdfium.wasmExports.free(rectBuf);
// Set color
// FPDFANNOT_COLORTYPE_Color = 0
m.FPDFAnnot_SetColor(
annotPtr,
0,
Math.round(color[0] * 255),
Math.round(color[1] * 255),
Math.round(color[2] * 255),
255,
);
// Set border
m.FPDFAnnot_SetBorder(annotPtr, 0, 0, borderWidth);
// Set URI for external links
if (url) {
const uriPtr = writeUtf16(m, url);
m.FPDFAnnot_SetURI(annotPtr, uriPtr);
m.pdfium.wasmExports.free(uriPtr);
}
// Set title / contents
if (title) {
const titlePtr = writeUtf16(m, title);
m.FPDFAnnot_SetStringValue(annotPtr, "Contents", titlePtr);
m.pdfium.wasmExports.free(titlePtr);
}
} finally {
m.FPDFPage_CloseAnnot(annotPtr);
}
} finally {
m.FPDF_ClosePage(pagePtr);
}
return await saveRawDocument(docPtr);
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
}
/**
* Remove a link annotation from a page by its index.
* Returns the updated PDF bytes.
*/
export async function removeLinkAnnotation(
data: ArrayBuffer | Uint8Array,
pageIndex: number,
annotIndex: number,
password?: string,
): Promise<ArrayBuffer> {
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(data, password);
try {
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) throw new Error(`Failed to load page ${pageIndex}`);
m.FPDFPage_RemoveAnnot(pagePtr, annotIndex);
m.FPDF_ClosePage(pagePtr);
return await saveRawDocument(docPtr);
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
}
/**
* Extract all link annotations from a given PDF page.
*/
export async function extractLinksFromPage(
data: ArrayBuffer | Uint8Array,
pageIndex: number,
password?: string,
): Promise<{
links: PdfLibLink[];
pdfPageWidth: number;
pdfPageHeight: number;
}> {
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(data, password);
try {
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) return { links: [], pdfPageWidth: 0, pdfPageHeight: 0 };
const pageWidth = m.FPDF_GetPageWidthF(pagePtr);
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
const links: PdfLibLink[] = [];
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
for (let i = 0; i < annotCount; i++) {
try {
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, i);
if (!annotPtr) continue;
const subtype = m.FPDFAnnot_GetSubtype(annotPtr);
if (subtype !== FPDF_ANNOT_LINK) {
m.FPDFPage_CloseAnnot(annotPtr);
continue;
}
// Get rect (CropBox-adjusted for correct overlay positioning)
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
const hasRect = readAnnotRectAdjusted(m, annotPtr, rectBuf);
if (!hasRect) {
m.pdfium.wasmExports.free(rectBuf);
m.FPDFPage_CloseAnnot(annotPtr);
continue;
}
const rect = parseRectToCss(m, rectBuf, pageHeight);
m.pdfium.wasmExports.free(rectBuf);
// Try to get link object
const linkPtr = m.FPDFAnnot_GetLink(annotPtr);
let linkType: LinkType = "unknown";
let targetPage: number | undefined;
let uri: string | undefined;
if (linkPtr) {
// Check for action
const actionPtr = m.FPDFLink_GetAction(linkPtr);
if (actionPtr) {
const actionType = m.FPDFAction_GetType(actionPtr);
if (actionType === PDFACTION_URI) {
const uriLen = m.FPDFAction_GetURIPath(docPtr, actionPtr, 0, 0);
if (uriLen > 0) {
const uriBuf = m.pdfium.wasmExports.malloc(uriLen);
m.FPDFAction_GetURIPath(docPtr, actionPtr, uriBuf, uriLen);
uri = m.pdfium.UTF8ToString(uriBuf);
m.pdfium.wasmExports.free(uriBuf);
linkType = "external";
}
} else if (actionType === PDFACTION_GOTO) {
const destPtr = m.FPDFAction_GetDest(docPtr, actionPtr);
if (destPtr) {
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
linkType = "internal";
}
}
}
// Check for direct destination
if (linkType === "unknown") {
const destPtr = m.FPDFLink_GetDest(docPtr, linkPtr);
if (destPtr) {
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
linkType = "internal";
}
}
}
// Get title from /Contents
let title: string | undefined;
const contentsLen = m.FPDFAnnot_GetStringValue(
annotPtr,
"Contents",
0,
0,
);
if (contentsLen > 2) {
const contentsBuf = m.pdfium.wasmExports.malloc(contentsLen);
m.FPDFAnnot_GetStringValue(
annotPtr,
"Contents",
contentsBuf,
contentsLen,
);
title = readUtf16(m, contentsBuf, contentsLen) || undefined;
m.pdfium.wasmExports.free(contentsBuf);
}
// Get color
let color: [number, number, number] | undefined;
// allocate 4 uint for RGBA
const rPtr = m.pdfium.wasmExports.malloc(16);
const gPtr = rPtr + 4;
const bPtr = rPtr + 8;
const aPtr = rPtr + 12;
const hasColor = m.FPDFAnnot_GetColor(
annotPtr,
0,
rPtr,
gPtr,
bPtr,
aPtr,
);
if (hasColor) {
color = [
m.pdfium.getValue(rPtr, "i32") / 255,
m.pdfium.getValue(gPtr, "i32") / 255,
m.pdfium.getValue(bPtr, "i32") / 255,
];
}
m.pdfium.wasmExports.free(rPtr);
links.push({
id: `link-${pageIndex}-${i}`,
annotIndex: i,
rect,
type: linkType,
targetPage,
uri,
title,
color,
});
m.FPDFPage_CloseAnnot(annotPtr);
} catch (e) {
console.warn("[pdfLinkUtils] Failed to parse annotation:", e);
}
}
m.FPDF_ClosePage(pagePtr);
return { links, pdfPageWidth: pageWidth, pdfPageHeight: pageHeight };
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
}
@@ -0,0 +1,230 @@
/**
* pdfiumBitmapUtils — Shared utilities for working with PDFium WASM bitmaps.
*
* Centralises the RGBA→BGRA pixel conversion and image-to-page-object
* embedding that previously appeared (copy-pasted) in at least 5 files.
*
* Performance note: instead of calling `m.pdfium.setValue()` per pixel
* (which crosses the JS↔WASM FFI boundary on every call), we perform
* the colour-channel swizzle in a plain JS TypedArray and then bulk-
* copy the result into the WASM heap with a single `HEAPU8.set()`.
*/
import type { WrappedPdfiumModule } from "@embedpdf/pdfium";
/** FPDF_ANNOT_LINK */
export const FPDF_ANNOT_LINK = 4;
/** FPDF_ANNOT_WIDGET (form field) */
export const FPDF_ANNOT_WIDGET = 20;
/** FPDF_ANNOT_INK */
export const FPDF_ANNOT_INK = 15;
/** FPDF_ANNOT_LINE */
export const FPDF_ANNOT_LINE = 3;
/** PDFACTION_GOTO */
export const PDFACTION_GOTO = 1;
/** PDFACTION_URI */
export const PDFACTION_URI = 3;
/** FLAT_PRINT (for FPDFPage_Flatten) */
export const FLAT_PRINT = 2;
/**
* Convert an RGBA pixel buffer to BGRA (PDFium's expected format) **in place
* inside the WASM heap** with a single bulk memcpy.
*
* When `stride === width * 4` the copy is a single `HEAPU8.set()`.
* When the bitmap has padding (stride > width * 4), rows are copied
* individually to skip the padding bytes.
*
* This is ~100× faster than per-pixel `m.pdfium.setValue()` calls for
* large images.
*/
export function copyRgbaToBgraHeap(
m: WrappedPdfiumModule,
rgba: Uint8Array | Uint8ClampedArray,
bufferPtr: number,
width: number,
height: number,
stride: number,
): void {
const rowBytes = width * 4;
if (stride === rowBytes) {
// Fast path: no padding — single bulk copy after swizzle
const bgra = new Uint8Array(rgba.length);
for (let i = 0; i < rgba.length; i += 4) {
bgra[i] = rgba[i + 2]; // B
bgra[i + 1] = rgba[i + 1]; // G
bgra[i + 2] = rgba[i]; // R
bgra[i + 3] = rgba[i + 3]; // A
}
new Uint8Array((m.pdfium.wasmExports as any).memory.buffer).set(
bgra,
bufferPtr,
);
} else {
// Stride has padding — swizzle + copy row by row
const rowBuf = new Uint8Array(rowBytes);
const heap = new Uint8Array((m.pdfium.wasmExports as any).memory.buffer);
for (let y = 0; y < height; y++) {
const srcRowStart = y * rowBytes;
for (let x = 0; x < rowBytes; x += 4) {
rowBuf[x] = rgba[srcRowStart + x + 2]; // B
rowBuf[x + 1] = rgba[srcRowStart + x + 1]; // G
rowBuf[x + 2] = rgba[srcRowStart + x]; // R
rowBuf[x + 3] = rgba[srcRowStart + x + 3]; // A
}
heap.set(rowBuf, bufferPtr + y * stride);
}
}
}
export interface DecodedImage {
rgba: Uint8Array;
width: number;
height: number;
}
/**
* Create a PDFium bitmap from decoded RGBA pixels, attach it to a new image
* page object, position it via an affine matrix, and insert it into the page.
*
* Returns `true` if the image was successfully inserted, `false` otherwise.
* All intermediate WASM resources are cleaned up on failure.
*/
export function embedBitmapImageOnPage(
m: WrappedPdfiumModule,
docPtr: number,
pagePtr: number,
image: DecodedImage,
pdfX: number,
pdfY: number,
drawWidth: number,
drawHeight: number,
): boolean {
const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1);
if (!bitmapPtr) return false;
try {
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
copyRgbaToBgraHeap(
m,
image.rgba,
bufferPtr,
image.width,
image.height,
stride,
);
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
if (!imageObjPtr) return false;
const setBitmapOk = m.FPDFImageObj_SetBitmap(
pagePtr,
0,
imageObjPtr,
bitmapPtr,
);
if (!setBitmapOk) {
m.FPDFPageObj_Destroy(imageObjPtr);
return false;
}
// -- early-destroy the bitmap; PDFium has copied the pixel data internally
m.FPDFBitmap_Destroy(bitmapPtr);
// Set affine transform: [a b c d e f]
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
try {
m.pdfium.setValue(matrixPtr, drawWidth, "float"); // a — scaleX
m.pdfium.setValue(matrixPtr + 4, 0, "float"); // b
m.pdfium.setValue(matrixPtr + 8, 0, "float"); // c
m.pdfium.setValue(matrixPtr + 12, drawHeight, "float"); // d — scaleY
m.pdfium.setValue(matrixPtr + 16, pdfX, "float"); // e — translateX
m.pdfium.setValue(matrixPtr + 20, pdfY, "float"); // f — translateY
if (!m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) {
m.FPDFPageObj_Destroy(imageObjPtr);
return false;
}
} finally {
m.pdfium.wasmExports.free(matrixPtr);
}
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
return true;
} finally {
// Safety net: FPDFBitmap_Destroy is a no-op if ptr is 0 in most PDFium
// builds but guard anyway. If already destroyed above, the second call
// is harmless because we allow it to be idempotent.
// We use a try-catch to be safe across PDFium WASM builds.
try {
m.FPDFBitmap_Destroy(bitmapPtr);
} catch {
/* already freed */
}
}
}
/**
* Draw a simple light-grey rectangle as a placeholder for annotations
* that could not be rendered.
*/
export function drawPlaceholderRect(
m: WrappedPdfiumModule,
pagePtr: number,
x: number,
y: number,
width: number,
height: number,
): void {
const pathPtr = m.FPDFPageObj_CreateNewPath(x, y);
if (!pathPtr) return;
m.FPDFPath_LineTo(pathPtr, x + width, y);
m.FPDFPath_LineTo(pathPtr, x + width, y + height);
m.FPDFPath_LineTo(pathPtr, x, y + height);
m.FPDFPath_Close(pathPtr);
m.FPDFPageObj_SetFillColor(pathPtr, 230, 230, 230, 150);
m.FPDFPageObj_SetStrokeColor(pathPtr, 128, 128, 128, 255);
m.FPDFPageObj_SetStrokeWidth(pathPtr, 1);
// fillMode 1 = alternate fill, stroke = true
m.FPDFPath_SetDrawMode(pathPtr, 1, true);
m.FPDFPage_InsertObject(pagePtr, pathPtr);
}
/**
* Decode an image data URL (e.g. `data:image/png;base64,...`) to raw RGBA
* pixel data via an offscreen canvas.
*/
export function decodeImageDataUrl(
dataUrl: string,
): Promise<DecodedImage | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(null);
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
resolve({
rgba: new Uint8Array(imageData.data.buffer),
width: canvas.width,
height: canvas.height,
});
} catch {
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = dataUrl;
});
}
@@ -0,0 +1,120 @@
/**
* pdfiumPageRender — render a single PDF page from an already-opened PDFium
* document pointer to a canvas data URL.
*
* Shared by the first-page thumbnail path (thumbnailUtils.ts) and the
* per-page thumbnail service (thumbnailGenerationService.ts) so the pixel-
* copy + white-background logic lives in one place.
*/
import { getPdfiumModule } from "@app/services/pdfiumService";
/** FPDF_ANNOT (0x01) | FPDF_LCD_TEXT (0x10). */
const PDFIUM_RENDER_FLAGS = 0x01 | 0x10;
export interface RenderPdfiumPageOptions {
/** When true (default), bake the page's own rotation into the bitmap.
* When false, render upright so callers can apply CSS rotation. */
applyRotation?: boolean;
/** Output format; defaults to PNG. */
format?: "png" | "jpeg";
/** JPEG quality [0,1]; ignored for PNG. */
quality?: number;
}
/**
* Render a single page (0-indexed) of an open PDFium document into a data URL.
*
* The caller is responsible for opening and closing the document pointer.
*/
export async function renderPdfiumPageDataUrl(
docPtr: number,
pageIndex: number,
scale: number,
options: RenderPdfiumPageOptions = {},
): Promise<string | null> {
const { applyRotation = true, format = "png", quality } = options;
const m = await getPdfiumModule();
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) return null;
try {
// Effective dims already account for /Rotate; PDFium auto-applies it during render.
const rawW = m.FPDF_GetPageWidthF(pagePtr);
const rawH = m.FPDF_GetPageHeightF(pagePtr);
// Returns 03 for 0°/90°/180°/270° CW.
const pageRotQuarters = (m as any).FPDFPage_GetRotation(pagePtr) | 0;
const isQuarterTurn = pageRotQuarters === 1 || pageRotQuarters === 3;
// false: swap dims back to raw + counter-rotate to cancel PDFium's auto /Rotate.
const outW = applyRotation ? rawW : isQuarterTurn ? rawH : rawW;
const outH = applyRotation ? rawH : isQuarterTurn ? rawW : rawH;
const renderRotate = applyRotation ? 0 : (4 - pageRotQuarters) % 4;
const w = Math.max(1, Math.round(outW * scale));
const h = Math.max(1, Math.round(outH * scale));
const bitmapPtr = m.FPDFBitmap_Create(w, h, 1);
try {
// White background; PDF content doesn't encode paper colour.
m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, w, h, 0xffffffff);
m.FPDF_RenderPageBitmap(
bitmapPtr,
pagePtr,
0,
0,
w,
h,
renderRotate,
PDFIUM_RENDER_FLAGS,
);
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
const heap = new Uint8Array((m.pdfium.wasmExports as any).memory.buffer);
const pixels = new Uint8ClampedArray(w * h * 4);
// @embedpdf/pdfium WASM stores pixels in RGBA byte order (not BGRA),
// so copy rows directly without channel swapping.
for (let y = 0; y < h; y++) {
const srcRow = bufferPtr + y * stride;
pixels.set(heap.subarray(srcRow, srcRow + w * 4), y * w * 4);
}
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.putImageData(new ImageData(pixels, w, h), 0, 0);
return format === "jpeg"
? canvas.toDataURL("image/jpeg", quality ?? 0.8)
: canvas.toDataURL();
} finally {
m.FPDFBitmap_Destroy(bitmapPtr);
}
} finally {
m.FPDF_ClosePage(pagePtr);
}
}
/**
* Read raw width/height/rotation for a page without rendering.
*/
export async function readPdfiumPageMetadata(
docPtr: number,
pageIndex: number,
): Promise<{ width: number; height: number; rotation: number } | null> {
const m = await getPdfiumModule();
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) return null;
try {
const width = m.FPDF_GetPageWidthF(pagePtr);
const height = m.FPDF_GetPageHeightF(pagePtr);
const rotation = (((m as any).FPDFPage_GetRotation(pagePtr) | 0) & 3) * 90;
return { width, height, rotation };
} finally {
m.FPDF_ClosePage(pagePtr);
}
}
@@ -0,0 +1,71 @@
/**
* Utilities for managing file resources and blob URLs
*/
import { useCallback } from "react";
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
export class ResourceManager {
private static blobUrls = new Set<string>();
/**
* Create a blob URL and track it for cleanup
*/
static createBlobUrl(blob: Blob): string {
const url = URL.createObjectURL(blob);
this.blobUrls.add(url);
return url;
}
/**
* Revoke a specific blob URL
*/
static revokeBlobUrl(url: string): void {
if (this.blobUrls.has(url)) {
URL.revokeObjectURL(url);
this.blobUrls.delete(url);
}
}
/**
* Revoke all tracked blob URLs
*/
static revokeAllBlobUrls(): void {
this.blobUrls.forEach((url) => URL.revokeObjectURL(url));
this.blobUrls.clear();
}
/**
* Create a File with proper naming convention
*/
static createResultFile(
data: BlobPart,
originalName: string,
prefix: string = AUTOMATION_CONSTANTS.PROCESSED_FILE_PREFIX,
type: string = "application/pdf",
): File {
return new File([data], `${prefix}${originalName}`, { type });
}
/**
* Create a timestamped file for responses
*/
static createTimestampedFile(
data: BlobPart,
prefix: string,
extension: string = ".pdf",
type: string = "application/pdf",
): File {
const timestamp = Date.now();
return new File([data], `${prefix}${timestamp}${extension}`, { type });
}
}
/**
* Hook for automatic cleanup on component unmount
*/
export function useResourceCleanup(): () => void {
return useCallback(() => {
ResourceManager.revokeAllBlobUrls();
}, []);
}
@@ -0,0 +1,98 @@
/**
* Scarf analytics pixel tracking utility
*
* This module provides a firePixel function that can be called from anywhere,
* including non-React utility functions. Configuration and consent state are
* injected via setScarfConfig() which should be called from a React hook
* during app initialization.
*
* IMPORTANT: setScarfConfig() must be called before firePixel() will work.
* The initialization hook (useScarfTracking) is mounted in App.tsx.
*
* For testing: Use resetScarfConfig() to clear module state between tests.
*/
// Module-level state
let configured: boolean = false;
let enableScarf: boolean | null = null;
let isServiceAccepted: ((service: string, category: string) => boolean) | null =
null;
let lastFiredPathname: string | null = null;
let lastFiredTime = 0;
/**
* Configure scarf tracking with app config and consent checker
* Should be called from a React hook during app initialization (see useScarfTracking)
*
* @param scarfEnabled - Whether scarf tracking is enabled globally
* @param consentChecker - Function to check if user has accepted scarf service
*/
export function setScarfConfig(
scarfEnabled: boolean | null,
consentChecker: (service: string, category: string) => boolean,
): void {
configured = true;
enableScarf = scarfEnabled;
isServiceAccepted = consentChecker;
}
/**
* Fire scarf pixel for analytics tracking
* Only fires if:
* - Scarf tracking has been initialized via setScarfConfig()
* - Scarf is globally enabled in config
* - User has accepted scarf service via cookie consent
* - Pathname has changed or enough time has passed since last fire
*
* @param pathname - The pathname to track (usually window.location.pathname)
*/
export function firePixel(pathname: string): void {
// Dev-mode warning if called before initialization
if (!configured) {
console.warn(
"[scarfTracking] firePixel() called before setScarfConfig(). " +
"Ensure useScarfTracking() hook is mounted in App.tsx.",
);
return;
}
// Check if Scarf is globally disabled
if (enableScarf === false) {
return;
}
// Check if consent checker is available and scarf service is accepted
if (!isServiceAccepted || !isServiceAccepted("scarf", "analytics")) {
return;
}
const now = Date.now();
// Only fire if pathname changed or it's been at least 250ms since last fire
if (pathname === lastFiredPathname && now - lastFiredTime < 250) {
return;
}
lastFiredPathname = pathname;
lastFiredTime = now;
const url =
"https://static.scarf.sh/a.png?x-pxid=3c1d68de-8945-4e9f-873f-65320b6fabf7" +
"&path=" +
encodeURIComponent(pathname);
const img = new Image();
img.referrerPolicy = "no-referrer-when-downgrade";
img.src = url;
}
/**
* Reset scarf tracking configuration and state
* Useful for testing to ensure clean state between test runs
*/
export function resetScarfConfig(): void {
enableScarf = null;
isServiceAccepted = null;
lastFiredPathname = null;
lastFiredTime = 0;
}
@@ -0,0 +1,63 @@
/**
* Utility for dynamically loading external scripts
*/
interface ScriptLoadOptions {
src: string;
id?: string;
async?: boolean;
defer?: boolean;
onLoad?: () => void;
}
const loadedScripts = new Set<string>();
export function loadScript({
src,
id,
async = true,
defer = false,
onLoad,
}: ScriptLoadOptions): Promise<void> {
return new Promise((resolve, reject) => {
// Check if already loaded
const scriptId = id || src;
if (loadedScripts.has(scriptId)) {
resolve();
return;
}
// Check if script already exists in DOM
const existingScript = id
? document.getElementById(id)
: document.querySelector(`script[src="${src}"]`);
if (existingScript) {
loadedScripts.add(scriptId);
resolve();
return;
}
// Create and append script
const script = document.createElement("script");
script.src = src;
if (id) script.id = id;
script.async = async;
script.defer = defer;
script.onload = () => {
loadedScripts.add(scriptId);
if (onLoad) onLoad();
resolve();
};
script.onerror = () => {
reject(new Error(`Failed to load script: ${src}`));
};
document.head.appendChild(script);
});
}
export function isScriptLoaded(idOrSrc: string): boolean {
return loadedScripts.has(idOrSrc);
}
@@ -0,0 +1,53 @@
import { NavKey } from "@app/components/shared/config/types";
/**
* Navigate to a specific settings section
*
* @param section - The settings section key to navigate to
*
* @example
* // Navigate to People section
* navigateToSettings('people');
*
* // Navigate to Admin Premium section
* navigateToSettings('adminPremium');
*/
export function navigateToSettings(section: NavKey) {
const basePath = window.location.pathname.split("/settings")[0] || "";
const newPath = `${basePath}/settings/${section}`;
window.history.pushState({}, "", newPath);
// Trigger a popstate event to notify components
window.dispatchEvent(new PopStateEvent("popstate"));
}
/**
* Get the URL path for a settings section
* Useful for creating links
*
* @param section - The settings section key
* @returns The URL path for the settings section
*
* @example
* <a href={getSettingsUrl('people')}>Go to People Settings</a>
* // Returns: "/settings/people"
*/
export function getSettingsUrl(section: NavKey): string {
return `/settings/${section}`;
}
/**
* Check if currently viewing a settings section
*
* @param section - Optional section key to check for specific section
* @returns True if in settings (and matching specific section if provided)
*/
export function isInSettings(section?: NavKey): boolean {
const pathname = window.location.pathname;
if (!section) {
return pathname.startsWith("/settings");
}
return pathname === `/settings/${section}`;
}
@@ -0,0 +1,181 @@
/**
* Helper utilities for handling settings with pending changes that require restart.
*
* Backend returns settings in this format:
* {
* "enableLogin": false, // Current active value
* "csrfDisabled": true,
* "_pending": { // Optional - only present if there are pending changes
* "enableLogin": true // Value that will be active after restart
* }
* }
*/
export interface SettingsWithPending<T = any> {
_pending?: Partial<T>;
[key: string]: any;
}
/**
* Merge pending changes into the settings object.
* Returns a new object with pending values overlaid on top of current values.
*
* @param settings Settings object from backend (may contain _pending block)
* @returns Merged settings with pending values applied
*/
export function mergePendingSettings<T extends SettingsWithPending>(
settings: T,
): Omit<T, "_pending"> {
if (!settings || !settings._pending) {
// No pending changes, return as-is (without _pending property)
const { _pending, ...rest } = settings || {};
return rest as Omit<T, "_pending">;
}
// Deep merge pending changes
const merged = deepMerge(settings, settings._pending);
// Remove _pending from result
const { _pending, ...result } = merged;
return result as Omit<T, "_pending">;
}
/**
* Check if a specific field has a pending change awaiting restart.
*
* @param settings Settings object from backend
* @param fieldPath Dot-notation path to the field (e.g., "oauth2.clientSecret")
* @returns True if field has pending changes
*/
export function isFieldPending<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string,
): boolean {
if (!settings?._pending) {
console.log(
`[isFieldPending] No _pending block found for field: ${fieldPath}`,
);
return false;
}
// Navigate the pending object using dot notation
const value = getNestedValue(settings._pending, fieldPath);
const isPending = value !== undefined;
if (isPending) {
console.log(
`[isFieldPending] Field ${fieldPath} IS pending with value:`,
value,
);
}
return isPending;
}
/**
* Check if there are any pending changes in the settings.
*
* @param settings Settings object from backend
* @returns True if there are any pending changes
*/
export function hasPendingChanges<T extends SettingsWithPending>(
settings: T | null | undefined,
): boolean {
return (
settings?._pending !== undefined &&
Object.keys(settings._pending).length > 0
);
}
/**
* Get the pending value for a specific field, or undefined if no pending change.
*
* @param settings Settings object from backend
* @param fieldPath Dot-notation path to the field
* @returns Pending value or undefined
*/
export function getPendingValue<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string,
): any {
if (!settings?._pending) {
return undefined;
}
return getNestedValue(settings._pending, fieldPath);
}
/**
* Get the current active value for a field (ignoring pending changes).
*
* @param settings Settings object from backend
* @param fieldPath Dot-notation path to the field
* @returns Current active value
*/
export function getCurrentValue<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string,
): any {
if (!settings) {
return undefined;
}
// Get from settings, ignoring _pending
const { _pending, ...activeSettings } = settings;
return getNestedValue(activeSettings, fieldPath);
}
// ========== Helper Functions ==========
/**
* Deep merge two objects. Second object takes priority.
*/
function deepMerge(target: any, source: any): any {
if (!source) return target;
if (!target) return source;
const result = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = result[key];
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
result[key] = deepMerge(targetValue, sourceValue);
} else {
result[key] = sourceValue;
}
}
}
return result;
}
/**
* Get nested value using dot notation.
*/
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) return undefined;
const parts = path.split(".");
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = current[part];
}
return current;
}
/**
* Check if value is a plain object (not array, not null, not Date, etc.)
*/
function isPlainObject(value: any): boolean {
return (
value !== null && typeof value === "object" && value.constructor === Object
);
}
@@ -0,0 +1,35 @@
import { SidebarRefs, SidebarState, SidebarInfo } from "@app/types/sidebar";
/**
* Gets the All tools sidebar information using React refs and state
* @param refs - Object containing refs to sidebar elements
* @param state - Current sidebar state
* @returns Object containing the sidebar rect and whether the tool panel is active
*/
export function getSidebarInfo(
refs: SidebarRefs,
state: SidebarState,
): SidebarInfo {
const { quickAccessRef, toolPanelRef } = refs;
const { sidebarsVisible, readerMode } = state;
// Determine if tool panel should be active based on state
const isToolPanelActive = sidebarsVisible && !readerMode;
let rect: DOMRect | null = null;
if (isToolPanelActive && toolPanelRef.current) {
// Tool panel is expanded: use its rect
rect = toolPanelRef.current.getBoundingClientRect();
} else if (quickAccessRef.current) {
// Fall back to quick access bar
// This probably isn't needed but if we ever have tooltips or modals that need to be positioned relative to the quick access bar, we can use this
rect = quickAccessRef.current.getBoundingClientRect();
}
return {
rect,
isToolPanelActive,
sidebarState: state,
};
}
@@ -0,0 +1,396 @@
// PDFium annotation subtype constants
import {
FPDF_ANNOT_INK,
FPDF_ANNOT_LINE,
embedBitmapImageOnPage,
drawPlaceholderRect,
decodeImageDataUrl,
} from "@app/utils/pdfiumBitmapUtils";
import { generateThumbnailWithMetadata } from "@app/utils/thumbnailUtils";
import {
createChildStub,
createProcessedFile,
} from "@app/contexts/file/fileActions";
import {
createStirlingFile,
FileId,
StirlingFile,
StirlingFileStub,
} from "@app/types/fileContext";
import type { SignatureAPI } from "@app/components/viewer/viewerTypes";
import {
getPdfiumModule,
openRawDocumentSafe,
closeDocAndFreeBuffer,
saveRawDocument,
} from "@app/services/pdfiumService";
interface MinimalFileContextSelectors {
getAllFileIds: () => FileId[];
getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
getFile: (id: FileId) => StirlingFile | undefined;
}
interface SignatureFlatteningOptions {
signatureApiRef: React.RefObject<SignatureAPI | null>;
getImageData: (id: string) => string | undefined;
exportActions?: {
saveAsCopy: () => Promise<ArrayBuffer | null>;
};
selectors: MinimalFileContextSelectors;
originalFile?: StirlingFile;
getScrollState: () => { currentPage: number; totalPages: number };
activeFileIndex?: number;
}
export interface SignatureFlatteningResult {
inputFileIds: FileId[];
outputStirlingFile: StirlingFile;
outputStub: StirlingFileStub;
}
export async function flattenSignatures(
options: SignatureFlatteningOptions,
): Promise<SignatureFlatteningResult | null> {
const {
signatureApiRef,
getImageData,
exportActions,
selectors,
originalFile,
getScrollState,
activeFileIndex,
} = options;
try {
// Step 1: Extract all annotations from EmbedPDF before export
const allAnnotations: Array<{ pageIndex: number; annotations: any[] }> = [];
if (signatureApiRef?.current) {
const scrollState = getScrollState();
const totalPages = scrollState.totalPages;
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
try {
const pageAnnotations =
await signatureApiRef.current.getPageAnnotations(pageIndex);
if (pageAnnotations && pageAnnotations.length > 0) {
const sessionAnnotations = pageAnnotations.filter((annotation) => {
const hasStoredImageData =
annotation.id && getImageData(annotation.id);
const hasDirectImageData =
annotation.imageData ||
annotation.appearance ||
annotation.stampData ||
annotation.imageSrc ||
annotation.contents ||
annotation.data;
return (
hasStoredImageData ||
(hasDirectImageData &&
typeof hasDirectImageData === "string" &&
hasDirectImageData.startsWith("data:image"))
);
});
if (sessionAnnotations.length > 0) {
allAnnotations.push({
pageIndex,
annotations: sessionAnnotations,
});
}
}
} catch (pageError) {
console.warn(
`Error extracting annotations from page ${pageIndex + 1}:`,
pageError,
);
}
}
}
// Step 2: Delete ONLY session annotations from EmbedPDF before export
if (allAnnotations.length > 0 && signatureApiRef?.current) {
for (const pageData of allAnnotations) {
for (const annotation of pageData.annotations) {
try {
signatureApiRef.current.deleteAnnotation(
annotation.id,
pageData.pageIndex,
);
} catch (deleteError) {
console.warn(
`Failed to delete annotation ${annotation.id}:`,
deleteError,
);
}
}
}
}
// Step 3: Use EmbedPDF's saveAsCopy to get the original PDF
if (!exportActions) {
console.error("No export actions available");
return null;
}
const pdfArrayBuffer = await exportActions.saveAsCopy();
if (pdfArrayBuffer) {
const blob = new Blob([pdfArrayBuffer], { type: "application/pdf" });
let currentFile = originalFile;
if (!currentFile) {
const allFileIds = selectors.getAllFileIds();
if (allFileIds.length > 0) {
const fileIndex =
activeFileIndex !== undefined && activeFileIndex < allFileIds.length
? activeFileIndex
: 0;
const fileStub = selectors.getStirlingFileStub(allFileIds[fileIndex]);
const fileObject = selectors.getFile(allFileIds[fileIndex]);
if (fileStub && fileObject) {
currentFile = createStirlingFile(
fileObject,
allFileIds[fileIndex] as FileId,
);
}
}
}
if (!currentFile) {
console.error("No file available to replace");
return null;
}
let signedFile = new File([blob], currentFile.name, {
type: "application/pdf",
});
// Step 4: Manually render extracted annotations onto the PDF using PDFium WASM
if (allAnnotations.length > 0) {
try {
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(pdfArrayBufferForFlattening);
try {
const pageCount = m.FPDF_GetPageCount(docPtr);
for (const pageData of allAnnotations) {
const { pageIndex, annotations } = pageData;
if (pageIndex < pageCount) {
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) continue;
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
for (const annotation of annotations) {
try {
const rect =
annotation.rect ||
annotation.bounds ||
annotation.rectangle ||
annotation.position;
if (rect) {
const originalX =
rect.origin?.x || rect.x || rect.left || 0;
const originalY =
rect.origin?.y || rect.y || rect.top || 0;
const width = rect.size?.width || rect.width || 100;
const height = rect.size?.height || rect.height || 50;
// Convert from CSS top-left to PDF bottom-left
const pdfX = originalX;
const pdfY = pageHeight - originalY - height;
let imageDataUrl =
annotation.imageData ||
annotation.appearance ||
annotation.stampData ||
annotation.imageSrc ||
annotation.contents ||
annotation.data;
if (!imageDataUrl && annotation.id) {
const storedImageData = getImageData(annotation.id);
if (storedImageData) {
imageDataUrl = storedImageData;
}
}
// Convert SVG to PNG first if needed
if (
imageDataUrl &&
typeof imageDataUrl === "string" &&
imageDataUrl.startsWith("data:image/svg+xml")
) {
const pngBytes = await rasteriseSvgToPng(
imageDataUrl,
width * 2,
height * 2,
);
if (pngBytes) {
imageDataUrl = await uint8ArrayToPngDataUrl(pngBytes);
} else {
drawPlaceholderRect(
m,
pagePtr,
pdfX,
pdfY,
width,
height,
);
continue;
}
}
if (
imageDataUrl &&
typeof imageDataUrl === "string" &&
imageDataUrl.startsWith("data:image")
) {
// Decode the image data URL to raw pixels via canvas
const imageResult =
await decodeImageDataUrl(imageDataUrl);
if (imageResult) {
embedBitmapImageOnPage(
m,
docPtr,
pagePtr,
imageResult,
pdfX,
pdfY,
width,
height,
);
}
} else if (
annotation.type === FPDF_ANNOT_INK ||
annotation.type === FPDF_ANNOT_LINE
) {
drawPlaceholderRect(
m,
pagePtr,
pdfX,
pdfY,
width,
height,
);
}
}
} catch (annotationError) {
console.warn(
"Failed to render annotation:",
annotationError,
);
}
}
m.FPDFPage_GenerateContent(pagePtr);
m.FPDF_ClosePage(pagePtr);
}
}
const resultBuf = await saveRawDocument(docPtr);
signedFile = new File([resultBuf], currentFile.name, {
type: "application/pdf",
});
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
} catch (renderError) {
console.error("Failed to manually render annotations:", renderError);
console.warn("Signatures may only show as annotations");
}
}
const thumbnailResult = await generateThumbnailWithMetadata(signedFile);
const processedFileMetadata = createProcessedFile(
thumbnailResult.pageCount,
thumbnailResult.thumbnail,
);
const inputFileIds: FileId[] = [currentFile.fileId];
const record = selectors.getStirlingFileStub(currentFile.fileId);
if (!record) {
console.error("No file record found for:", currentFile.fileId);
return null;
}
const outputStub = createChildStub(
record,
{ toolId: "sign", timestamp: Date.now() },
signedFile,
thumbnailResult.thumbnail,
processedFileMetadata,
);
const outputStirlingFile = createStirlingFile(signedFile, outputStub.id);
return {
inputFileIds,
outputStirlingFile,
outputStub,
};
}
return null;
} catch (error) {
console.error("Error flattening signatures:", error);
return null;
}
}
/**
* Convert Uint8Array PNG bytes to a data URL for canvas decoding.
*/
function uint8ArrayToPngDataUrl(pngBytes: Uint8Array): Promise<string> {
return new Promise((resolve) => {
const blob = new Blob([pngBytes as BlobPart], { type: "image/png" });
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
}
/**
* Rasterise an SVG data URL to PNG bytes via an offscreen canvas.
*/
function rasteriseSvgToPng(
svgDataUrl: string,
width: number,
height: number,
): Promise<Uint8Array | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(null);
return;
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) {
resolve(null);
return;
}
blob.arrayBuffer().then(
(buf) => resolve(new Uint8Array(buf)),
() => resolve(null),
);
}, "image/png");
} catch {
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = svgDataUrl;
});
}
@@ -0,0 +1,89 @@
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
import {
HORIZONTAL_PADDING_RATIO,
VERTICAL_PADDING_RATIO,
} from "@app/constants/signConstants";
export interface SignaturePreview {
dataUrl: string;
width: number;
height: number;
}
const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
export const buildSignaturePreview = async (
config: SignParameters | null,
): Promise<SignaturePreview | null> => {
if (typeof window === "undefined" || typeof document === "undefined") {
return null;
}
if (!config) {
return null;
}
if (config.signatureType === "text") {
const text = config.signerName?.trim();
if (!text) {
return null;
}
const fontSize = config.fontSize ?? 16;
const fontFamily = config.fontFamily ?? "Helvetica";
const textColor = config.textColor ?? "#000000";
const paddingX = Math.round(fontSize * HORIZONTAL_PADDING_RATIO);
const paddingY = Math.round(fontSize * VERTICAL_PADDING_RATIO);
const measureCanvas = document.createElement("canvas");
const measureCtx = measureCanvas.getContext("2d");
if (!measureCtx) {
return null;
}
measureCtx.font = `${fontSize}px ${fontFamily}`;
const metrics = measureCtx.measureText(text);
const textWidth = Math.ceil(metrics.width);
const width = Math.max(1, textWidth + paddingX * 2);
const height = Math.max(1, Math.ceil(fontSize + paddingY * 2));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
return null;
}
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(text, paddingX, height / 2);
const dataUrl = canvas.toDataURL("image/png");
return { dataUrl, width, height };
}
const dataUrl = config.signatureData;
if (!dataUrl) {
return null;
}
const image = await loadImage(dataUrl);
return {
dataUrl,
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
};
};
@@ -0,0 +1,70 @@
import { StorageStats } from "@app/services/fileStorage";
/**
* Storage operation types for incremental updates
*/
export type StorageOperation = "add" | "remove" | "clear";
/**
* Update storage stats incrementally based on operation
*/
export function updateStorageStatsIncremental(
currentStats: StorageStats,
operation: StorageOperation,
files: File[] = [],
): StorageStats {
const filesSizeTotal = files.reduce((total, file) => total + file.size, 0);
switch (operation) {
case "add":
return {
...currentStats,
used: currentStats.used + filesSizeTotal,
available: currentStats.available - filesSizeTotal,
fileCount: currentStats.fileCount + files.length,
};
case "remove":
return {
...currentStats,
used: Math.max(0, currentStats.used - filesSizeTotal),
available: currentStats.available + filesSizeTotal,
fileCount: Math.max(0, currentStats.fileCount - files.length),
};
case "clear":
return {
...currentStats,
used: 0,
available: currentStats.quota || currentStats.available,
fileCount: 0,
};
default:
return currentStats;
}
}
/**
* Check storage usage and return warning message if needed
*/
export function checkStorageWarnings(stats: StorageStats): string | null {
if (!stats.quota || stats.used === 0) return null;
const usagePercent = (stats.used / stats.quota) * 100;
if (usagePercent > 90) {
return "Warning: Storage is nearly full (>90%). Browser may start clearing data.";
} else if (usagePercent > 80) {
return "Storage is getting full (>80%). Consider removing old files.";
}
return null;
}
/**
* Calculate storage usage percentage
*/
export function getStorageUsagePercent(stats: StorageStats): number {
return stats.quota ? (stats.used / stats.quota) * 100 : 0;
}
@@ -0,0 +1,56 @@
// Shared text Comparison and normalization utilities for compare tool
export const shouldConcatWithoutSpace = (word: string) => {
return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s";
};
export const appendWord = (existing: string, word: string) => {
if (!existing) return word;
if (shouldConcatWithoutSpace(word)) return `${existing}${word}`;
return `${existing} ${word}`;
};
export const tokenize = (text: string): string[] =>
text.split(/\s+/).filter(Boolean);
type TokenType = "unchanged" | "removed" | "added";
export interface LocalToken {
type: TokenType;
text: string;
}
const buildLcsMatrix = (a: string[], b: string[]) => {
const rows = a.length + 1;
const cols = b.length + 1;
const m: number[][] = new Array(rows);
for (let i = 0; i < rows; i += 1) m[i] = new Array(cols).fill(0);
for (let i = 1; i < rows; i += 1) {
for (let j = 1; j < cols; j += 1) {
m[i][j] =
a[i - 1] === b[j - 1]
? m[i - 1][j - 1] + 1
: Math.max(m[i][j - 1], m[i - 1][j]);
}
}
return m;
};
export const diffWords = (a: string[], b: string[]): LocalToken[] => {
const matrix = buildLcsMatrix(a, b);
const tokens: LocalToken[] = [];
let i = a.length;
let j = b.length;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
tokens.unshift({ type: "unchanged", text: a[i - 1] });
i -= 1;
j -= 1;
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
tokens.unshift({ type: "added", text: b[j - 1] });
j -= 1;
} else if (i > 0) {
tokens.unshift({ type: "removed", text: a[i - 1] });
i -= 1;
}
}
return tokens;
};
@@ -0,0 +1,29 @@
/**
* Truncates text from the centre, preserving the start and end.
* e.g. "very-long-filename.pdf" -> "very-lo...ame.pdf"
*/
export function truncateCenter(text: string, maxLength: number = 25): string {
if (text.length <= maxLength) return text;
const ellipsis = "...";
const charsToShow = maxLength - ellipsis.length;
const frontChars = Math.ceil(charsToShow / 2);
const backChars = Math.floor(charsToShow / 2);
return (
text.substring(0, frontChars) +
ellipsis +
text.substring(text.length - backChars)
);
}
/**
* Filters out emoji characters from a text string
* @param text - The input text string
* @returns The filtered text without emoji characters
*/
export const removeEmojis = (text: string): string => {
// Filter out emoji characters (Unicode ranges for emojis)
return text.replace(
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu,
"",
);
};
@@ -0,0 +1,223 @@
import {
openRawDocumentSafe,
closeRawDocument,
getPdfiumModule,
} from "@app/services/pdfiumService";
import {
renderPdfiumPageDataUrl,
readPdfiumPageMetadata,
} from "@app/utils/pdfiumPageRender";
export interface ThumbnailWithMetadata {
thumbnail: string; // Always returns a thumbnail (placeholder if needed)
pageCount: number;
pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270)
pageDimensions?: Array<{ width: number; height: number }>;
isEncrypted?: boolean;
}
/**
* Calculate thumbnail scale based on file size (modern 2024 scaling)
*/
export function calculateScaleFromFileSize(fileSize: number): number {
const MB = 1024 * 1024;
if (fileSize < 10 * MB) return 1.0; // Full quality for small files
if (fileSize < 50 * MB) return 0.8; // High quality for common file sizes
if (fileSize < 200 * MB) return 0.6; // Good quality for typical large files
if (fileSize < 500 * MB) return 0.4; // Readable quality for large but manageable files
return 0.3; // Still usable quality, not tiny
}
/** PDFium error code 4 = password required (encrypted PDF). */
const PDFIUM_ERR_PASSWORD = 4;
interface PdfiumRenderResult {
thumbnail: string;
pageCount: number;
pageRotations: number[];
pageDimensions: Array<{ width: number; height: number }>;
/** Set when the document is password-protected — caller substitutes the
* encrypted placeholder. Thumbnail/metadata fields are empty in that case. */
isEncrypted?: boolean;
}
/**
* Open a PDF with PDFium, render page 1 to a data URL, and optionally
* collect rotation + dimensions for every page. Returns `isEncrypted: true`
* (without rendering) when the document is password-protected.
*
* @param applyRotation When true, bakes the page's own rotation into the
* bitmap (static display). When false, renders upright so callers can
* apply rotation via CSS (PageEditor).
* @param collectAllPagesMetadata When true, reads per-page rotation and
* dimensions for all pages. When false (very large files), only the
* first page's metadata is populated.
*/
async function renderPdfThumbnailPdfium(
data: ArrayBuffer,
scale: number,
applyRotation: boolean,
collectAllPagesMetadata: boolean,
): Promise<PdfiumRenderResult> {
const m = await getPdfiumModule();
let docPtr: number;
try {
docPtr = await openRawDocumentSafe(data);
} catch (error) {
if (
error instanceof Error &&
new RegExp(`error ${PDFIUM_ERR_PASSWORD}`).test(error.message)
) {
return {
thumbnail: "",
pageCount: 1,
pageRotations: [],
pageDimensions: [],
isEncrypted: true,
};
}
throw error;
}
try {
const pageCount = m.FPDF_GetPageCount(docPtr);
const thumbnail = await renderPdfiumPageDataUrl(docPtr, 0, scale, {
applyRotation,
});
if (!thumbnail) throw new Error("PDFium: failed to render page 0");
// Page 0 metadata is already available via the render, but read it
// directly for consistency with the later per-page loop.
const firstMeta = await readPdfiumPageMetadata(docPtr, 0);
const pageRotations: number[] = [firstMeta?.rotation ?? 0];
const pageDimensions: Array<{ width: number; height: number }> = [
{
width: firstMeta?.width ?? 0,
height: firstMeta?.height ?? 0,
},
];
if (collectAllPagesMetadata) {
for (let i = 1; i < pageCount; i++) {
const meta = await readPdfiumPageMetadata(docPtr, i);
if (!meta) continue;
pageRotations[i] = meta.rotation;
pageDimensions[i] = { width: meta.width, height: meta.height };
}
}
return { thumbnail, pageCount, pageRotations, pageDimensions };
} finally {
await closeRawDocument(docPtr);
}
}
async function generatePDFThumbnail(
arrayBuffer: ArrayBuffer,
scale: number,
): Promise<string> {
const result = await renderPdfThumbnailPdfium(
arrayBuffer,
scale,
true,
false,
);
if (result.isEncrypted) {
return "";
}
return result.thumbnail;
}
/**
* Generate thumbnail for any file type - always returns a thumbnail (placeholder if needed)
*/
export async function generateThumbnailForFile(file: File): Promise<string> {
// Very large PDFs skip thumbnail generation — SVG icon shown in UI instead
if (file.size >= 100 * 1024 * 1024) {
return "";
}
// Handle image files - convert to data URL for persistence
if (file.type.startsWith("image/")) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
// Handle PDF files
if (file.type.startsWith("application/pdf")) {
const scale = calculateScaleFromFileSize(file.size);
// Only read first 2MB for thumbnail generation to save memory
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunk = file.slice(0, Math.min(chunkSize, file.size));
const arrayBuffer = await chunk.arrayBuffer();
try {
return await generatePDFThumbnail(arrayBuffer, scale);
} catch {
// PDFium needs the xref table at the end of the file, so the 2MB
// chunk can fail to open for PDFs larger than that. Retry with the
// full buffer before falling back to an empty thumbnail.
try {
const fullArrayBuffer = await file.arrayBuffer();
return await generatePDFThumbnail(fullArrayBuffer, scale);
} catch (error) {
console.warn(`PDF processing failed for ${file.name}:`, error);
return "";
}
}
}
// Non-PDF, non-image files use scalable SVG icons in the UI — no raster thumbnail needed
return "";
}
/**
* Generate thumbnail and extract page count for a PDF file - always returns a valid thumbnail
* @param applyRotation - If true, render thumbnail with PDF rotation applied (for static display).
* If false, render without rotation (for CSS-based rotation in PageEditor)
*/
export async function generateThumbnailWithMetadata(
file: File,
applyRotation: boolean = true,
): Promise<ThumbnailWithMetadata> {
// Non-PDF files have no page count
if (!file.type.startsWith("application/pdf")) {
const thumbnail = await generateThumbnailForFile(file);
return { thumbnail, pageCount: 0 };
}
const scale = calculateScaleFromFileSize(file.size);
const isVeryLarge = file.size >= 100 * 1024 * 1024; // 100MB threshold
try {
const arrayBuffer = await file.arrayBuffer();
const result = await renderPdfThumbnailPdfium(
arrayBuffer,
scale,
applyRotation,
!isVeryLarge,
);
if (result.isEncrypted) {
return {
thumbnail: "",
pageCount: 1,
isEncrypted: true,
};
}
return {
thumbnail: result.thumbnail,
pageCount: result.pageCount,
pageRotations: result.pageRotations,
pageDimensions: result.pageDimensions,
};
} catch {
return { thumbnail: "", pageCount: 1 };
}
}
@@ -0,0 +1,142 @@
/**
* Standardized error handling utilities for tool operations
*/
import { normalizeAxiosErrorData } from "@app/services/errorUtils";
/**
* Default error extractor that follows the standard pattern
*/
export const extractErrorMessage = (error: any): string => {
if (error.response?.data && typeof error.response.data === "string") {
return error.response.data;
}
if (error.message) {
return error.message;
}
return "There was an error processing your request.";
};
/**
* Creates a standardized error handler for tool operations
* @param fallbackMessage - Message to show when no specific error can be extracted
* @returns Error handler function that follows the standard pattern
*/
export const createStandardErrorHandler = (fallbackMessage: string) => {
return (error: any): string => {
if (error.response?.data && typeof error.response.data === "string") {
return error.response.data;
}
if (error.message) {
return error.message;
}
return fallbackMessage;
};
};
/**
* Parses a 422 response, extracts errored file IDs from the payload (JSON or UUID regex),
* and marks them in the UI. Returns true if IDs were found and handled, false otherwise.
*/
export const handle422Error = async (
error: any,
markFileError: (fileId: string) => void,
): Promise<boolean> => {
const status = error?.response?.status;
if (typeof status !== "number" || status !== 422) return false;
const payload = error?.response?.data;
let parsed: unknown = payload;
if (typeof payload === "string") {
try {
parsed = JSON.parse(payload);
} catch {
parsed = payload;
}
} else if (payload && typeof (payload as Blob).text === "function") {
const text = await (payload as Blob).text();
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
}
let ids: string[] | undefined = Array.isArray(
(parsed as { errorFileIds?: unknown })?.errorFileIds,
)
? (parsed as { errorFileIds: string[] }).errorFileIds
: undefined;
if (!ids && typeof parsed === "string") {
const match = parsed.match(
/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g,
);
if (match && match.length > 0) ids = Array.from(new Set(match));
}
if (ids && ids.length > 0) {
for (const id of ids) {
try {
markFileError(id);
} catch (_e) {
void _e;
}
}
return true;
}
return false;
};
/**
* Handles password-related errors with status code checking
* @param error - The error object from axios
* @param incorrectPasswordMessage - Message to show for incorrect password (typically 500 status)
* @param fallbackMessage - Message to show for other errors
* @returns Error message string
*/
export const handlePasswordError = async (
error: any,
incorrectPasswordMessage: string,
fallbackMessage: string,
): Promise<string> => {
const status = error?.response?.status;
// Handle specific error cases with user-friendly messages
// Backend returns 400 with PdfPasswordException for incorrect/missing PDF passwords
if (status === 500) {
return incorrectPasswordMessage;
}
if (status === 400) {
const data = error?.response?.data;
// ProblemDetail JSON has type "/errors/pdf-password", blob needs parsing
const isPasswordError = await (async () => {
if (data instanceof Blob) {
try {
const text = await data.text();
return text.includes("pdf-password") || text.includes("passworded");
} catch {
return false;
}
}
const type = data?.type ?? "";
return type.includes("pdf-password");
})();
if (isPasswordError) {
return incorrectPasswordMessage;
}
}
// For other errors, try to extract the message
const normalizedData = await normalizeAxiosErrorData(error?.response?.data);
const errorWithNormalizedData = {
...error,
response: {
...error?.response,
data: normalizedData,
},
};
return extractErrorMessage(errorWithNormalizedData) || fallbackMessage;
};
@@ -0,0 +1,57 @@
// Note: This utility should be used with useToolResources for ZIP operations
import { getFilenameFromHeaders } from "@app/utils/fileResponseUtils";
export type ResponseHandler = (
blob: Blob,
originalFiles: File[],
) => Promise<File[]> | File[];
/**
* Processes a blob response into File(s).
* - If a tool-specific responseHandler is provided, it is used.
* - If responseHeaders provided and contains Content-Disposition, uses that filename.
* - Otherwise, create a single file using the filePrefix + original name.
* - If filePrefix is empty, preserves the original filename.
*/
export async function processResponse(
blob: Blob,
originalFiles: File[],
filePrefix?: string,
responseHandler?: ResponseHandler,
responseHeaders?: Record<string, any>,
): Promise<File[]> {
if (responseHandler) {
const out = await responseHandler(blob, originalFiles);
return Array.isArray(out) ? out : [out as unknown as File];
}
// Check if we should use the backend-provided filename from headers
// Only when responseHeaders are explicitly provided (indicating the operation requested this)
if (responseHeaders) {
const contentDisposition = responseHeaders["content-disposition"];
const backendFilename = getFilenameFromHeaders(contentDisposition);
if (backendFilename) {
const type =
blob.type ||
responseHeaders["content-type"] ||
"application/octet-stream";
return [new File([blob], backendFilename, { type })];
}
// If preserveBackendFilename was requested but no Content-Disposition header found,
// fall back to default behavior (this handles cases where backend doesn't set the header)
}
// Default behavior: use filePrefix + original name
const original = originalFiles[0]?.name ?? "result.pdf";
// Only add prefix if it's not empty - this preserves original filenames for file history
const name = filePrefix ? `${filePrefix}${original}` : original;
const type = blob.type || "application/octet-stream";
// File was modified by tool processing - set lastModified to current time
return [
new File([blob], name, {
type,
lastModified: Date.now(),
}),
];
}
@@ -0,0 +1,129 @@
import { ToolId } from "@app/types/toolId";
import { ToolRegistryEntry, ToolRegistry } from "@app/data/toolsTaxonomy";
import {
scoreMatch,
minScoreForQuery,
normalizeForSearch,
} from "@app/utils/fuzzySearch";
export interface RankedToolItem {
item: [ToolId, ToolRegistryEntry];
matchedText?: string;
}
export function filterToolRegistryByQuery(
toolRegistry: Partial<ToolRegistry>,
query: string,
): RankedToolItem[] {
const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][];
if (!query.trim()) {
return entries.map(([id, tool]) => ({
item: [id, tool] as [ToolId, ToolRegistryEntry],
}));
}
const nq = normalizeForSearch(query);
const threshold = minScoreForQuery(query);
const exactName: Array<{ id: ToolId; tool: ToolRegistryEntry; pos: number }> =
[];
const exactSyn: Array<{
id: ToolId;
tool: ToolRegistryEntry;
text: string;
pos: number;
}> = [];
const fuzzyName: Array<{
id: ToolId;
tool: ToolRegistryEntry;
score: number;
text: string;
}> = [];
const fuzzySyn: Array<{
id: ToolId;
tool: ToolRegistryEntry;
score: number;
text: string;
}> = [];
for (const [id, tool] of entries) {
const nameNorm = normalizeForSearch(tool.name || "");
const pos = nameNorm.indexOf(nq);
if (pos !== -1) {
exactName.push({ id, tool, pos });
continue;
}
const syns = Array.isArray(tool.synonyms) ? tool.synonyms : [];
let matchedExactSyn: { text: string; pos: number } | null = null;
for (const s of syns) {
const sn = normalizeForSearch(s);
const sp = sn.indexOf(nq);
if (sp !== -1) {
matchedExactSyn = { text: s, pos: sp };
break;
}
}
if (matchedExactSyn) {
exactSyn.push({
id,
tool,
text: matchedExactSyn.text,
pos: matchedExactSyn.pos,
});
continue;
}
// Fuzzy name
const nameScore = scoreMatch(query, tool.name || "");
if (nameScore >= threshold) {
fuzzyName.push({ id, tool, score: nameScore, text: tool.name || "" });
}
// Fuzzy synonyms (we'll consider these only if fuzzy name results are weak)
let bestSynScore = 0;
let bestSynText = "";
for (const s of syns) {
const synScore = scoreMatch(query, s);
if (synScore > bestSynScore) {
bestSynScore = synScore;
bestSynText = s;
}
if (bestSynScore >= 95) break;
}
if (bestSynScore >= threshold) {
fuzzySyn.push({ id, tool, score: bestSynScore, text: bestSynText });
}
}
// Sort within buckets
exactName.sort(
(a, b) =>
a.pos - b.pos || (a.tool.name || "").length - (b.tool.name || "").length,
);
exactSyn.sort((a, b) => a.pos - b.pos || a.text.length - b.text.length);
fuzzyName.sort((a, b) => b.score - a.score);
fuzzySyn.sort((a, b) => b.score - a.score);
// Concatenate buckets with de-duplication by tool id
const seen = new Set<string>();
const ordered: RankedToolItem[] = [];
const push = (id: ToolId, tool: ToolRegistryEntry, matchedText?: string) => {
if (seen.has(id)) return;
seen.add(id);
ordered.push({ item: [id, tool], matchedText });
};
for (const { id, tool } of exactName) push(id as ToolId, tool, tool.name);
for (const { id, tool, text } of exactSyn) push(id as ToolId, tool, text);
for (const { id, tool, text } of fuzzyName) push(id as ToolId, tool, text);
for (const { id, tool, text } of fuzzySyn) push(id as ToolId, tool, text);
if (ordered.length > 0) return ordered;
// Fallback: return everything unchanged
return entries.map(([id, tool]) => ({
item: [id, tool] as [ToolId, ToolRegistryEntry],
}));
}
@@ -0,0 +1,42 @@
import { TFunction } from "i18next";
// Helper function to get synonyms for a tool (only from translations)
export const getSynonyms = (t: TFunction, toolId: string): string[] => {
try {
const candidateKeys = [`home.${toolId}.tags`, `${toolId}.tags`];
const match = candidateKeys
.map((key) => ({ key, value: t(key) as unknown as string }))
.find(({ key, value }) => value && value !== key);
const tags = match?.value;
const usedKey = match?.key;
if (!tags) {
console.warn(`[Tags] Missing tags for tool: ${toolId}`);
return [];
}
// Split by comma and clean up the tags
const cleanedTags = tags
.split(",")
.map((tag: string) => tag.trim())
.filter((tag: string) => tag.length > 0);
// Log the tags found for this tool
if (cleanedTags.length > 0) {
console.info(
`[Tags] Tool "${toolId}" (${usedKey}) has ${cleanedTags.length} tags:`,
cleanedTags,
);
} else {
console.warn(`[Tags] Tool "${toolId}" has empty tags value`);
}
return cleanedTags;
} catch (error) {
console.error(
`[Tags] Failed to get translated synonyms for tool ${toolId}:`,
error,
);
return [];
}
};
@@ -0,0 +1,136 @@
import { ToolId } from "@app/types/toolId";
// Map URL paths to tool keys (multiple URLs can map to same tool)
export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
// Basic tools - standard patterns
"/split": "split",
"/split-pdfs": "split",
"/merge": "merge",
"/merge-pdfs": "merge",
"/compress": "compress",
"/compress-pdf": "compress",
"/rotate": "rotate",
"/rotate-pdf": "rotate",
"/repair": "repair",
"/flatten": "flatten",
"/crop": "crop",
// Convert tool and all its variants
"/convert": "convert",
"/convert-pdf": "convert",
"/file-to-pdf": "convert",
"/eml-to-pdf": "convert",
"/html-to-pdf": "convert",
"/markdown-to-pdf": "convert",
"/pdf-to-csv": "convert",
"/pdf-to-xlsx": "convert",
"/pdf-to-img": "convert",
"/pdf-to-markdown": "convert",
"/pdf-to-pdfa": "convert",
"/pdf-to-word": "convert",
"/pdf-to-xml": "convert",
"/cbr-to-pdf": "convert",
"/pdf-to-cbr": "convert",
"/cbz-to-pdf": "convert",
"/pdf-to-cbz": "convert",
// Security tools
"/add-password": "addPassword",
"/remove-password": "removePassword",
"/change-permissions": "changePermissions",
"/cert-sign": "certSign",
"/manage-signatures": "certSign",
"/remove-certificate-sign": "removeCertSign",
"/remove-cert-sign": "removeCertSign",
"/unlock-pdf-forms": "unlockPDFForms",
"/validate-signature": "validateSignature",
// Content manipulation
"/sanitize": "sanitize",
"/sanitize-pdf": "sanitize",
"/ocr": "ocr",
"/ocr-pdf": "ocr",
"/watermark": "watermark",
"/add-watermark": "watermark",
"/add-image": "addImage",
"/add-stamp": "addStamp",
"/add-page-numbers": "addPageNumbers",
"/redact": "redact",
// Page manipulation
"/remove-pages": "removePages",
"/remove-blanks": "removeBlanks",
"/extract-pages": "extractPages",
"/reorganize-pages": "reorganizePages",
"/single-large-page": "pdfToSinglePage",
"/page-layout": "pageLayout",
"/scale-pages": "scalePages",
"/booklet-imposition": "bookletImposition",
// Splitting tools
"/auto-split-pdf": "split",
"/auto-size-split-pdf": "split",
"/scanner-image-split": "scannerImageSplit",
// Annotation and content removal
"/annotations": "annotate",
"/annotate": "annotate",
"/remove-annotations": "removeAnnotations",
"/remove-image": "removeImage",
// Image and visual tools
"/extract-images": "extractImages",
"/adjust-contrast": "adjustContrast",
"/fake-scan": "scannerEffect",
"/replace-color-pdf": "replaceColor",
// Metadata and info
"/change-metadata": "changeMetadata",
"/get-pdf-info": "getPdfInfo",
"/add-attachments": "addAttachments",
// Advanced tools
"/overlay-pdfs": "overlayPdfs",
"/edit-table-of-contents": "editTableOfContents",
"/auto-rename": "autoRename",
"/compare": "compare",
"/multi-tool": "multiTool",
"/show-js": "showJS",
// Special/utility tools
"/read": "read",
"/automate": "automate",
"/sign": "sign",
"/add-text": "addText",
"/pdf-text-editor": "pdfTextEditor",
// Developer tools
"/dev-api": "devApi",
"/dev-folder-scanning": "devFolderScanning",
"/dev-sso-guide": "devSsoGuide",
"/dev-airgapped": "devAirgapped",
// Legacy URL mappings from sitemap
"/pdf-organizer": "reorganizePages",
"/multi-page-layout": "pageLayout",
"/extract-page": "extractPages",
"/pdf-to-single-page": "pdfToSinglePage",
"/img-to-pdf": "convert",
"/pdf-to-presentation": "convert",
"/pdf-to-text": "convert",
"/pdf-to-html": "convert",
"/auto-redact": "redact",
"/stamp": "addStamp",
"/view-pdf": "read",
"/get-info-on-pdf": "getPdfInfo",
"/remove-image-pdf": "removeImage",
"/replace-and-invert-color-pdf": "replaceColor",
"/pipeline": "automate",
"/extract-image-scans": "scannerImageSplit",
"/show-javascript": "showJS",
"/scanner-effect": "scannerEffect",
"/split-by-size-or-count": "split",
"/overlay-pdf": "overlayPdfs",
"/split-pdf-by-sections": "split",
"/split-pdf-by-chapters": "split",
};
@@ -0,0 +1,136 @@
/**
* URL routing utilities for tool navigation with registry support
*/
import { ToolRoute } from "@app/types/navigation";
import { ToolId, isValidToolId } from "@app/types/toolId";
import { getDefaultWorkbench } from "@app/types/workbench";
import {
ToolRegistry,
getToolWorkbench,
getToolUrlPath,
} from "@app/data/toolsTaxonomy";
import { firePixel } from "@app/utils/scarfTracking";
import { URL_TO_TOOL_MAP } from "@app/utils/urlMapping";
import { BASE_PATH, withBasePath } from "@app/constants/app";
/**
* Parse the current URL to extract tool routing information
*/
export function parseToolRoute(registry: ToolRegistry): ToolRoute {
const fullPath = window.location.pathname;
// Remove base path to get app-relative path
const path =
BASE_PATH && fullPath.startsWith(BASE_PATH)
? fullPath.slice(BASE_PATH.length) || "/"
: fullPath;
const searchParams = new URLSearchParams(window.location.search);
// First, check URL mapping for multiple URL aliases
const mappedToolId = URL_TO_TOOL_MAP[path];
if (mappedToolId && registry[mappedToolId]) {
const tool = registry[mappedToolId];
return {
workbench: getToolWorkbench(tool),
toolId: mappedToolId,
};
}
// Fallback: Try to find tool by primary URL path in registry
for (const [toolId, tool] of Object.entries(registry)) {
const toolUrlPath = getToolUrlPath(toolId);
if (path === toolUrlPath && isValidToolId(toolId)) {
return {
workbench: getToolWorkbench(tool),
toolId,
};
}
}
// Check for query parameter fallback (e.g., ?tool=split)
const toolParam = searchParams.get("tool");
if (toolParam && isValidToolId(toolParam) && registry[toolParam]) {
const tool = registry[toolParam];
return {
workbench: getToolWorkbench(tool),
toolId: toolParam,
};
}
// Default to fileEditor workbench for home page
return {
workbench: getDefaultWorkbench(),
toolId: null,
};
}
/**
* Update URL and fire analytics pixel
*/
function updateUrl(
newPath: string,
searchParams: URLSearchParams,
replace: boolean = false,
): void {
const currentPath = window.location.pathname;
const queryString = searchParams.toString();
const fullUrl = newPath + (queryString ? `?${queryString}` : "");
// Only update URL and fire pixel if something actually changed
if (
currentPath !== newPath ||
window.location.search !== (queryString ? `?${queryString}` : "")
) {
if (replace) {
window.history.replaceState(null, "", fullUrl);
} else {
window.history.pushState(null, "", fullUrl);
}
firePixel(newPath);
}
}
/**
* Update the URL to reflect the current tool selection
*/
export function updateToolRoute(
toolId: ToolId,
registry: ToolRegistry,
replace: boolean = false,
): void {
const tool = registry[toolId];
if (!tool) {
console.warn(`Tool ${toolId} not found in registry`);
return;
}
const toolPath = getToolUrlPath(toolId);
const newPath = withBasePath(toolPath);
const searchParams = new URLSearchParams(window.location.search);
// Remove tool query parameter since we're using path-based routing
searchParams.delete("tool");
updateUrl(newPath, searchParams, replace);
}
/**
* Clear tool routing and return to home page
*/
export function clearToolRoute(replace: boolean = false): void {
const searchParams = new URLSearchParams(window.location.search);
searchParams.delete("tool");
updateUrl(withBasePath("/"), searchParams, replace);
}
/**
* Get clean tool name for display purposes using registry
*/
export function getToolDisplayName(
toolId: ToolId,
registry: ToolRegistry,
): string {
const tool = registry[toolId];
return tool ? tool.name : toolId;
}
@@ -0,0 +1,176 @@
import { useEffect, useRef } from "react";
export const DEFAULT_VISIBILITY_THRESHOLD = 70; // Require at least 70% of the page height to be visible
export const DEFAULT_FALLBACK_ZOOM = 1.44; // 144% fallback when no reliable metadata is present
export interface ZoomViewport {
clientWidth?: number;
clientHeight?: number;
width?: number;
height?: number;
}
export type AutoZoomDecision =
| { type: "fallback"; zoom: number }
| { type: "fitWidth" }
| { type: "adjust"; zoom: number };
export interface AutoZoomParams {
viewportWidth: number;
viewportHeight: number;
fitWidthZoom: number;
pagesPerSpread: number;
pageRect?: { width: number; height: number } | null;
metadataAspectRatio?: number | null;
visibilityThreshold?: number;
fallbackZoom?: number;
}
export function determineAutoZoom({
viewportWidth,
viewportHeight,
fitWidthZoom,
pagesPerSpread,
pageRect,
metadataAspectRatio,
visibilityThreshold = DEFAULT_VISIBILITY_THRESHOLD,
fallbackZoom = DEFAULT_FALLBACK_ZOOM,
}: AutoZoomParams): AutoZoomDecision {
// Get aspect ratio from pageRect or metadata
const rectWidth = pageRect?.width ?? 0;
const rectHeight = pageRect?.height ?? 0;
const aspectRatio: number | null =
rectWidth > 0 ? rectHeight / rectWidth : (metadataAspectRatio ?? null);
// Need aspect ratio to proceed
if (!aspectRatio || aspectRatio <= 0) {
return { type: "fallback", zoom: Math.min(fitWidthZoom, fallbackZoom) };
}
// Landscape pages need 100% visibility, portrait need the specified threshold
const isLandscape = aspectRatio < 1;
const targetVisibility = isLandscape ? 100 : visibilityThreshold;
// Calculate zoom level that shows targetVisibility% of page height
const pageHeightAtFitWidth = (viewportWidth / pagesPerSpread) * aspectRatio;
const heightBasedZoom =
(fitWidthZoom * (viewportHeight / pageHeightAtFitWidth)) /
(targetVisibility / 100);
// Use whichever zoom is smaller (more zoomed out) to satisfy both width and height constraints
if (heightBasedZoom < fitWidthZoom) {
// Need to zoom out from fitWidth to show enough height
return { type: "adjust", zoom: heightBasedZoom };
} else {
// fitWidth already shows enough
return { type: "fitWidth" };
}
}
export interface MeasurePageRectOptions {
selector?: string;
maxAttempts?: number;
shouldCancel?: () => boolean;
}
export async function measureRenderedPageRect({
selector = '[data-page-index="0"]',
maxAttempts = 12,
shouldCancel,
}: MeasurePageRectOptions = {}): Promise<DOMRect | null> {
if (typeof window === "undefined" || typeof document === "undefined") {
return null;
}
let rafId: number | null = null;
const waitForNextFrame = () =>
new Promise<void>((resolve) => {
rafId = window.requestAnimationFrame(() => {
rafId = null;
resolve();
});
});
try {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (shouldCancel?.()) {
return null;
}
const element = document.querySelector(selector) as HTMLElement | null;
if (element) {
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
return rect;
}
}
await waitForNextFrame();
}
} finally {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
}
return null;
}
export interface FitWidthResizeOptions {
isManaged: boolean;
requestFitWidth: () => void;
onDebouncedResize: () => void;
debounceMs?: number;
}
export function useFitWidthResize({
isManaged,
requestFitWidth,
onDebouncedResize,
debounceMs = 150,
}: FitWidthResizeOptions): void {
const managedRef = useRef(isManaged);
const requestFitWidthRef = useRef(requestFitWidth);
const onDebouncedResizeRef = useRef(onDebouncedResize);
useEffect(() => {
managedRef.current = isManaged;
}, [isManaged]);
useEffect(() => {
requestFitWidthRef.current = requestFitWidth;
}, [requestFitWidth]);
useEffect(() => {
onDebouncedResizeRef.current = onDebouncedResize;
}, [onDebouncedResize]);
useEffect(() => {
let timeoutId: number | undefined;
const handleResize = () => {
if (!managedRef.current) {
return;
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
requestFitWidthRef.current?.();
onDebouncedResizeRef.current?.();
}, debounceMs);
};
window.addEventListener("resize", handleResize);
return () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
window.removeEventListener("resize", handleResize);
};
}, [debounceMs]);
}