Compare commits

...

19 Commits

Author SHA1 Message Date
Connor Yoh
47a16e71cf Working as well as it was 2025-09-05 18:00:00 +01:00
Connor Yoh
5bf024be48 Builds 2025-09-05 17:41:53 +01:00
Connor Yoh
921b0a07b0 Merge remote-tracking branch 'origin/V2' into feature/v2/filehistory 2025-09-05 16:27:44 +01:00
Connor Yoh
2165c7599b type errors 2025-09-05 14:37:31 +01:00
Anthony Stirling
1898df0df9
Urls for tools for scrapers and open in new tab support (#4364)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-09-05 11:35:17 +00:00
Anthony Stirling
da359d329d
V2 flatten (#4358)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-09-05 11:25:30 +00:00
James Brunton
bd13f6bf57
Enable ESLint no-unused-vars rule (#4367)
# Description of Changes
Enable ESLint [no-unused-vars
rule](https://typescript-eslint.io/rules/no-unused-vars/)
2025-09-05 11:16:17 +00:00
Reece Browne
87c63efcec
Feature/v2/filewithid implementation (#4369)
Added Filewithid type
Updated code where file was being used to use filewithid
Updated places we identified files by name or composite keys to use UUID
Updated places we should have been using quickkey
Updated pageeditor issue where we parsed pagenumber from pageid instead
of using pagenumber directly

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-09-05 11:33:03 +01:00
James Brunton
5caec41d96
Enable ESLint no-empty-object-type rule (#4354)
# Description of Changes
Enable ESLint [no-empty-object-type
rule](https://typescript-eslint.io/rules/no-empty-object-type/)
2025-09-05 10:15:36 +00:00
James Brunton
d558bb5fac
Enable ESLint ban-ts-comment rule (#4350)
# Description of Changes
Enable ESLint [ban-ts-comment
rule](https://typescript-eslint.io/rules/ban-ts-comment/)
2025-09-05 10:02:00 +00:00
James Brunton
cd1fc682ab
Enable ESLint no-case-declarations rule (#4348)
# Description of Changes
Enable ESLint [no-case-declarations
rule](https://eslint.org/docs/latest/rules/no-case-declarations)
2025-09-05 09:58:14 +00:00
James Brunton
b9cf7e7820
Enable ESLint no-empty-pattern rule (#4343)
# Description of Changes
Enable ESLint [no-empty-pattern
rule](https://eslint.org/docs/latest/rules/no-empty-pattern)
2025-09-05 10:55:03 +01:00
James Brunton
94e8f603ff
Enable ESLint no-unused-expressions rule (#4363)
# Description of Changes
Enable ESLint [no-unused-expressions
rule](https://typescript-eslint.io/rules/no-unused-expressions/)
2025-09-04 15:12:38 +00:00
James Brunton
74609e54fe
Enable ESLint prefer-const rule (#4349)
# Description of Changes
Enable ESLint [prefer-const
rule](https://eslint.org/docs/latest/rules/prefer-const)
2025-09-04 15:09:29 +00:00
James Brunton
003285506f
Enable ESLint no-useless-escape rule (#4344)
# Description of Changes
Enable ESLint [no-useless-escape
rule](https://eslint.org/docs/latest/rules/no-useless-escape)
2025-09-04 15:04:49 +00:00
James Brunton
6d3b08d9b6
Enable ESLint no-empty rule (#4342)
# Description of Changes
Enable ESLint [no-empty
rule](https://eslint.org/docs/latest/rules/no-empty)
2025-09-04 15:59:31 +01:00
James Brunton
295e682e03
Add linting to frontend (#4341)
# Description of Changes
There's no current linter running over our TypeScript code, which means
we've got a bunch of dead code and other code smells around with nothing
notifying us. This PR adds ESLint with the typescript-eslint plugin and
enables the recommended settings as a starting point for us.

I've disabled all of the failing rules for the scope of this PR, just to
get linting running without causing a massive diff. I'll follow up with
future PRs that enable the failing rules one by one.

Also updates our version of TypeScript, which introduces a new type
error in the code (which I've had to fix)
2025-09-04 14:08:28 +01:00
Connor Yoh
02740b2741 show history 2025-09-04 12:11:09 +01:00
Connor Yoh
d0c6ae2c31 Precomputed-leaf files 2025-09-04 11:26:55 +01:00
147 changed files with 3525 additions and 1522 deletions

View File

@ -24,7 +24,7 @@ indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[{*.js,*.jsx,*.ts,*.tsx}]
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
indent_size = 2
[*.css]

View File

@ -147,6 +147,8 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Lint frontend
run: cd frontend && npm run lint
- name: Build frontend
run: cd frontend && npm run build
- name: Run frontend tests

View File

@ -19,5 +19,6 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
]
}

300
ADDING_TOOLS.md Normal file
View File

@ -0,0 +1,300 @@
# Adding New React Tools to Stirling PDF
This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools.
## Overview
When adding tools, follow this systematic approach using the established patterns and architecture.
## 1. Create Tool Structure
Create these files in the correct directories:
```
frontend/src/hooks/tools/[toolName]/
├── use[ToolName]Parameters.ts # Parameter definitions and validation
└── use[ToolName]Operation.ts # Tool operation logic using useToolOperation
frontend/src/components/tools/[toolName]/
└── [ToolName]Settings.tsx # Settings UI component (if needed)
frontend/src/tools/
└── [ToolName].tsx # Main tool component
```
## 2. Implementation Pattern
Use `useBaseTool` for simplified hook management. This is the recommended approach for all new tools:
**Parameters Hook** (`use[ToolName]Parameters.ts`):
```typescript
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface [ToolName]Parameters extends BaseParameters {
// Define your tool-specific parameters here
someOption: boolean;
}
export const defaultParameters: [ToolName]Parameters = {
someOption: false,
};
export const use[ToolName]Parameters = (): BaseParametersHook<[ToolName]Parameters> => {
return useBaseParameters({
defaultParameters,
endpointName: 'your-endpoint-name',
validateFn: (params) => true, // Add validation logic
});
};
```
**Operation Hook** (`use[ToolName]Operation.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
export const build[ToolName]FormData = (parameters: [ToolName]Parameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Add parameters to formData
return formData;
};
export const [toolName]OperationConfig = {
toolType: ToolType.singleFile, // or ToolType.multiFile (buildFormData's file parameter will need to be updated)
buildFormData: build[ToolName]FormData,
operationType: '[toolName]',
endpoint: '/api/v1/category/endpoint-name',
filePrefix: 'processed_', // Will be overridden with translation
defaultParameters,
} as const;
export const use[ToolName]Operation = () => {
const { t } = useTranslation();
return useToolOperation({
...[toolName]OperationConfig,
filePrefix: t('[toolName].filenamePrefix', 'processed') + '_',
getErrorMessage: createStandardErrorHandler(t('[toolName].error.failed', 'Operation failed'))
});
};
```
**Main Component** (`[ToolName].tsx`):
```typescript
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { use[ToolName]Parameters } from "../hooks/tools/[toolName]/use[ToolName]Parameters";
import { use[ToolName]Operation } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const [ToolName] = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool('[toolName]', use[ToolName]Parameters, use[ToolName]Operation, props);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("[toolName].files.placeholder", "Select files to get started"),
},
steps: [
// Add settings steps if needed
],
executeButton: {
text: t("[toolName].submit", "Process"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("[toolName].results.title", "Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
[ToolName].tool = () => use[ToolName]Operation;
export default [ToolName] as ToolComponent;
```
**Note**: Some existing tools (like AddPassword, Compress) use a legacy pattern with manual hook management. **Always use the Modern Pattern above for new tools** - it's cleaner, more maintainable, and includes automation support.
## 3. Register Tool in System
Update these files to register your new tool:
**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`):
1. Add imports at the top:
```typescript
import [ToolName] from "../tools/[ToolName]";
import { [toolName]OperationConfig } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Settings";
```
2. Add tool entry in the `allTools` object:
```typescript
[toolName]: {
icon: <LocalIcon icon="your-icon-name" width="1.5rem" height="1.5rem" />,
name: t("home.[toolName].title", "Tool Name"),
component: [ToolName],
description: t("home.[toolName].desc", "Tool description"),
categoryId: ToolCategoryId.STANDARD_TOOLS, // or appropriate category
subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY,
maxFiles: -1, // or specific number
endpoints: ["endpoint-name"],
operationConfig: [toolName]OperationConfig,
settingsComponent: [ToolName]Settings, // if settings exist
},
```
## 4. Add Tooltips (Optional but Recommended)
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const use[ToolName]Tips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("[toolName].tooltip.header.title", "Tool Overview")
},
tips: [
{
title: t("[toolName].tooltip.description.title", "What does this tool do?"),
description: t("[toolName].tooltip.description.text", "Simple explanation in everyday language that non-technical users can understand."),
bullets: [
t("[toolName].tooltip.description.bullet1", "Easy-to-understand benefit 1"),
t("[toolName].tooltip.description.bullet2", "Easy-to-understand benefit 2")
]
}
// Add more tip sections as needed
]
};
};
```
**Add tooltip to your main component:**
```typescript
import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips";
const [ToolName] = (props: BaseToolProps) => {
const tips = use[ToolName]Tips();
// In your steps array:
steps: [
{
title: t("[toolName].steps.settings", "Settings"),
tooltip: tips, // Add this line
content: <[ToolName]Settings ... />
}
]
```
## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
**File to update:** `frontend/public/locales/en-GB/translation.json`
**Required Translation Keys**:
```json
{
"home": {
"[toolName]": {
"title": "Tool Name",
"desc": "Tool description"
}
},
"[toolName]": {
"title": "Tool Name",
"submit": "Process",
"filenamePrefix": "processed",
"files": {
"placeholder": "Select files to get started"
},
"steps": {
"settings": "Settings"
},
"options": {
"title": "Tool Options",
"someOption": "Option Label",
"someOption.desc": "Option description",
"note": "General information about the tool."
},
"results": {
"title": "Results"
},
"error": {
"failed": "Operation failed"
},
"tooltip": {
"header": {
"title": "Tool Overview"
},
"description": {
"title": "What does this tool do?",
"text": "Simple explanation in everyday language",
"bullet1": "Easy-to-understand benefit 1",
"bullet2": "Easy-to-understand benefit 2"
}
}
}
}
```
**Translation Notes:**
- **Only update `en-GB/translation.json`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks
- Add `options.*` keys if your tool has settings with descriptions
**Tooltip Writing Guidelines:**
- **Use simple, everyday language** - avoid technical terms like "converts interactive elements"
- **Focus on benefits** - explain what the user gains, not how it works internally
- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened"
- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?"
- **Keep descriptions concise** - 1-2 sentences maximum per section
- **Use bullet points** for multiple benefits or features
## 6. Migration from Thymeleaf
When migrating existing Thymeleaf templates:
1. **Identify Form Parameters**: Look at the original `<form>` inputs to determine parameter structure
2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing)
3. **Map API Endpoint**: Note the `th:action` URL for the operation hook
4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI
## 7. Testing Your Tool
- Verify tool appears in UI with correct icon and description
- Test with various file sizes and types
- Confirm translations work
- Check error handling
- Test undo functionality
- Verify results display correctly
## Tool Development Patterns
### Three Tool Patterns:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR

View File

@ -208,6 +208,7 @@ return useToolOperation({
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
## Communication Style
- Be direct and to the point

View File

@ -0,0 +1,42 @@
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
{
ignores: [
"dist", // Contains 3rd party code
"public", // Contains 3rd party code
],
},
{
rules: {
"no-undef": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-empty-object-type": [
"error",
{
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: 'with-single-extends',
},
],
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
{
"args": "all", // All function args must be used (or explicitly ignored)
"argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"caughtErrors": "all", // Caught errors must be used (or explicitly ignored)
"caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
},
],
},
}
);

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,7 @@
"predev": "npm run generate-icons",
"dev": "npx tsc --noEmit && vite",
"prebuild": "npm run generate-icons",
"lint": "npx eslint",
"build": "npx tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
@ -72,6 +73,7 @@
]
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@iconify-json/material-symbols": "^1.2.33",
"@iconify/utils": "^3.0.1",
"@playwright/test": "^1.40.0",
@ -80,6 +82,7 @@
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^1.0.0",
"eslint": "^9.34.0",
"jsdom": "^23.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
@ -87,7 +90,8 @@
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5.8.3",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"vite": "^6.3.5",
"vitest": "^1.0.0"
}

View File

@ -1305,7 +1305,48 @@
"title": "Flatten",
"header": "Flatten PDF",
"flattenOnlyForms": "Flatten only forms",
"submit": "Flatten"
"submit": "Flatten",
"filenamePrefix": "flattened",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"steps": {
"settings": "Settings"
},
"options": {
"stepTitle": "Flatten Options",
"title": "Flatten Options",
"flattenOnlyForms": "Flatten only forms",
"flattenOnlyForms.desc": "Only flatten form fields, leaving other interactive elements intact",
"note": "Flattening removes interactive elements from the PDF, making them non-editable."
},
"results": {
"title": "Flatten Results"
},
"error": {
"failed": "An error occurred while flattening the PDF."
},
"tooltip": {
"header": {
"title": "About Flattening PDFs"
},
"description": {
"title": "What does flattening do?",
"text": "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere.",
"bullet1": "Text boxes become regular text (can't be edited)",
"bullet2": "Checkboxes and buttons become pictures",
"bullet3": "Great for final versions you don't want changed",
"bullet4": "Ensures consistent appearance across all devices"
},
"formsOnly": {
"title": "What does 'Flatten only forms' mean?",
"text": "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments.",
"bullet1": "Forms become non-editable",
"bullet2": "Links still work when clicked",
"bullet3": "Comments and notes remain visible",
"bullet4": "Bookmarks still help you navigate"
}
}
},
"repair": {
"tags": "fix,restore,correction,recover",

View File

@ -107,7 +107,7 @@ async function main() {
needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
}
} catch (error) {
} catch {
// If we can't parse existing file, regenerate
needsRegeneration = true;
}

View File

@ -24,7 +24,7 @@ try {
// Install license-checker if not present
try {
require.resolve('license-checker');
} catch (e) {
} catch {
console.log('📦 Installing license-checker...');
execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
}
@ -224,7 +224,7 @@ function getLicenseUrl(licenseType) {
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (licenseType.includes('AND') || licenseType.includes('OR')) {
// Extract the first license from compound expressions for URL
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-\.]+)/);
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
if (match && licenseUrls[match[1]]) {
return licenseUrls[match[1]];
}

View File

@ -14,6 +14,9 @@ import "./styles/cookieconsent.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
// Import file ID debugging helpers (development only)
import "./utils/fileIdSafety";
// Loading component for i18next suspense
const LoadingFallback = () => (
<div

View File

@ -4,7 +4,6 @@ import { Dropzone } from '@mantine/dropzone';
import { FileMetadata } from '../types/file';
import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext';
import { createFileId } from '../types/fileContext';
import { Tool } from '../types/tool';
import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
@ -21,13 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
// Wrapper for storeFile that generates UUID
const storeFileWithId = useCallback(async (file: File) => {
const fileId = createFileId(); // Generate UUID for storage
return await storeFile(file, fileId);
}, [storeFile]);
const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager();
// File management handlers
const isFileSupported = useCallback((fileName: string) => {

View File

@ -1,42 +1,28 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import React, { useState, useCallback, useRef, useMemo } from 'react';
import {
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext';
import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import styles from './FileEditor.module.css';
import FileEditorThumbnail from './FileEditorThumbnail';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId } from '../../types/file';
import { FileId, StirlingFile } from '../../types/fileContext';
interface FileEditorProps {
onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void;
onOpenPageEditor?: () => void;
onMergeFiles?: (files: StirlingFile[]) => void;
toolMode?: boolean;
showUpload?: boolean;
showBulkActions?: boolean;
supportedExtensions?: string[];
}
const FileEditor = ({
onOpenPageEditor,
onMergeFiles,
toolMode = false,
showUpload = true,
showBulkActions = true,
supportedExtensions = ["pdf"]
}: FileEditorProps) => {
const { t } = useTranslation();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
@ -49,13 +35,10 @@ const FileEditor = ({
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
// Extract needed values from state (memoized to prevent infinite loops)
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing;
// Get the real context actions
const { actions } = useFileActions();
// Get navigation actions
const { actions: navActions } = useNavigationActions();
// Get file selection context
@ -92,10 +75,10 @@ const FileEditor = ({
const contextSelectedIdsRef = useRef<FileId[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
// Use activeFileRecords directly - no conversion needed
// Use activeStirlingFileStubs directly - no conversion needed
const localSelectedIds = contextSelectedIds;
// Helper to convert FileRecord to FileThumbnail format
// Helper to convert StirlingFileStub to FileThumbnail format
const recordToFileItem = useCallback((record: any) => {
const file = selectors.getFile(record.id);
if (!file) return null;
@ -162,26 +145,6 @@ const FileEditor = ({
if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles);
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'convert',
timestamp: Date.now(),
fileIds: extractionResult.extractedFiles.map(f => f.name) as FileId[] /* FIX ME: This doesn't seem right */,
status: 'pending',
metadata: {
originalFileName: file.name,
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
fileSize: file.size,
parameters: {
extractionType: 'zip',
extractedCount: extractionResult.extractedCount,
totalFiles: extractionResult.totalFiles
}
}
};
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
@ -214,25 +177,6 @@ const FileEditor = ({
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Record upload operations for PDF files
for (const file of allExtractedFiles) {
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'upload',
timestamp: Date.now(),
fileIds: [file.name as FileId /* This doesn't seem right */],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
uploadMethod: 'drag-drop'
}
}
};
}
// Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles);
setStatus(`Added ${allExtractedFiles.length} files`);
@ -253,27 +197,10 @@ const FileEditor = ({
}
}, [addFiles]);
const selectAll = useCallback(() => {
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
}, [activeFileRecords, setSelectedFiles]);
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
const closeAllFiles = useCallback(() => {
if (activeFileRecords.length === 0) return;
// Remove all files from context but keep in storage
const allFileIds = activeFileRecords.map(record => record.id);
removeFiles(allFileIds, false); // false = keep in storage
// Clear selections
setSelectedFiles([]);
}, [activeFileRecords, removeFiles, setSelectedFiles]);
const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeFileRecords.find(r => r.id === fileId);
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID
@ -303,21 +230,12 @@ const FileEditor = ({
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setSelectedFiles([]);
}
return newMode;
});
}, [setSelectedFiles]);
// File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeFileRecords.map(r => r.id);
const currentIds = activeStirlingFileStubs.map(r => r.id);
// Find indices
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
@ -369,71 +287,34 @@ const FileEditor = ({
// Update status
const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeFileRecords, reorderFiles, setStatus]);
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
// File operations using context
const handleDeleteFile = useCallback((fileId: FileId) => {
const record = activeFileRecords.find(r => r.id === fileId);
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Record close operation
const fileName = file.name;
const contextFileId = record.id;
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'remove',
timestamp: Date.now(),
fileIds: [fileName as FileId /* FIX ME: This doesn't seem right */],
status: 'pending',
metadata: {
originalFileName: fileName,
fileSize: record.size,
parameters: {
action: 'close',
reason: 'user_request'
}
}
};
// Remove file from context but keep in storage (close, don't delete)
const contextFileId = record.id;
removeFiles([contextFileId], false);
// Remove from context selections
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
setSelectedFiles(currentSelected);
}
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeFileRecords.find(r => r.id === fileId);
const record = activeStirlingFileStubs.find(r => r.id === fileId);
if (record) {
// Set the file as selected in context and switch to viewer for preview
setSelectedFiles([fileId]);
navActions.setWorkbench('viewer');
}
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
const handleMergeFromHere = useCallback((fileId: FileId) => {
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
if (startIndex === -1) return;
const recordsToMerge = activeFileRecords.slice(startIndex);
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [activeFileRecords, selectors, onMergeFiles]);
const handleSplitFile = useCallback((fileId: FileId) => {
const file = selectors.getFile(fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file);
}
}, [selectors, onOpenPageEditor]);
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
@ -468,7 +349,7 @@ const FileEditor = ({
<Box p="md" pt="xl">
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
@ -476,7 +357,7 @@ const FileEditor = ({
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
<Box>
<SkeletonLoader type="controls" />
@ -523,7 +404,7 @@ const FileEditor = ({
pointerEvents: 'auto'
}}
>
{activeFileRecords.map((record, index) => {
{activeStirlingFileStubs.map((record, index) => {
const fileItem = recordToFileItem(record);
if (!fileItem) return null;
@ -532,7 +413,7 @@ const FileEditor = ({
key={record.id}
file={fileItem}
index={index}
totalFiles={activeFileRecords.length}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}

View File

@ -45,7 +45,6 @@ const FileEditorThumbnail = ({
selectedFiles,
onToggleFile,
onDeleteFile,
onViewFile,
onSetStatus,
onReorderFiles,
onDownloadFile,
@ -62,12 +61,12 @@ const FileEditorThumbnail = ({
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
}, [activeFiles, file.name, file.size]);
return activeFiles.find(f => f.fileId === file.id);
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
// Get file record to access tool history
const fileRecord = selectors.getFileRecord(file.id);
const fileRecord = selectors.getStirlingFileStub(file.id);
const toolHistory = fileRecord?.toolHistory || [];
const hasToolHistory = toolHistory.length > 0;
const versionNumber = fileRecord?.versionNumber || 0;

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { Stack, Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
@ -22,7 +22,6 @@ const FileDetails: React.FC<FileDetailsProps> = ({
// Get the currently displayed file
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = selectedFiles.length > 1;
// Use IndexedDB hook for the current file
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);

View File

@ -53,12 +53,10 @@ const FileListArea: React.FC<FileListAreaProps> = ({
</Center>
) : (
filteredFiles.map((file, index) => {
// Check if this file is a leaf (appears in group keys) or a history file
const isLeafFile = fileGroups.has(file.id);
const lineagePath = fileGroups.get(file.id) || [];
const isHistoryFile = !isLeafFile; // If not a leaf, it's a history file
const isLatestVersion = isLeafFile; // Leaf files are the latest in their branch
const hasVersionHistory = lineagePath.length > 1;
// Determine if this is a history file based on whether it's in the recent files or loaded as history
const isLeafFile = recentFiles.some(rf => rf.id === file.id);
const isHistoryFile = !isLeafFile; // If not in recent files, it's a loaded history file
const isLatestVersion = isLeafFile; // Only leaf files (from recent files) are latest versions
return (
<FileListItem
@ -71,7 +69,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onDownload={() => onDownloadSingle(file)}
onDoubleClick={() => onFileDoubleClick(file)}
isHistoryFile={isHistoryFile}
isLatestVersion={isLatestVersion && hasVersionHistory}
isLatestVersion={isLatestVersion}
/>
);
})

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge, Button } from '@mantine/core';
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge, Button, Loader } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
@ -38,7 +38,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const { fileGroups, expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
const { fileGroups, expandedFileIds, onToggleExpansion, onAddToRecents, isLoadingHistory, getHistoryError } = useFileManagerContext();
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
@ -46,10 +46,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
// Get version information for this file
const leafFileId = isLatestVersion ? file.id : (file.originalFileId || file.id);
const lineagePath = fileGroups.get(leafFileId) || [];
const hasVersionHistory = lineagePath.length > 1;
const hasVersionHistory = (file.versionNumber || 0) > 0; // Show history for any processed file (v1+)
const currentVersion = file.versionNumber || 0; // Display original files as v0
const isExpanded = expandedFileIds.has(leafFileId);
// Get loading state for this file's history
const isLoadingFileHistory = isLoadingHistory(file.id);
const historyError = getHistoryError(file.id);
return (
<>
<Box
@ -91,6 +95,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" align="center">
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
{isLoadingFileHistory && <Loader size={14} />}
<Badge size="xs" variant="light" color={currentVersion > 0 ? "blue" : "gray"}>
v{currentVersion}
</Badge>
@ -100,7 +105,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Text size="xs" c="dimmed">
{getFileSize(file)} {getFileDate(file)}
{hasVersionHistory && (
<Text span c="dimmed"> {lineagePath.length} versions</Text>
<Text span c="dimmed"> has history</Text>
)}
</Text>
@ -157,17 +162,30 @@ const FileListItem: React.FC<FileListItemProps> = ({
{isLatestVersion && hasVersionHistory && (
<>
<Menu.Item
leftSection={<HistoryIcon style={{ fontSize: 16 }} />}
leftSection={
isLoadingFileHistory ?
<Loader size={16} /> :
<HistoryIcon style={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
onToggleExpansion(leafFileId);
}}
disabled={isLoadingFileHistory}
>
{isExpanded ?
{isLoadingFileHistory ?
t('fileManager.loadingHistory', 'Loading History...') :
(isExpanded ?
t('fileManager.hideHistory', 'Hide History') :
t('fileManager.showHistory', 'Show History')
)
}
</Menu.Item>
{historyError && (
<Menu.Item disabled c="red" style={{ fontSize: '12px' }}>
{t('fileManager.historyError', 'Error loading history')}
</Menu.Item>
)}
<Menu.Divider />
</>
)}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Stack, Box } from '@mantine/core';
import { Box } from '@mantine/core';
import FileSourceButtons from './FileSourceButtons';
import FileDetails from './FileDetails';
import SearchInput from './SearchInput';

View File

@ -1,181 +0,0 @@
import React from 'react';
import {
Stack,
Paper,
Text,
Badge,
Group,
Collapse,
Box,
ScrollArea,
Code,
Divider
} from '@mantine/core';
// FileContext no longer needed - these were stub functions anyway
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
import { PageOperation } from '../../types/pageEditor';
import { FileId } from '../../types/file';
interface FileOperationHistoryProps {
fileId: FileId;
showOnlyApplied?: boolean;
maxHeight?: number;
}
const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
fileId,
showOnlyApplied = false,
maxHeight = 400
}) => {
// These were stub functions in the old context - replace with empty stubs
const getFileHistory = (fileId: FileId) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
const getAppliedOperations = (fileId: FileId) => [];
const history = getFileHistory(fileId);
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const operations = allOperations.filter((op: any) => 'fileIds' in op) as FileOperation[];
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
const getOperationIcon = (type: string) => {
switch (type) {
case 'split': return '✂️';
case 'merge': return '🔗';
case 'compress': return '🗜️';
case 'rotate': return '🔄';
case 'delete': return '🗑️';
case 'move': return '↕️';
case 'insert': return '📄';
case 'upload': return '⬆️';
case 'add': return '';
case 'remove': return '';
case 'replace': return '🔄';
case 'convert': return '🔄';
default: return '⚙️';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'applied': return 'green';
case 'failed': return 'red';
case 'pending': return 'yellow';
default: return 'gray';
}
};
const renderOperationDetails = (operation: FileOperation) => {
if ('metadata' in operation && operation.metadata) {
const { metadata } = operation;
return (
<Box mt="xs">
{metadata.parameters && (
<Text size="xs" c="dimmed">
Parameters: <Code>{JSON.stringify(metadata.parameters, null, 2)}</Code>
</Text>
)}
{metadata.originalFileName && (
<Text size="xs" c="dimmed">
Original file: {metadata.originalFileName}
</Text>
)}
{metadata.outputFileNames && (
<Text size="xs" c="dimmed">
Output files: {metadata.outputFileNames.join(', ')}
</Text>
)}
{metadata.fileSize && (
<Text size="xs" c="dimmed">
File size: {(metadata.fileSize / 1024 / 1024).toFixed(2)} MB
</Text>
)}
{metadata.pageCount && (
<Text size="xs" c="dimmed">
Pages: {metadata.pageCount}
</Text>
)}
{metadata.error && (
<Text size="xs" c="red">
Error: {metadata.error}
</Text>
)}
</Box>
);
}
return null;
};
if (!history || operations.length === 0) {
return (
<Paper p="md" withBorder>
<Text c="dimmed" ta="center">
{showOnlyApplied ? 'No applied operations found' : 'No operation history available'}
</Text>
</Paper>
);
}
return (
<Paper p="md" withBorder>
<Group justify="space-between" mb="md">
<Text fw={500}>
{showOnlyApplied ? 'Applied Operations' : 'Operation History'}
</Text>
<Badge variant="light" color="blue">
{operations.length} operations
</Badge>
</Group>
<ScrollArea h={maxHeight}>
<Stack gap="sm">
{operations.map((operation, index) => (
<Paper key={operation.id} p="sm" withBorder radius="sm" bg="gray.0">
<Group justify="space-between" align="start">
<Group gap="xs">
<Text span size="lg">
{getOperationIcon(operation.type)}
</Text>
<Box>
<Text fw={500} size="sm">
{operation.type.charAt(0).toUpperCase() + operation.type.slice(1)}
</Text>
<Text size="xs" c="dimmed">
{formatTimestamp(operation.timestamp)}
</Text>
</Box>
</Group>
<Badge
variant="filled"
color={getStatusColor(operation.status)}
size="sm"
>
{operation.status}
</Badge>
</Group>
{renderOperationDetails(operation)}
{index < operations.length - 1 && <Divider mt="sm" />}
</Paper>
))}
</Stack>
</ScrollArea>
{history && (
<Group justify="space-between" mt="sm" pt="sm" style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}>
<Text size="xs" c="dimmed">
Created: {formatTimestamp(history.createdAt)}
</Text>
<Text size="xs" c="dimmed">
Last modified: {formatTimestamp(history.lastModified)}
</Text>
</Group>
)}
</Paper>
);
};
export default FileOperationHistory;

View File

@ -1,6 +1,4 @@
import React from 'react';
import { Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler';
@ -19,7 +17,6 @@ import Footer from '../shared/Footer';
// No props needed - component uses contexts directly
export default function Workbench() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling
@ -78,11 +75,9 @@ export default function Workbench() {
return (
<FileEditor
toolMode={!!selectedToolId}
showUpload={true}
showBulkActions={!selectedToolId}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolId && {
onOpenPageEditor: (file) => {
onOpenPageEditor: () => {
setCurrentView("pageEditor");
},
onMergeFiles: (filesToMerge) => {

View File

@ -1,8 +1,6 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css';
import { GRID_CONSTANTS } from './constants';
interface DragDropItem {
@ -22,12 +20,7 @@ interface DragDropGridProps<T extends DragDropItem> {
const DragDropGrid = <T extends DragDropItem>({
items,
selectedItems,
selectionMode,
isAnimating,
onReorderPages,
renderItem,
renderSplitMarker,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
@ -92,8 +85,6 @@ const DragDropGrid = <T extends DragDropItem>({
overscan: OVERSCAN,
});
// Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;

View File

@ -1,5 +1,5 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
import { ActionIcon, CheckboxIndicator } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
@ -44,7 +44,6 @@ const FileThumbnail = ({
selectedFiles,
onToggleFile,
onDeleteFile,
onViewFile,
onSetStatus,
onReorderFiles,
onDownloadFile,
@ -61,8 +60,8 @@ const FileThumbnail = ({
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
}, [activeFiles, file.name, file.size]);
return activeFiles.find(f => f.fileId === file.id);
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => {
@ -93,40 +92,6 @@ const FileThumbnail = ({
// ---- Selection ----
const isSelected = selectedFiles.includes(file.id);
// ---- Meta formatting ----
const prettySize = useMemo(() => {
const bytes = file.size ?? 0;
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}, [file.size]);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toUpperCase();
}, [file.name]);
const pageLabel = useMemo(
() =>
file.pageCount > 0
? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}`
: '',
[file.pageCount]
);
const dateLabel = useMemo(() => {
const d =
file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback
if (Number.isNaN(d.getTime())) return '';
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: '2-digit',
year: 'numeric',
}).format(d);
}, [file.modifiedAt]);
// ---- Drag & drop wiring ----
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;

View File

@ -1,13 +1,7 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
import {
Button, Text, Center, Box,
Notification, TextInput, LoadingOverlay, Modal, Alert,
Stack, Group, Portal
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useState, useCallback, useRef, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "../../contexts/FileContext";
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService";
// Thumbnail generation is now handled by individual PageThumbnail components
@ -19,16 +13,11 @@ import NavigationWarningModal from '../shared/NavigationWarningModal';
import { FileId } from "../../types/file";
import {
DOMCommand,
RotatePageCommand,
DeletePagesCommand,
ReorderPagesCommand,
SplitCommand,
BulkRotateCommand,
BulkSplitCommand,
SplitAllCommand,
PageBreakCommand,
BulkPageBreakCommand,
UndoManager
} from './commands/pageCommands';
import { GRID_CONSTANTS } from './constants';
@ -49,35 +38,24 @@ const PageEditor = ({
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
const selectedFiles = selectors.getSelectedFiles();
// Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
const processingProgress = state.ui.processingProgress;
const hasUnsavedChanges = state.ui.hasUnsavedChanges;
// Edit state management
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false);
const [foundDraft, setFoundDraft] = useState<any>(null);
const autoSaveTimer = useRef<number | null>(null);
// DOM-first undo manager (replaces the old React state undo system)
const undoManagerRef = useRef(new UndoManager());
// Document state management
const { document: mergedPdfDocument, isVeryLargeDocument, isLoading: documentLoading } = usePageDocument();
const { document: mergedPdfDocument } = usePageDocument();
// UI state management
const {
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
setSelectionMode, setSelectedPageIds, setMovingPage, setIsAnimating, setSplitPositions, setExportLoading,
setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading,
togglePage, toggleSelectAll, animateReorder
} = usePageEditorState();
@ -146,12 +124,6 @@ const PageEditor = ({
}).filter(id => id !== '');
}, [displayDocument]);
// Convert selectedPageIds to numbers for components that still need numbers
const selectedPageNumbers = useMemo(() =>
getPageNumbersFromIds(selectedPageIds),
[selectedPageIds, getPageNumbersFromIds]
);
// Select all pages by default when document initially loads
const hasInitializedSelection = useRef(false);
useEffect(() => {

View File

@ -1,4 +1,3 @@
import React from "react";
import {
Tooltip,
ActionIcon,
@ -9,9 +8,7 @@ import ContentCutIcon from "@mui/icons-material/ContentCut";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
import DownloadIcon from "@mui/icons-material/Download";
interface PageEditorControlsProps {
// Close/Reset functions
@ -46,7 +43,6 @@ interface PageEditorControlsProps {
}
const PageEditorControls = ({
onClosePdf,
onUndo,
onRedo,
canUndo,
@ -54,12 +50,7 @@ const PageEditorControls = ({
onRotate,
onDelete,
onSplit,
onSplitAll,
onPageBreak,
onPageBreakAll,
onExportAll,
exportLoading,
selectionMode,
selectedPageIds,
displayDocument,
splitPositions,

View File

@ -52,16 +52,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
pageRefs,
onReorderPages,
onTogglePage,
onAnimateReorder,
onExecuteCommand,
onSetStatus,
onSetMovingPage,
onDeletePage,
createRotateCommand,
createDeleteCommand,
createSplitCommand,
pdfDocument,
setPdfDocument,
splitPositions,
onInsertFiles,
}: PageThumbnailProps) => {
@ -172,7 +169,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
type: 'page',
pageNumber: page.pageNumber
}),
onDrop: ({ source }) => {}
onDrop: (_) => {}
});
(element as any).__dragCleanup = () => {

View File

@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook {
const globalProcessing = state.ui.isProcessing;
// Get primary file record outside useMemo to track processedFile changes
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
const processedFilePages = primaryFileRecord?.processedFile?.pages;
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages;
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
// Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo((): PDFDocument | null => {
@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook {
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
// If we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryFileRecord) {
if (!primaryStirlingFileStub) {
console.log('🎬 PageEditor: No primary file record found, showing loading');
return null;
}
const name =
activeFileIds.length === 1
? (primaryFileRecord.name ?? 'document.pdf')
? (primaryStirlingFileStub.name ?? 'document.pdf')
: activeFileIds
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + ');
// Build page insertion map from files with insertion positions
@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook {
const originalFileIds: FileId[] = [];
activeFileIds.forEach(fileId => {
const record = selectors.getFileRecord(fileId);
const record = selectors.getStirlingFileStub(fileId);
if (record?.insertAfterPageId !== undefined) {
if (!insertionMap.has(record.insertAfterPageId)) {
insertionMap.set(record.insertAfterPageId, []);
@ -68,16 +68,15 @@ export function usePageDocument(): PageDocumentHook {
// Build pages by interleaving original pages with insertions
let pages: PDFPage[] = [];
let totalPageCount = 0;
// Helper function to create pages from a file
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
const fileRecord = selectors.getFileRecord(fileId);
if (!fileRecord) {
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
if (!stirlingFileStub) {
return [];
}
const processedFile = fileRecord.processedFile;
const processedFile = stirlingFileStub.processedFile;
let filePages: PDFPage[] = [];
if (processedFile?.pages && processedFile.pages.length > 0) {
@ -144,8 +143,6 @@ export function usePageDocument(): PageDocumentHook {
});
}
totalPageCount = pages.length;
if (pages.length === 0) {
return null;
}
@ -159,7 +156,7 @@ export function usePageDocument(): PageDocumentHook {
};
return mergedDoc;
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
// Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => {

View File

@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from './Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '../../hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
interface AllToolsNavButtonProps {
activeButton: string;
@ -13,6 +15,7 @@ interface AllToolsNavButtonProps {
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
const { getHomeNavigation } = useSidebarNavigation();
const handleClick = () => {
setActiveButton('tools');
@ -24,6 +27,12 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
// Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const navProps = getHomeNavigation();
const handleNavClick = (e: React.MouseEvent) => {
handleUnlessSpecialClick(e, handleClick);
};
const iconNode = (
<span className="iconContainer">
<AppsIcon sx={{ fontSize: '2rem' }} />
@ -31,18 +40,21 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
);
return (
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon
component="a"
href={navProps.href}
onClick={handleNavClick}
size={'lg'}
variant="subtle"
onClick={handleClick}
aria-label={t("quickAccess.allTools", "All Tools")}
style={{
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
border: 'none',
borderRadius: '8px',
textDecoration: 'none'
}}
className={isActive ? 'activeIconScale' : ''}
>

View File

@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { FileRecord } from "../../types/fileContext";
import { StirlingFileStub } from "../../types/fileContext";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: File;
record?: FileRecord;
record?: StirlingFileStub;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
@ -25,7 +25,7 @@ interface FileCardProps {
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
const thumb = record?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);

View File

@ -1,18 +1,18 @@
import React, { useState } from "react";
import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@mantine/core";
import { useState } from "react";
import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
import FileCard from "./FileCard";
import { FileRecord } from "../../types/fileContext";
import { StirlingFileStub } from "../../types/fileContext";
import { FileId } from "../../types/file";
interface FileGridProps {
files: Array<{ file: File; record?: FileRecord }>;
files: Array<{ file: File; record?: StirlingFileStub }>;
onRemove?: (index: number) => void;
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
onView?: (item: { file: File; record?: FileRecord }) => void;
onEdit?: (item: { file: File; record?: FileRecord }) => void;
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
onSelect?: (fileId: FileId) => void;
selectedFiles?: FileId[];
showSearch?: boolean;
@ -123,9 +123,17 @@ const FileGrid = ({
h="30rem"
style={{ overflowY: "auto", width: "100%" }}
>
{displayFiles.map((item, idx) => {
const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */;
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
{displayFiles
.filter(item => {
if (!item.record?.id) {
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
return false;
}
return true;
})
.map((item, idx) => {
const fileId = item.record!.id; // Safe to assert after filter
const originalIdx = files.findIndex(f => f.record?.id === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return (
<FileCard

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from './LocalIcon';
import { useTranslation } from 'react-i18next';

View File

@ -15,7 +15,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
const { i18n } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
const [isChanging, setIsChanging] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
@ -36,7 +35,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
}
// Start transition animation
setIsChanging(true);
setPendingLanguage(value);
// Simulate processing time for smooth transition
@ -44,7 +42,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
i18n.changeLanguage(value);
setTimeout(() => {
setIsChanging(false);
setPendingLanguage(null);
setOpened(false);

View File

@ -13,7 +13,7 @@ try {
localIconCount = Object.keys(iconSet.icons || {}).length;
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
}
} catch (error) {
} catch {
console.info(' Local icons not available - using CDN fallback');
}

View File

@ -3,10 +3,11 @@ import { ActionIcon, Stack, Divider } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import LocalIcon from './LocalIcon';
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import AppConfigModal from './AppConfigModal';
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '../../hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
import { ButtonConfig } from '../../types/sidebar';
import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
@ -17,12 +18,12 @@ import {
getActiveNavButton,
} from './quickAccessBar/QuickAccessBar';
const QuickAccessBar = forwardRef<HTMLDivElement>(({
}, ref) => {
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@ -37,6 +38,52 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
openFilesModal();
};
// Helper function to render navigation buttons with URL support
const renderNavButton = (config: ButtonConfig, index: number) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
: null;
const handleClick = (e?: React.MouseEvent) => {
if (navProps && e) {
handleUnlessSpecialClick(e, config.onClick);
} else {
config.onClick();
}
};
// Render navigation button with conditional URL support
return (
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
{...(navProps ? {
component: "a" as const,
href: navProps.href,
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick()
})}
size={isActive ? (config.size || 'lg') : 'lg'}
variant="subtle"
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isActive ? 'activeIconScale' : ''}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${isActive ? 'active' : 'inactive'}`}>
{config.name}
</span>
</div>
);
};
const buttonConfigs: ButtonConfig[] = [
{
@ -153,27 +200,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
<Stack gap="lg" align="center">
{buttonConfigs.slice(0, -1).map((config, index) => (
<React.Fragment key={config.id}>
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
size={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? (config.size || 'lg') : 'lg'}
variant="subtle"
onClick={() => {
config.onClick();
}}
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
{config.name}
</span>
</div>
{renderNavButton(config, index)}
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
{index === 1 && (

View File

@ -29,12 +29,11 @@ export default function RightRail() {
// File state and selection
const { state, selectors } = useFileState();
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
const { removeFiles } = useFileManagement();
const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
const fileRecords = selectors.getFileRecords();
// Compute selection state and total items
const getSelectionState = useCallback(() => {

View File

@ -82,8 +82,8 @@ export function adjustFontSizeToFit(
return () => {
cancelAnimationFrame(raf);
try { ro.disconnect(); } catch {}
try { mo.disconnect(); } catch {}
try { ro.disconnect(); } catch { /* Ignore errors */ }
try { mo.disconnect(); } catch { /* Ignore errors */ }
};
}

View File

@ -16,6 +16,8 @@ import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '../../../hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '../../../utils/clickHandlers';
import FitText from '../FitText';
import { Tooltip } from '../Tooltip';
@ -26,8 +28,9 @@ interface ActiveToolButtonProps {
const NAV_IDS = ['read', 'sign', 'automate'];
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setActiveButton }) => {
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
const indicatorShouldShow = Boolean(
@ -38,7 +41,6 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
const [replayAnim, setReplayAnim] = useState<boolean>(false);
const [isAnimating, setIsAnimating] = useState<boolean>(false);
const [isBackHover, setIsBackHover] = useState<boolean>(false);
const prevKeyRef = useRef<string | null>(null);
const collapseTimeoutRef = useRef<number | null>(null);
@ -71,11 +73,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
replayRafRef.current = requestAnimationFrame(() => {
setReplayAnim(true);
});
setIsAnimating(true);
prevKeyRef.current = (selectedToolKey as string) || null;
animTimeoutRef.current = window.setTimeout(() => {
setReplayAnim(false);
setIsAnimating(false);
animTimeoutRef.current = null;
}, 500);
}
@ -84,10 +84,8 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
clearTimers();
setIndicatorTool(selectedTool);
setIndicatorVisible(true);
setIsAnimating(true);
prevKeyRef.current = (selectedToolKey as string) || null;
animTimeoutRef.current = window.setTimeout(() => {
setIsAnimating(false);
animTimeoutRef.current = null;
}, 500);
}
@ -95,11 +93,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
const triggerCollapse = () => {
clearTimers();
setIndicatorVisible(false);
setIsAnimating(true);
collapseTimeoutRef.current = window.setTimeout(() => {
setIndicatorTool(null);
prevKeyRef.current = null;
setIsAnimating(false);
collapseTimeoutRef.current = null;
}, 500); // match CSS transition duration
}
@ -142,21 +138,26 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
<div className="flex flex-col items-center gap-1">
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
<ActionIcon
component="a"
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => {
setActiveButton('tools');
handleBackToTools();
});
}}
size={'xl'}
variant="subtle"
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
onClick={() => {
setActiveButton('tools');
handleBackToTools();
}}
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
style={{
backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)',
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
border: 'none',
borderRadius: '8px',
cursor: 'pointer'
cursor: 'pointer',
textDecoration: 'none'
}}
>
<span className="iconContainer">

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { Box, Stack, Text } from '@mantine/core';
import React from 'react';
import { Box, Stack } from '@mantine/core';
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
import ToolButton from './toolPicker/ToolButton';
import { useTranslation } from 'react-i18next';
@ -40,12 +40,10 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
</Stack>
</Box>
))}
{/* global spacer to allow scrolling past last row in search mode */}
{/* Global spacer to allow scrolling past last row in search mode */}
<div aria-hidden style={{ height: 200 }} />
</Stack>
);
};
export default SearchResults;

View File

@ -1,5 +1,3 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker';
@ -8,12 +6,11 @@ import ToolRenderer from './ToolRenderer';
import ToolSearch from './toolPicker/ToolSearch';
import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
import { Stack, ScrollArea } from '@mantine/core';
import { ScrollArea } from '@mantine/core';
// No props needed - component uses context
export default function ToolPanel() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs;
@ -27,7 +24,6 @@ export default function ToolPanel() {
filteredTools,
toolRegistry,
setSearchQuery,
handleBackToTools
} = useToolWorkflow();
const { selectedToolKey, handleToolSelect } = useToolWorkflow();

View File

@ -3,7 +3,6 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import AddPasswordSettings from './AddPasswordSettings';
import { defaultParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
import type { AddPasswordParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
// Mock useTranslation with predictable return values
const mockT = vi.fn((key: string) => `mock-${key}`);

View File

@ -1,5 +1,4 @@
import React from "react";
import { Stack, Text, PasswordInput, Select } from "@mantine/core";
import { Stack, PasswordInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters";

View File

@ -1,5 +1,4 @@
import React from "react";
import { Button, Stack, Text } from "@mantine/core";
import { Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface WatermarkTypeSettingsProps {

View File

@ -1,5 +1,5 @@
import React from "react";
import { Stack, Text, TextInput } from "@mantine/core";
import { Stack, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import { removeEmojis } from "../../../utils/textUtils";

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
@ -38,10 +38,8 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
automationIcon,
setAutomationIcon,
selectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters
@ -84,14 +82,6 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
updateTool(selectedTools.length, newTool);
};
const handleBackClick = () => {
if (hasUnsavedChanges()) {
setUnsavedWarningOpen(true);
} else {
onBack();
}
};
const handleConfirmBack = () => {
setUnsavedWarningOpen(false);
onBack();

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import { Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';

View File

@ -76,13 +76,13 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
await automateOperation.executeOperation(
{
automationConfig: automation,
onStepStart: (stepIndex: number, operationName: string) => {
onStepStart: (stepIndex: number, _operationName: string) => {
setCurrentStepIndex(stepIndex);
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
));
},
onStepComplete: (stepIndex: number, resultFiles: File[]) => {
onStepComplete: (stepIndex: number, _resultFiles: File[]) => {
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
));

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
@ -32,7 +32,6 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
const { t } = useTranslation();
const [parameters, setParameters] = useState<any>({});
const [isValid, setIsValid] = useState(true);
// Get tool info from registry
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
@ -87,9 +86,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
};
const handleSave = () => {
if (isValid) {
onSave(parameters);
}
};
return (
@ -127,7 +124,6 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
<Button
leftSection={<CheckIcon />}
onClick={handleSave}
disabled={!isValid}
>
{t('automate.config.save', 'Save Configuration')}
</Button>

View File

@ -1,4 +1,4 @@
import { Stack, Text, Checkbox } from "@mantine/core";
import { Stack, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters";

View File

@ -22,13 +22,13 @@ import {
OUTPUT_OPTIONS,
FIT_OPTIONS
} from "../../../constants/convertConstants";
import { FileId } from "../../../types/file";
import { StirlingFile } from "../../../types/fileContext";
interface ConvertSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
selectedFiles: File[];
selectedFiles: StirlingFile[];
disabled?: boolean;
}
@ -129,7 +129,7 @@ const ConvertSettings = ({
};
const filterFilesByExtension = (extension: string) => {
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as StirlingFile[];
return files.filter(file => {
const fileExtension = detectFileExtension(file.name);
@ -143,21 +143,8 @@ const ConvertSettings = ({
});
};
const updateFileSelection = (files: File[]) => {
// Map File objects to their actual IDs in FileContext
const fileIds = files.map(file => {
// Find the file ID by matching file properties
const fileRecord = state.files.ids
.map(id => selectors.getFileRecord(id))
.find(record =>
record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified
);
return fileRecord?.id;
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
const updateFileSelection = (files: StirlingFile[]) => {
const fileIds = files.map(file => file.fileId);
setSelectedFiles(fileIds);
};

View File

@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
import { StirlingFile } from '../../../types/fileContext';
interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
selectedFiles: File[];
selectedFiles: StirlingFile[];
disabled?: boolean;
}

View File

@ -0,0 +1,35 @@
import { Stack, Text, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { FlattenParameters } from "../../../hooks/tools/flatten/useFlattenParameters";
interface FlattenSettingsProps {
parameters: FlattenParameters;
onParameterChange: <K extends keyof FlattenParameters>(key: K, value: FlattenParameters[K]) => void;
disabled?: boolean;
}
const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: FlattenSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Stack gap="sm">
<Checkbox
checked={parameters.flattenOnlyForms}
onChange={(event) => onParameterChange('flattenOnlyForms', event.currentTarget.checked)}
disabled={disabled}
label={
<div>
<Text size="sm">{t('flatten.options.flattenOnlyForms', 'Flatten only forms')}</Text>
<Text size="xs" c="dimmed">
{t('flatten.options.flattenOnlyForms.desc', 'Only flatten form fields, leaving other interactive elements intact')}
</Text>
</div>
}
/>
</Stack>
</Stack>
);
};
export default FlattenSettings;

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Stack, Select, Text, Divider } from '@mantine/core';
import { Stack, Select, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LanguagePicker from './LanguagePicker';
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';

View File

@ -8,11 +8,7 @@ interface RemoveCertificateSignSettingsProps {
disabled?: boolean;
}
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = ({
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = (_) => {
const { t } = useTranslation();
return (

View File

@ -1,4 +1,4 @@
import { Stack, Text, PasswordInput } from "@mantine/core";
import { Stack, PasswordInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/useRemovePasswordParameters";

View File

@ -8,11 +8,7 @@ interface RepairSettingsProps {
disabled?: boolean;
}
const RepairSettings: React.FC<RepairSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const RepairSettings: React.FC<RepairSettingsProps> = (_) => {
const { t } = useTranslation();
return (

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { Text, Anchor } from "@mantine/core";
import { useTranslation } from "react-i18next";
import FolderIcon from '@mui/icons-material/Folder';
@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext";
import { useFileManager } from "../../../hooks/useFileManager";
import { StirlingFile } from "../../../types/fileContext";
export interface FileStatusIndicatorProps {
selectedFiles?: File[];
selectedFiles?: StirlingFile[];
placeholder?: string;
}
@ -17,7 +18,7 @@ const FileStatusIndicator = ({
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext();
const { files: workbenchFiles } = useAllFiles();
const { files: stirlingFileStubs } = useAllFiles();
const { loadRecentFiles } = useFileManager();
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
@ -27,7 +28,7 @@ const FileStatusIndicator = ({
try {
const recentFiles = await loadRecentFiles();
setHasRecentFiles(recentFiles.length > 0);
} catch (error) {
} catch {
setHasRecentFiles(false);
}
};
@ -55,7 +56,7 @@ const FileStatusIndicator = ({
}
// Check if there are no files in the workbench
if (workbenchFiles.length === 0) {
if (stirlingFileStubs.length === 0) {
// If no recent files, show upload button
if (!hasRecentFiles) {
return (

View File

@ -1,9 +1,10 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileStatusIndicator from './FileStatusIndicator';
import { StirlingFile } from '../../../types/fileContext';
export interface FilesToolStepProps {
selectedFiles: File[];
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
onCollapsedClick?: () => void;
placeholder?: string;

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Button, Group, Stack } from "@mantine/core";
import React, { useEffect, useRef } from "react";
import { Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import UndoIcon from "@mui/icons-material/Undo";

View File

@ -1,10 +1,8 @@
import React from 'react';
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
import { Stack, Text, Divider, Card, Group, Anchor } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
export interface SuggestedToolsSectionProps {}
export function SuggestedToolsSection(): React.ReactElement {
const { t } = useTranslation();
const suggestedTools = useSuggestedTools();
@ -21,12 +19,16 @@ export function SuggestedToolsSection(): React.ReactElement {
{suggestedTools.map((tool) => {
const IconComponent = tool.icon;
return (
<Card
<Anchor
key={tool.id}
href={tool.href}
onClick={tool.onClick}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<Card
p="sm"
withBorder
style={{ cursor: 'pointer' }}
onClick={tool.navigate}
>
<Group gap="xs">
<IconComponent fontSize="small" />
@ -35,6 +37,7 @@ export function SuggestedToolsSection(): React.ReactElement {
</Text>
</Group>
</Card>
</Anchor>
);
})}
</Stack>

View File

@ -1,5 +1,5 @@
import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
import React, { createContext, useContext, useMemo } from 'react';
import { Text, Stack, Flex, Divider } from '@mantine/core';
import LocalIcon from '../../shared/LocalIcon';
import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../../types/tips';

View File

@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
import { StirlingFile } from '../../../types/fileContext';
export interface FilesStepConfig {
selectedFiles: File[];
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
placeholder?: string;
onCollapsedClick?: () => void;
@ -80,7 +81,7 @@ export function createToolFlow(config: ToolFlowConfig) {
})}
{/* Middle Steps */}
{config.steps.map((stepConfig, index) =>
{config.steps.map((stepConfig) =>
steps.create(stepConfig.title, {
isVisible: stepConfig.isVisible,
isCollapsed: stepConfig.isCollapsed,

View File

@ -1,5 +1,4 @@
import React from 'react';
import { Box, Stack } from '@mantine/core';
import { Box } from '@mantine/core';
import ToolButton from '../toolPicker/ToolButton';
import SubcategoryHeader from './SubcategoryHeader';

View File

@ -8,11 +8,7 @@ interface SingleLargePageSettingsProps {
disabled?: boolean;
}
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = (_) => {
const { t } = useTranslation();
return (

View File

@ -2,6 +2,8 @@ import React from "react";
import { Button } from "@mantine/core";
import { Tooltip } from "../../shared/Tooltip";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { useToolNavigation } from "../../../hooks/useToolNavigation";
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
import FitText from "../../shared/FitText";
interface ToolButtonProps {
@ -14,6 +16,8 @@ interface ToolButtonProps {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
const isUnavailable = !tool.component && !tool.link;
const { getToolNavigation } = useToolNavigation();
const handleClick = (id: string) => {
if (isUnavailable) return;
if (tool.link) {
@ -25,23 +29,15 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
onSelect(id);
};
// Get navigation props for URL support
const navProps = !isUnavailable && !tool.link ? getToolNavigation(id, tool) : null;
const tooltipContent = isUnavailable
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
: tool.description;
return (
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={()=> handleClick(id)}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
>
const buttonContent = (
<>
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
<FitText
text={tool.name}
@ -50,7 +46,67 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
</>
);
const handleExternalClick = (e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => handleClick(id));
};
const buttonElement = navProps ? (
// For internal tools with URLs, render Button as an anchor for proper link behavior
<Button
component="a"
href={navProps.href}
onClick={navProps.onClick}
variant={isSelected ? "filled" : "subtle"}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
>
{buttonContent}
</Button>
) : tool.link && !isUnavailable ? (
// For external links, render Button as an anchor with proper href
<Button
component="a"
href={tool.link}
target="_blank"
rel="noopener noreferrer"
onClick={handleExternalClick}
variant={isSelected ? "filled" : "subtle"}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
>
{buttonContent}
</Button>
) : (
// For unavailable tools, use regular button
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={() => handleClick(id)}
size="sm"
radius="md"
fullWidth
justify="flex-start"
className="tool-button"
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
>
{buttonContent}
</Button>
);
return (
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
{buttonElement}
</Tooltip>
);
};

View File

@ -126,7 +126,7 @@ const ToolSearch = ({
key={id}
variant="subtle"
onClick={() => {
onToolSelect && onToolSelect(id);
onToolSelect?.(id);
setDropdownOpen(false);
}}
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}

View File

@ -8,11 +8,7 @@ interface UnlockPdfFormsSettingsProps {
disabled?: boolean;
}
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = (_) => {
const { t } = useTranslation();
return (

View File

@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useFlattenTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("flatten.tooltip.header.title", "About Flattening PDFs")
},
tips: [
{
title: t("flatten.tooltip.description.title", "What does flattening do?"),
description: t("flatten.tooltip.description.text", "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere."),
bullets: [
t("flatten.tooltip.description.bullet1", "Text boxes become regular text (can't be edited)"),
t("flatten.tooltip.description.bullet2", "Checkboxes and buttons become pictures"),
t("flatten.tooltip.description.bullet3", "Great for final versions you don't want changed"),
t("flatten.tooltip.description.bullet4", "Ensures consistent appearance across all devices")
]
},
{
title: t("flatten.tooltip.formsOnly.title", "What does 'Flatten only forms' mean?"),
description: t("flatten.tooltip.formsOnly.text", "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments."),
bullets: [
t("flatten.tooltip.formsOnly.bullet1", "Forms become non-editable"),
t("flatten.tooltip.formsOnly.bullet2", "Links still work when clicked"),
t("flatten.tooltip.formsOnly.bullet3", "Comments and notes remain visible"),
t("flatten.tooltip.formsOnly.bullet4", "Bookmarks still help you navigate")
]
}
]
};
};

View File

@ -1,20 +1,19 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
import { Paper, Stack, Text, ScrollArea, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import LastPageIcon from "@mui/icons-material/LastPage";
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
import CloseIcon from "@mui/icons-material/Close";
import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
import { useFileState } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { isFileObject } from "../../types/fileContext";
import { FileId } from "../../types/file";
@ -141,8 +140,6 @@ export interface ViewerProps {
}
const Viewer = ({
sidebarsVisible,
setSidebarsVisible,
onClose,
previewFile,
}: ViewerProps) => {
@ -151,13 +148,7 @@ const Viewer = ({
// Get current file from FileContext
const { selectors } = useFileState();
const { actions } = useFileActions();
const currentFile = useCurrentFile();
const getCurrentFile = () => currentFile.file;
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
const clearAllFiles = actions.clearAllFiles;
const addFiles = actions.addFiles;
const activeFiles = selectors.getFiles();
// Tab management for multiple files
@ -201,7 +192,7 @@ const Viewer = ({
const effectiveFile = React.useMemo(() => {
if (previewFile) {
// Validate the preview file
if (!(previewFile instanceof File)) {
if (!isFileObject(previewFile)) {
return null;
}
@ -405,7 +396,7 @@ const Viewer = ({
// Start progressive preloading after a short delay
setTimeout(() => startProgressivePreload(), 100);
}
} catch (error) {
} catch {
if (!cancelled) {
setPageImages([]);
setNumPages(0);

View File

@ -19,7 +19,10 @@ import {
FileContextStateValue,
FileContextActionsValue,
FileContextActions,
FileRecord
FileId,
StirlingFileStub,
StirlingFile,
createStirlingFile
} from '../types/fileContext';
// Import modular components
@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
import { FileLifecycleManager } from './file/lifecycle';
import { FileStateContext, FileActionsContext } from './file/contexts';
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
import { FileId } from '../types/file';
const DEBUG = process.env.NODE_ENV === 'development';
@ -37,7 +39,6 @@ const DEBUG = process.env.NODE_ENV === 'development';
// Inner provider component that has access to IndexedDB
function FileContextInner({
children,
enableUrlSync = true,
enablePersistence = true
}: FileContextProviderProps) {
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
@ -79,7 +80,7 @@ function FileContextInner({
}
// File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<File[]> => {
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested
@ -98,15 +99,15 @@ function FileContextInner({
}));
}
return addedFilesWithIds.map(({ file }) => file);
return addedFilesWithIds.map(({ file, id }) => createStirlingFile(file, id));
}, [indexedDB, enablePersistence]);
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => {
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<StirlingFile[]> => {
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
return result.map(({ file }) => file);
return result.map(({ file, id }) => createStirlingFile(file, id));
}, []);
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<File[]> => {
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested
@ -114,7 +115,7 @@ function FileContextInner({
selectFiles(result);
}
return result.map(({ file }) => file);
return result.map(({ file, id }) => createStirlingFile(file, id));
}, []);
// Action creators
@ -122,42 +123,21 @@ function FileContextInner({
// Helper functions for pinned files
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB);
return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
}, [indexedDB]);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
}, [indexedDB]);
// Helper to find FileId from File object
const findFileId = useCallback((file: File): FileId | undefined => {
return (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
const storedFile = filesRef.current.get(id);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
}, []);
// File pinning functions - use StirlingFile directly
const pinFileWrapper = useCallback((file: StirlingFile) => {
baseActions.pinFile(file.fileId);
}, [baseActions]);
// File-to-ID wrapper functions for pinning
const pinFileWrapper = useCallback((file: File) => {
const fileId = findFileId(file);
if (fileId) {
baseActions.pinFile(fileId);
} else {
console.warn('File not found for pinning:', file.name);
}
}, [baseActions, findFileId]);
const unpinFileWrapper = useCallback((file: File) => {
const fileId = findFileId(file);
if (fileId) {
baseActions.unpinFile(fileId);
} else {
console.warn('File not found for unpinning:', file.name);
}
}, [baseActions, findFileId]);
const unpinFileWrapper = useCallback((file: StirlingFile) => {
baseActions.unpinFile(file.fileId);
}, [baseActions]);
// Complete actions object
const actions = useMemo<FileContextActions>(() => ({
@ -178,8 +158,8 @@ function FileContextInner({
}
}
},
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) =>
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef),
reorderFiles: (orderedFileIds: FileId[]) => {
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
},
@ -303,7 +283,7 @@ export {
useFileSelection,
useFileManagement,
useFileUI,
useFileRecord,
useStirlingFileStub,
useAllFiles,
useSelectedFiles,
// Primary API hooks for tools

View File

@ -1,9 +1,10 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { FileMetadata } from '../types/file';
import { StoredFile, fileStorage } from '../services/fileStorage';
import { fileStorage } from '../services/fileStorage';
import { downloadFiles } from '../utils/downloadUtils';
import { FileId } from '../types/file';
import { getLatestVersions, groupFilesByOriginal, getVersionHistory } from '../utils/fileHistoryUtils';
import { getLatestVersions, groupFilesByOriginal, getVersionHistory, createFileMetadataWithHistory } from '../utils/fileHistoryUtils';
import { useMultiFileHistory } from '../hooks/useFileHistory';
// Type for the context value - now contains everything directly
interface FileManagerContextValue {
@ -18,6 +19,10 @@ interface FileManagerContextValue {
expandedFileIds: Set<string>;
fileGroups: Map<string, FileMetadata[]>;
// History loading state
isLoadingHistory: (fileId: FileId) => boolean;
getHistoryError: (fileId: FileId) => string | null;
// Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onLocalFileClick: () => void;
@ -75,11 +80,20 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [expandedFileIds, setExpandedFileIds] = useState<Set<string>>(new Set());
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, FileMetadata[]>>(new Map()); // Cache for loaded history
const fileInputRef = useRef<HTMLInputElement>(null);
// Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(new Set());
// History loading hook
const {
loadFileHistory,
getHistory,
isLoadingHistory,
getError: getHistoryError
} = useMultiFileHistory();
// Computed values (with null safety)
const selectedFilesSet = new Set(selectedFileIds);
@ -101,36 +115,24 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const displayFiles = useMemo(() => {
if (!recentFiles || recentFiles.length === 0) return [];
const recordsForGrouping = recentFiles.map(file => ({
...file,
originalFileId: file.originalFileId,
versionNumber: file.versionNumber || 0
}));
// Get branch groups (leaf files with their lineage paths)
const branchGroups = groupFilesByOriginal(recordsForGrouping);
// Show only leaf files (end of branches) in main list
const expandedFiles = [];
for (const [leafFileId, lineagePath] of branchGroups) {
const leafFile = recentFiles.find(f => f.id === leafFileId);
if (!leafFile) continue;
// Add the leaf file (shown in main list)
// Since we now only load leaf files, iterate through recent files directly
for (const leafFile of recentFiles) {
// Add the leaf file (main file shown in list)
expandedFiles.push(leafFile);
// If expanded, add the lineage history (except the leaf itself)
if (expandedFileIds.has(leafFileId)) {
const historyFiles = lineagePath
.filter((record: any) => record.id !== leafFileId)
.map((record: any) => recentFiles.find(f => f.id === record.id))
.filter((f): f is FileMetadata => f !== undefined);
expandedFiles.push(...historyFiles);
// If expanded, add the loaded history files
if (expandedFileIds.has(leafFile.id)) {
const historyFiles = loadedHistoryFiles.get(leafFile.id) || [];
// Sort history files by version number (oldest first)
const sortedHistory = historyFiles.sort((a, b) => (a.versionNumber || 0) - (b.versionNumber || 0));
expandedFiles.push(...sortedHistory);
}
}
return expandedFiles;
}, [recentFiles, expandedFileIds, fileGroups]);
}, [recentFiles, expandedFileIds, loadedHistoryFiles]);
const selectedFiles = selectedFileIds.length === 0 ? [] :
displayFiles.filter(file => selectedFilesSet.has(file.id));
@ -194,13 +196,47 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, [filteredFiles, lastClickedIndex]);
const handleFileRemove = useCallback((index: number) => {
const handleFileRemove = useCallback(async (index: number) => {
const fileToRemove = filteredFiles[index];
if (fileToRemove) {
setSelectedFileIds(prev => prev.filter(id => id !== fileToRemove.id));
const deletedFileId = fileToRemove.id;
// Clear from selection immediately
setSelectedFileIds(prev => prev.filter(id => id !== deletedFileId));
// Clear from expanded state to prevent ghost entries
setExpandedFileIds(prev => {
const newExpanded = new Set(prev);
newExpanded.delete(deletedFileId);
return newExpanded;
});
// Clear from history cache - need to remove this file from any cached history
setLoadedHistoryFiles(prev => {
const newCache = new Map(prev);
// If the deleted file was a main file with cached history, remove its cache
newCache.delete(deletedFileId);
// Also remove the deleted file from any other file's history cache
for (const [mainFileId, historyFiles] of newCache.entries()) {
const filteredHistory = historyFiles.filter(histFile => histFile.id !== deletedFileId);
if (filteredHistory.length !== historyFiles.length) {
// The deleted file was in this history, update the cache
newCache.set(mainFileId, filteredHistory);
}
onFileRemove(index);
}, [filteredFiles, onFileRemove]);
}
return newCache;
});
// Call the parent's deletion logic
await onFileRemove(index);
// Refresh to ensure consistent state
await refreshRecentFiles();
}
}, [filteredFiles, onFileRemove, refreshRecentFiles]);
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
if (isFileSupported(file.name)) {
@ -252,57 +288,67 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
if (selectedFileIds.length === 0) return;
try {
// Get files to delete based on current filtered view
const filesToDelete = filteredFiles.filter(file =>
// Use the same logic as individual file deletion for consistency
// Delete each selected file individually using the same cache update logic
const allFilesToDelete = filteredFiles.filter(file =>
selectedFileIds.includes(file.id)
);
// For each selected file, determine which files to delete based on branch logic
const fileIdsToDelete = new Set<string>();
// Deduplicate by file ID since shared files can appear multiple times in the display
const uniqueFilesToDelete = allFilesToDelete.reduce((unique: typeof allFilesToDelete, file) => {
if (!unique.some(f => f.id === file.id)) {
unique.push(file);
}
return unique;
}, []);
for (const file of filesToDelete) {
// If this is a leaf file (main entry), delete its entire branch
const branchLineage = fileGroups.get(file.id) || [];
const filesToDelete = uniqueFilesToDelete;
const deletedFileIds = new Set(filesToDelete.map(f => f.id));
if (branchLineage.length > 0) {
// This is a leaf file with a lineage - check each file in the branch
for (const branchFile of branchLineage) {
// Check if this file is part of OTHER branches (shared between branches)
const isPartOfOtherBranches = Array.from(fileGroups.values()).some(otherLineage => {
// Check if this file appears in a different branch lineage
return otherLineage !== branchLineage &&
otherLineage.some((f: any) => f.id === branchFile.id);
// Update history cache synchronously
setLoadedHistoryFiles(prev => {
const newCache = new Map(prev);
for (const fileToDelete of filesToDelete) {
// If the deleted file was a main file with cached history, remove its cache
newCache.delete(fileToDelete.id);
// Also remove the deleted file from any other file's history cache
for (const [mainFileId, historyFiles] of newCache.entries()) {
const filteredHistory = historyFiles.filter(histFile => histFile.id !== fileToDelete.id);
if (filteredHistory.length !== historyFiles.length) {
// The deleted file was in this history, update the cache
newCache.set(mainFileId, filteredHistory);
}
}
}
return newCache;
});
if (isPartOfOtherBranches) {
// File is shared between branches - don't delete it
console.log(`Keeping shared file: ${branchFile.name} (part of other branches)`);
} else {
// File is exclusive to this branch - safe to delete
fileIdsToDelete.add(branchFile.id);
console.log(`Deleting branch-exclusive file: ${branchFile.name}`);
}
}
} else {
// This is a standalone file or history file - just delete it
fileIdsToDelete.add(file.id);
// Also clear any expanded state for deleted files to prevent ghost entries
setExpandedFileIds(prev => {
const newExpanded = new Set(prev);
for (const deletedId of deletedFileIds) {
newExpanded.delete(deletedId);
}
return newExpanded;
});
// Clear selection immediately to prevent ghost selections
setSelectedFileIds(prev => prev.filter(id => !deletedFileIds.has(id)));
// Delete files from IndexedDB
for (const file of filesToDelete) {
await fileStorage.deleteFile(file.id);
}
// Delete files from storage
for (const fileId of fileIdsToDelete) {
await fileStorage.deleteFile(fileId as FileId);
}
// Clear selection
setSelectedFileIds([]);
// Refresh the file list
// Refresh the file list to get updated data
await refreshRecentFiles();
} catch (error) {
console.error('Failed to delete selected files:', error);
}
}, [selectedFileIds, filteredFiles, fileGroups, recentFiles, refreshRecentFiles]);
}, [selectedFileIds, filteredFiles, refreshRecentFiles]);
const handleDownloadSelected = useCallback(async () => {
@ -331,7 +377,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, []);
const handleToggleExpansion = useCallback((fileId: string) => {
const handleToggleExpansion = useCallback(async (fileId: string) => {
const isCurrentlyExpanded = expandedFileIds.has(fileId);
// Update expansion state
setExpandedFileIds(prev => {
const newSet = new Set(prev);
if (newSet.has(fileId)) {
@ -341,7 +390,124 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
return newSet;
});
}, []);
// Load complete history chain if expanding
if (!isCurrentlyExpanded) {
const currentFileMetadata = recentFiles.find(f => f.id === fileId);
if (currentFileMetadata && (currentFileMetadata.versionNumber || 0) > 0) {
try {
// Load the current file to get its full history
const storedFile = await fileStorage.getFile(fileId as FileId);
if (storedFile) {
const file = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
// Get the complete history metadata (this will give us original/parent IDs)
const historyData = await loadFileHistory(file, fileId as FileId);
if (historyData?.originalFileId) {
// Load complete history chain by traversing parent relationships
const historyFiles: FileMetadata[] = [];
// Get all stored files for chain traversal
const allStoredMetadata = await fileStorage.getAllFileMetadata();
const fileMap = new Map(allStoredMetadata.map(f => [f.id, f]));
// Build complete chain by following parent relationships backwards
const visitedIds = new Set([fileId]); // Don't include the current file
const toProcess = [historyData]; // Start with current file's history data
while (toProcess.length > 0) {
const currentHistoryData = toProcess.shift()!;
// Add original file if we haven't seen it
if (currentHistoryData.originalFileId && !visitedIds.has(currentHistoryData.originalFileId)) {
visitedIds.add(currentHistoryData.originalFileId);
const originalMeta = fileMap.get(currentHistoryData.originalFileId as FileId);
if (originalMeta) {
try {
const origStoredFile = await fileStorage.getFile(originalMeta.id);
if (origStoredFile) {
const origFile = new File([origStoredFile.data], origStoredFile.name, {
type: origStoredFile.type,
lastModified: origStoredFile.lastModified
});
const origMetadata = await createFileMetadataWithHistory(origFile, originalMeta.id, originalMeta.thumbnail);
historyFiles.push(origMetadata);
}
} catch (error) {
console.warn(`Failed to load original file ${originalMeta.id}:`, error);
}
}
}
// Add parent file if we haven't seen it
if (currentHistoryData.parentFileId && !visitedIds.has(currentHistoryData.parentFileId)) {
visitedIds.add(currentHistoryData.parentFileId);
const parentMeta = fileMap.get(currentHistoryData.parentFileId);
if (parentMeta) {
try {
const parentStoredFile = await fileStorage.getFile(parentMeta.id);
if (parentStoredFile) {
const parentFile = new File([parentStoredFile.data], parentStoredFile.name, {
type: parentStoredFile.type,
lastModified: parentStoredFile.lastModified
});
const parentMetadata = await createFileMetadataWithHistory(parentFile, parentMeta.id, parentMeta.thumbnail);
historyFiles.push(parentMetadata);
// Load parent's history to continue the chain
const parentHistoryData = await loadFileHistory(parentFile, parentMeta.id);
if (parentHistoryData) {
toProcess.push(parentHistoryData);
}
}
} catch (error) {
console.warn(`Failed to load parent file ${parentMeta.id}:`, error);
}
}
}
}
// Also find any files that have the current file as their original (siblings/alternatives)
for (const [metaId, meta] of fileMap) {
if (!visitedIds.has(metaId) && (meta as any).originalFileId === historyData.originalFileId) {
visitedIds.add(metaId);
try {
const siblingStoredFile = await fileStorage.getFile(meta.id);
if (siblingStoredFile) {
const siblingFile = new File([siblingStoredFile.data], siblingStoredFile.name, {
type: siblingStoredFile.type,
lastModified: siblingStoredFile.lastModified
});
const siblingMetadata = await createFileMetadataWithHistory(siblingFile, meta.id, meta.thumbnail);
historyFiles.push(siblingMetadata);
}
} catch (error) {
console.warn(`Failed to load sibling file ${meta.id}:`, error);
}
}
}
// Cache the loaded history files
setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles)));
}
}
} catch (error) {
console.warn(`Failed to load history chain for file ${fileId}:`, error);
}
}
} else {
// Clear loaded history when collapsing
setLoadedHistoryFiles(prev => {
const newMap = new Map(prev);
newMap.delete(fileId as FileId);
return newMap;
});
}
}, [expandedFileIds, recentFiles, loadFileHistory]);
const handleAddToRecents = useCallback(async (file: FileMetadata) => {
try {
@ -399,6 +565,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
expandedFileIds,
fileGroups,
// History loading state
isLoadingHistory,
getHistoryError,
// Handlers
onSourceChange: handleSourceChange,
onLocalFileClick: handleLocalFileClick,
@ -429,6 +599,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileInputRef,
expandedFileIds,
fileGroups,
isLoadingHistory,
getHistoryError,
handleSourceChange,
handleLocalFileClick,
handleFileSelect,

View File

@ -6,7 +6,7 @@
import React, { createContext, useContext, useCallback, useRef } from 'react';
const DEBUG = process.env.NODE_ENV === 'development';
import { fileStorage, StoredFile } from '../services/fileStorage';
import { fileStorage } from '../services/fileStorage';
import { FileId } from '../types/file';
import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
@ -21,12 +21,14 @@ interface IndexedDBContextValue {
// Batch operations
loadAllMetadata: () => Promise<FileMetadata[]>;
loadLeafMetadata: () => Promise<FileMetadata[]>; // Only leaf files for recent files list
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
clearAll: () => Promise<void>;
// Utilities
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
markFileAsProcessed: (fileId: FileId) => Promise<boolean>;
}
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
@ -62,7 +64,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
// Store in IndexedDB
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
await fileStorage.storeFile(file, fileId, thumbnail);
// Cache the file object for immediate reuse
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
@ -139,6 +141,64 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
await fileStorage.deleteFile(fileId);
}, []);
const loadLeafMetadata = useCallback(async (): Promise<FileMetadata[]> => {
const metadata = await fileStorage.getLeafFileMetadata(); // Only get leaf files
// Separate PDF and non-PDF files for different processing
const pdfFiles = metadata.filter(m => m.type.includes('pdf'));
const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf'));
// Process non-PDF files immediately (no history extraction needed)
const nonPdfMetadata: FileMetadata[] = nonPdfFiles.map(m => ({
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail,
isLeaf: m.isLeaf
}));
// Process PDF files with controlled concurrency to avoid memory issues
const BATCH_SIZE = 5; // Process 5 PDFs at a time to avoid overwhelming memory
const pdfMetadata: FileMetadata[] = [];
for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) {
const batch = pdfFiles.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (m) => {
try {
// For PDF files, load and extract basic history for display only
const storedFile = await fileStorage.getFile(m.id);
if (storedFile?.data) {
const file = new File([storedFile.data], m.name, {
type: m.type,
lastModified: m.lastModified
});
return await createFileMetadataWithHistory(file, m.id, m.thumbnail);
}
} catch (error) {
if (DEBUG) console.warn('🗂️ Failed to extract basic metadata from leaf file:', m.name, error);
}
// Fallback to basic metadata without history
return {
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail,
isLeaf: m.isLeaf
};
}));
pdfMetadata.push(...batchResults);
}
return [...nonPdfMetadata, ...pdfMetadata];
}, []);
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
const metadata = await fileStorage.getAllFileMetadata();
@ -219,16 +279,22 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
return await fileStorage.updateThumbnail(fileId, thumbnail);
}, []);
const markFileAsProcessed = useCallback(async (fileId: FileId): Promise<boolean> => {
return await fileStorage.markFileAsProcessed(fileId);
}, []);
const value: IndexedDBContextValue = {
saveFile,
loadFile,
loadMetadata,
deleteFile,
loadAllMetadata,
loadLeafMetadata,
deleteMultiple,
clearAll,
getStorageStats,
updateThumbnail
updateThumbnail,
markFileAsProcessed
};
return (

View File

@ -103,7 +103,7 @@ const NavigationActionsContext = createContext<NavigationContextActionsValue | u
export const NavigationProvider: React.FC<{
children: React.ReactNode;
enableUrlSync?: boolean;
}> = ({ children, enableUrlSync = true }) => {
}> = ({ children }) => {
const [state, dispatch] = useReducer(navigationReducer, initialState);
const toolRegistry = useFlatToolRegistry();

View File

@ -89,6 +89,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
clearToolSelection: () => void;
// Tool Reset Actions
toolResetFunctions: Record<string, () => void>;
registerToolReset: (toolId: string, resetFunction: () => void) => void;
resetTool: (toolId: string) => void;
@ -258,6 +259,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
clearToolSelection: () => actions.setSelectedTool(null),
// Tool Reset Actions
toolResetFunctions,
registerToolReset,
resetTool,

View File

@ -6,7 +6,7 @@ import { FileId } from '../../types/file';
import {
FileContextState,
FileContextAction,
FileRecord
StirlingFileStub
} from '../../types/fileContext';
// Initial state
@ -29,7 +29,7 @@ export const initialFileContextState: FileContextState = {
function processFileSwap(
state: FileContextState,
filesToRemove: FileId[],
filesToAdd: FileRecord[]
filesToAdd: StirlingFileStub[]
): FileContextState {
// Only remove unpinned files
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
@ -70,11 +70,11 @@ function processFileSwap(
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
switch (action.type) {
case 'ADD_FILES': {
const { fileRecords } = action.payload;
const { stirlingFileStubs } = action.payload;
const newIds: FileId[] = [];
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
const newById: Record<FileId, StirlingFileStub> = { ...state.files.byId };
fileRecords.forEach(record => {
stirlingFileStubs.forEach(record => {
// Only add if not already present (dedupe by stable ID)
if (!newById[record.id]) {
newIds.push(record.id);
@ -235,13 +235,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
case 'CONSUME_FILES': {
const { inputFileIds, outputFileRecords } = action.payload;
return processFileSwap(state, inputFileIds, outputFileRecords);
const { inputFileIds, outputStirlingFileStubs } = action.payload;
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
}
case 'UNDO_CONSUME_FILES': {
const { inputFileRecords, outputFileIds } = action.payload;
return processFileSwap(state, outputFileIds, inputFileRecords);
const { inputStirlingFileStubs, outputFileIds } = action.payload;
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
}
case 'RESET_CONTEXT': {

View File

@ -3,19 +3,18 @@
*/
import {
FileRecord,
StirlingFileStub,
FileContextAction,
FileContextState,
toFileRecord,
toStirlingFileStub,
createFileId,
createQuickKey
} from '../../types/fileContext';
import { FileId, FileMetadata } from '../../types/file';
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { FileLifecycleManager } from './lifecycle';
import { fileProcessingService } from '../../services/fileProcessingService';
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
import { extractFileHistory } from '../../utils/fileHistoryUtils';
import { buildQuickKeySet } from './fileSelectors';
import { extractBasicFileMetadata } from '../../utils/fileHistoryUtils';
const DEBUG = process.env.NODE_ENV === 'development';
@ -110,7 +109,7 @@ export async function addFiles(
await addFilesMutex.lock();
try {
const fileRecords: FileRecord[] = [];
const stirlingFileStubs: StirlingFileStub[] = [];
const addedFiles: AddedFile[] = [];
// Build quickKey lookup from existing files for deduplication
@ -164,7 +163,7 @@ export async function addFiles(
}
// Create record with immediate thumbnail and page metadata
const record = toFileRecord(file, fileId);
const record = toStirlingFileStub(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
@ -184,29 +183,27 @@ export async function addFiles(
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
// Extract file history from PDF metadata (async)
extractFileHistory(file, record).then(updatedRecord => {
if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
// History was found, dispatch update to trigger re-render
// Extract basic metadata (version number and tool chain) for display
extractBasicFileMetadata(file, record).then(updatedRecord => {
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
// Basic metadata found, dispatch update to trigger re-render
dispatch({
type: 'UPDATE_FILE_RECORD',
payload: {
id: fileId,
updates: {
originalFileId: updatedRecord.originalFileId,
versionNumber: updatedRecord.versionNumber,
parentFileId: updatedRecord.parentFileId,
toolHistory: updatedRecord.toolHistory
}
}
});
}
}).catch(error => {
if (DEBUG) console.warn(`📄 Failed to extract history for ${file.name}:`, error);
if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error);
});
existingQuickKeys.add(quickKey);
fileRecords.push(record);
stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail });
}
break;
@ -227,7 +224,7 @@ export async function addFiles(
const fileId = createFileId();
filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId);
const record = toStirlingFileStub(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
@ -247,40 +244,27 @@ export async function addFiles(
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
// Extract file history from PDF metadata (async)
if (DEBUG) console.log(`📄 addFiles(processed): Starting async history extraction for ${file.name}`);
extractFileHistory(file, record).then(updatedRecord => {
if (DEBUG) console.log(`📄 addFiles(processed): History extraction completed for ${file.name}:`, {
hasChanges: updatedRecord !== record,
originalFileId: updatedRecord.originalFileId,
versionNumber: updatedRecord.versionNumber,
toolHistoryLength: updatedRecord.toolHistory?.length || 0
});
if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
// History was found, dispatch update to trigger re-render
if (DEBUG) console.log(`📄 addFiles(processed): Dispatching UPDATE_FILE_RECORD for ${file.name}`);
// Extract basic metadata (version number and tool chain) for display
extractBasicFileMetadata(file, record).then(updatedRecord => {
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
// Basic metadata found, dispatch update to trigger re-render
dispatch({
type: 'UPDATE_FILE_RECORD',
payload: {
id: fileId,
updates: {
originalFileId: updatedRecord.originalFileId,
versionNumber: updatedRecord.versionNumber,
parentFileId: updatedRecord.parentFileId,
toolHistory: updatedRecord.toolHistory
}
}
});
} else {
if (DEBUG) console.log(`📄 addFiles(processed): No history found for ${file.name}, skipping update`);
}
}).catch(error => {
if (DEBUG) console.error(`📄 addFiles(processed): Failed to extract history for ${file.name}:`, error);
if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error);
});
existingQuickKeys.add(quickKey);
fileRecords.push(record);
stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail });
}
break;
@ -308,7 +292,7 @@ export async function addFiles(
filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId);
const record = toStirlingFileStub(file, fileId);
// Generate processedFile metadata for stored files
let pageCount: number = 1;
@ -354,29 +338,27 @@ export async function addFiles(
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
}
// Extract file history from PDF metadata (async) - same as raw files
extractFileHistory(file, record).then(updatedRecord => {
if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
// History was found, dispatch update to trigger re-render
// Extract basic metadata (version number and tool chain) for display
extractBasicFileMetadata(file, record).then(updatedRecord => {
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
// Basic metadata found, dispatch update to trigger re-render
dispatch({
type: 'UPDATE_FILE_RECORD',
payload: {
id: fileId,
updates: {
originalFileId: updatedRecord.originalFileId,
versionNumber: updatedRecord.versionNumber,
parentFileId: updatedRecord.parentFileId,
toolHistory: updatedRecord.toolHistory
}
}
});
}
}).catch(error => {
if (DEBUG) console.warn(`📄 Failed to extract history for ${file.name}:`, error);
if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error);
});
existingQuickKeys.add(quickKey);
fileRecords.push(record);
stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
}
@ -385,9 +367,9 @@ export async function addFiles(
}
// Dispatch ADD_FILES action if we have new files
if (fileRecords.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
if (stirlingFileStubs.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
}
return addedFiles;
@ -403,7 +385,7 @@ export async function addFiles(
async function processFilesIntoRecords(
files: File[],
filesRef: React.MutableRefObject<Map<FileId, File>>
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> {
): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
return Promise.all(
files.map(async (file) => {
const fileId = createFileId();
@ -422,7 +404,7 @@ async function processFilesIntoRecords(
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
}
const record = toFileRecord(file, fileId);
const record = toStirlingFileStub(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
@ -431,22 +413,20 @@ async function processFilesIntoRecords(
record.processedFile = createProcessedFile(pageCount, thumbnail);
}
// Extract file history from PDF metadata (synchronous during consumeFiles)
// Extract basic metadata synchronously during consumeFiles for immediate display
if (file.type.includes('pdf')) {
try {
const updatedRecord = await extractFileHistory(file, record);
const updatedRecord = await extractBasicFileMetadata(file, record);
if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
// Update the record directly with history data
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
// Update the record directly with basic metadata
Object.assign(record, {
originalFileId: updatedRecord.originalFileId,
versionNumber: updatedRecord.versionNumber,
parentFileId: updatedRecord.parentFileId,
toolHistory: updatedRecord.toolHistory
});
}
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to extract history for ${file.name}:`, error);
if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error);
}
}
@ -459,10 +439,10 @@ async function processFilesIntoRecords(
* Helper function to persist files to IndexedDB
*/
async function persistFilesToIndexedDB(
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
): Promise<void> {
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => {
await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
try {
await indexedDB.saveFile(file, fileId, thumbnail);
} catch (error) {
@ -477,19 +457,31 @@ async function persistFilesToIndexedDB(
export async function consumeFiles(
inputFileIds: FileId[],
outputFiles: File[],
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>,
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; markFileAsProcessed: (fileId: FileId) => Promise<boolean> } | null
): Promise<FileId[]> {
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
// Process output files with thumbnails and metadata
const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef);
const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
// Persist output files to IndexedDB if available
// Mark input files as processed in IndexedDB (no longer leaf nodes) and save output files
if (indexedDB) {
await persistFilesToIndexedDB(outputFileRecords, indexedDB);
// Mark input files as processed (isLeaf = false)
await Promise.all(
inputFileIds.map(async (fileId) => {
try {
await indexedDB.markFileAsProcessed(fileId);
if (DEBUG) console.log(`📄 Marked file ${fileId} as processed (no longer leaf)`);
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to mark file ${fileId} as processed:`, error);
}
})
);
// Save output files to IndexedDB
await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
}
// Dispatch the consume action
@ -497,21 +489,20 @@ export async function consumeFiles(
type: 'CONSUME_FILES',
payload: {
inputFileIds,
outputFileRecords: outputFileRecords.map(({ record }) => record)
outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
}
});
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
// Return the output file IDs for undo tracking
return outputFileRecords.map(({ fileId }) => fileId);
return outputStirlingFileStubs.map(({ fileId }) => fileId);
}
/**
* Helper function to restore files to filesRef and manage IndexedDB cleanup
*/
async function restoreFilesAndCleanup(
filesToRestore: Array<{ file: File; record: FileRecord }>,
filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
fileIdsToRemove: FileId[],
filesRef: React.MutableRefObject<Map<FileId, File>>,
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
@ -560,18 +551,17 @@ async function restoreFilesAndCleanup(
*/
export async function undoConsumeFiles(
inputFiles: File[],
inputFileRecords: FileRecord[],
inputStirlingFileStubs: StirlingFileStub[],
outputFileIds: FileId[],
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>,
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
): Promise<void> {
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`);
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputStirlingFileStubs.length} input files, removing ${outputFileIds.length} output files`);
// Validate inputs
if (inputFiles.length !== inputFileRecords.length) {
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`);
if (inputFiles.length !== inputStirlingFileStubs.length) {
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`);
}
// Create a backup of current filesRef state for rollback
@ -581,7 +571,7 @@ export async function undoConsumeFiles(
// Prepare files to restore
const filesToRestore = inputFiles.map((file, index) => ({
file,
record: inputFileRecords[index]
record: inputStirlingFileStubs[index]
}));
// Restore input files and clean up output files
@ -596,13 +586,12 @@ export async function undoConsumeFiles(
dispatch({
type: 'UNDO_CONSUME_FILES',
payload: {
inputFileRecords,
inputStirlingFileStubs,
outputFileIds
}
});
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`);
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`);
} catch (error) {
// Rollback filesRef to previous state
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);

View File

@ -9,7 +9,7 @@ import {
FileContextStateValue,
FileContextActionsValue
} from './contexts';
import { FileRecord } from '../../types/fileContext';
import { StirlingFileStub, StirlingFile } from '../../types/fileContext';
import { FileId } from '../../types/file';
/**
@ -38,13 +38,13 @@ export function useFileActions(): FileContextActionsValue {
/**
* Hook for current/primary file (first in list)
*/
export function useCurrentFile(): { file?: File; record?: FileRecord } {
export function useCurrentFile(): { file?: File; record?: StirlingFileStub } {
const { state, selectors } = useFileState();
const primaryFileId = state.files.ids[0];
return useMemo(() => ({
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
}), [primaryFileId, selectors]);
}
@ -87,7 +87,7 @@ export function useFileManagement() {
addFiles: actions.addFiles,
removeFiles: actions.removeFiles,
clearAllFiles: actions.clearAllFiles,
updateFileRecord: actions.updateFileRecord,
updateStirlingFileStub: actions.updateStirlingFileStub,
reorderFiles: actions.reorderFiles
}), [actions]);
}
@ -111,24 +111,24 @@ export function useFileUI() {
/**
* Hook for specific file by ID (optimized for individual file access)
*/
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub } {
const { selectors } = useFileState();
return useMemo(() => ({
file: selectors.getFile(fileId),
record: selectors.getFileRecord(fileId)
record: selectors.getStirlingFileStub(fileId)
}), [fileId, selectors]);
}
/**
* Hook for all files (use sparingly - causes re-renders on file list changes)
*/
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getFiles(),
records: selectors.getFileRecords(),
records: selectors.getStirlingFileStubs(),
fileIds: state.files.ids
}), [state.files.ids, selectors]);
}
@ -136,12 +136,12 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
/**
* Hook for selected files (optimized for selection-based UI)
*/
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getSelectedFiles(),
records: selectors.getSelectedFileRecords(),
records: selectors.getSelectedStirlingFileStubs(),
fileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}
@ -166,9 +166,9 @@ export function useFileContext() {
addFiles: actions.addFiles,
consumeFiles: actions.consumeFiles,
undoConsumeFiles: actions.undoConsumeFiles,
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
// File ID lookup
findFileId: (file: File) => {

View File

@ -4,9 +4,11 @@
import { FileId } from '../../types/file';
import {
FileRecord,
StirlingFileStub,
FileContextState,
FileContextSelectors
FileContextSelectors,
StirlingFile,
createStirlingFile
} from '../../types/fileContext';
/**
@ -17,16 +19,24 @@ export function createFileSelectors(
filesRef: React.MutableRefObject<Map<FileId, File>>
): FileContextSelectors {
return {
getFile: (id: FileId) => filesRef.current.get(id),
getFile: (id: FileId) => {
const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
},
getFiles: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
return currentIds
.map(id => {
const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
},
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
getStirlingFileStub: (id: FileId) => stateRef.current.files.byId[id],
getFileRecords: (ids?: FileId[]) => {
getStirlingFileStubs: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
},
@ -35,11 +45,14 @@ export function createFileSelectors(
getSelectedFiles: () => {
return stateRef.current.ui.selectedFileIds
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
.map(id => {
const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
},
getSelectedFileRecords: () => {
getSelectedStirlingFileStubs: () => {
return stateRef.current.ui.selectedFileIds
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
@ -52,26 +65,21 @@ export function createFileSelectors(
getPinnedFiles: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
.map(id => {
const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
},
getPinnedFileRecords: () => {
getPinnedStirlingFileStubs: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
},
isFilePinned: (file: File) => {
// Find FileId by matching File object properties
const fileId = (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
const storedFile = filesRef.current.get(id);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
isFilePinned: (file: StirlingFile) => {
return stateRef.current.pinnedFiles.has(file.fileId);
},
// Stable signature for effects - prevents unnecessary re-renders
@ -90,9 +98,9 @@ export function createFileSelectors(
/**
* Helper for building quickKey sets for deduplication
*/
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
export function buildQuickKeySet(stirlingFileStubs: Record<FileId, StirlingFileStub>): Set<string> {
const quickKeys = new Set<string>();
Object.values(fileRecords).forEach(record => {
Object.values(stirlingFileStubs).forEach(record => {
if (record.quickKey) {
quickKeys.add(record.quickKey);
}
@ -119,7 +127,7 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
export function getPrimaryFile(
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>
): { file?: File; record?: FileRecord } {
): { file?: File; record?: StirlingFileStub } {
const primaryFileId = stateRef.current.files.ids[0];
if (!primaryFileId) return {};

View File

@ -3,7 +3,7 @@
*/
import { FileId } from '../../types/file';
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
import { FileContextAction, StirlingFileStub, ProcessedFilePage } from '../../types/fileContext';
const DEBUG = process.env.NODE_ENV === 'development';
@ -50,7 +50,7 @@ export class FileLifecycleManager {
this.blobUrls.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
} catch {
// Ignore revocation errors
}
});
@ -134,7 +134,7 @@ export class FileLifecycleManager {
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.thumbnailUrl);
} catch (error) {
} catch {
// Ignore revocation errors
}
}
@ -142,18 +142,18 @@ export class FileLifecycleManager {
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.blobUrl);
} catch (error) {
} catch {
// Ignore revocation errors
}
}
// Clean up processed file thumbnails
if (record.processedFile?.pages) {
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
record.processedFile.pages.forEach((page: ProcessedFilePage) => {
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
try {
URL.revokeObjectURL(page.thumbnail);
} catch (error) {
} catch {
// Ignore revocation errors
}
}
@ -166,7 +166,7 @@ export class FileLifecycleManager {
/**
* Update file record with race condition guards
*/
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
updateStirlingFileStub = (fileId: FileId, updates: Partial<StirlingFileStub>, stateRef?: React.MutableRefObject<any>): void => {
// Guard against updating removed files (race condition protection)
if (!this.filesRef.current.has(fileId)) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);

View File

@ -15,6 +15,7 @@ import Repair from "../tools/Repair";
import SingleLargePage from "../tools/SingleLargePage";
import UnlockPdfForms from "../tools/UnlockPdfForms";
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
import Flatten from "../tools/Flatten";
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
@ -28,6 +29,7 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
import CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
@ -39,6 +41,7 @@ import AddWatermarkSingleStepSettings from "../components/tools/addWatermark/Add
import OCRSettings from "../components/tools/ocr/OCRSettings";
import ConvertSettings from "../components/tools/convert/ConvertSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import { ToolId } from "../types/toolId";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -198,10 +201,14 @@ export function useFlatToolRegistry(): ToolRegistry {
flatten: {
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.flatten.title", "Flatten"),
component: null,
component: Flatten,
description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["flatten"],
operationConfig: flattenOperationConfig,
settingsComponent: FlattenSettings,
},
"unlock-pdf-forms": {
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
@ -355,6 +362,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
urlPath: '/pdf-to-single-page',
endpoints: ["pdf-to-single-page"],
operationConfig: singleLargePageOperationConfig,
},
@ -681,6 +689,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
urlPath: '/ocr-pdf',
operationConfig: ocrOperationConfig,
settingsComponent: OCRSettings,
},

View File

@ -1,7 +1,7 @@
import { describe, expect, test, vi, beforeEach, MockedFunction } from 'vitest';
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAddPasswordOperation } from './useAddPasswordOperation';
import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters';
import type { AddPasswordFullParameters } from './useAddPasswordParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {

View File

@ -46,7 +46,7 @@ export function useSavedAutomations() {
const { automationStorage } = await import('../../../services/automationStorage');
// Map suggested automation icons to MUI icon keys
const getIconKey = (suggestedIcon: {id: string}): string => {
const getIconKey = (_suggestedIcon: {id: string}): string => {
// Check the automation ID or name to determine the appropriate icon
switch (suggestedAutomation.id) {
case 'secure-pdf-ingestion':

View File

@ -6,7 +6,6 @@ import { SuggestedAutomation } from '../../../types/automation';
// Create icon components
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
const TextFieldsIcon = () => React.createElement(LocalIcon, { icon: 'text-fields', width: '1.5rem', height: '1.5rem' });
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { CompressParameters, defaultParameters } from './useCompressParameters';

View File

@ -2,9 +2,8 @@ import { useCallback } from 'react';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import { ConvertParameters, defaultParameters } from './useConvertParameters';
import { detectFileExtension } from '../../../utils/fileUtils';
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
// Static function that can be used by both the hook and automation executor

View File

@ -2,7 +2,6 @@ import {
COLOR_TYPES,
OUTPUT_OPTIONS,
FIT_OPTIONS,
TO_FORMAT_OPTIONS,
CONVERSION_MATRIX,
type ColorType,
type OutputOption,

View File

@ -4,7 +4,7 @@
*/
import { describe, test, expect } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from './useConvertParameters';
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
@ -347,9 +347,9 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
const malformedFiles: Array<{name: string}> = [
{ name: 'valid.pdf' },
// @ts-ignore - Testing runtime resilience
// @ts-expect-error - Testing runtime resilience
{ name: null },
// @ts-ignore
// @ts-expect-error - Testing runtime resilience
{ name: undefined }
];

View File

@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { FlattenParameters, defaultParameters } from './useFlattenParameters';
// Static function that can be used by both the hook and automation executor
export const buildFlattenFormData = (parameters: FlattenParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('flattenOnlyForms', parameters.flattenOnlyForms.toString());
return formData;
};
// Static configuration object
export const flattenOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildFlattenFormData,
operationType: 'flatten',
endpoint: '/api/v1/misc/flatten',
filePrefix: 'flattened_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useFlattenOperation = () => {
const { t } = useTranslation();
return useToolOperation<FlattenParameters>({
...flattenOperationConfig,
filePrefix: t('flatten.filenamePrefix', 'flattened') + '_',
getErrorMessage: createStandardErrorHandler(t('flatten.error.failed', 'An error occurred while flattening the PDF.'))
});
};

View File

@ -0,0 +1,19 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface FlattenParameters extends BaseParameters {
flattenOnlyForms: boolean;
}
export const defaultParameters: FlattenParameters = {
flattenOnlyForms: false,
};
export type FlattenParametersHook = BaseParametersHook<FlattenParameters>;
export const useFlattenParameters = (): FlattenParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'flatten',
});
};

View File

@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters';
// Static function that can be used by both the hook and automation executor
export const buildRemoveCertificateSignFormData = (parameters: RemoveCertificateSignParameters, file: File): FormData => {
export const buildRemoveCertificateSignFormData = (_parameters: RemoveCertificateSignParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;

View File

@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RepairParameters, defaultParameters } from './useRepairParameters';
// Static function that can be used by both the hook and automation executor
export const buildRepairFormData = (parameters: RepairParameters, file: File): FormData => {
export const buildRepairFormData = (_parameters: RepairParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;

View File

@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
import { BaseToolProps } from '../../../types/tool';
import { ToolOperationHook } from './useToolOperation';
import { BaseParametersHook } from './useBaseParameters';
import { StirlingFile } from '../../../types/fileContext';
interface BaseToolReturn<TParams> {
// File management
selectedFiles: File[];
selectedFiles: StirlingFile[];
// Tool-specific hooks
params: BaseParametersHook<TParams>;

View File

@ -6,11 +6,9 @@ import { useToolState, type ProcessingProgress } from './useToolState';
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { createOperation } from '../../../utils/toolOperationTracker';
import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { FileId } from '../../../types/file';
import { FileRecord } from '../../../types/fileContext';
import { prepareFilesWithHistory, verifyToolMetadataPreservation } from '../../../utils/fileHistoryUtils';
import { prepareStirlingFilesWithHistory, verifyToolMetadataPreservation } from '../../../utils/fileHistoryUtils';
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
@ -105,7 +103,7 @@ export interface ToolOperationHook<TParams = void> {
progress: ProcessingProgress | null;
// Actions
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>;
executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
resetResults: () => void;
clearError: () => void;
cancelOperation: () => void;
@ -131,7 +129,7 @@ export const useToolOperation = <TParams>(
config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => {
const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
const { addFiles, consumeFiles, undoConsumeFiles, selectors, findFileId } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
@ -141,13 +139,13 @@ export const useToolOperation = <TParams>(
// Track last operation for undo functionality
const lastOperationRef = useRef<{
inputFiles: File[];
inputFileRecords: FileRecord[];
inputStirlingFileStubs: StirlingFileStub[];
outputFileIds: FileId[];
} | null>(null);
const executeOperation = useCallback(async (
params: TParams,
selectedFiles: File[]
selectedFiles: StirlingFile[]
): Promise<void> => {
// Validation
if (selectedFiles.length === 0) {
@ -161,10 +159,6 @@ export const useToolOperation = <TParams>(
return;
}
// Setup operation tracking
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
recordOperation(fileId, operation);
// Reset state
actions.setLoading(true);
actions.setError(null);
@ -173,14 +167,13 @@ export const useToolOperation = <TParams>(
// Prepare files with history metadata injection (for PDFs)
actions.setStatus('Preparing files...');
const getFileRecord = (file: File) => {
const fileId = findFileId(file);
return fileId ? selectors.getFileRecord(fileId) : undefined;
const getFileStubById = (fileId: FileId) => {
return selectors.getStirlingFileStub(fileId);
};
const filesWithHistory = await prepareFilesWithHistory(
const filesWithHistory = await prepareStirlingFilesWithHistory(
validFiles,
getFileRecord,
getFileStubById,
config.operationType,
params as Record<string, any>
);
@ -188,8 +181,12 @@ export const useToolOperation = <TParams>(
try {
let processedFiles: File[];
// Convert StirlingFiles with history to regular Files for API processing
// The history is already injected into the File data, we just need to extract the File objects
const filesForAPI = extractFiles(filesWithHistory);
switch (config.toolType) {
case ToolType.singleFile:
case ToolType.singleFile: {
// Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = {
endpoint: config.endpoint,
@ -199,17 +196,17 @@ export const useToolOperation = <TParams>(
};
processedFiles = await processFiles(
params,
filesWithHistory,
filesForAPI,
apiCallsConfig,
actions.setProgress,
actions.setStatus
);
break;
case ToolType.multiFile:
}
case ToolType.multiFile: {
// Multi-file processing - single API call with all files
actions.setStatus('Processing files...');
const formData = config.buildFormData(params, filesWithHistory);
const formData = config.buildFormData(params, filesForAPI);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
@ -217,11 +214,11 @@ export const useToolOperation = <TParams>(
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, filesWithHistory);
processedFiles = await config.responseHandler(response.data, filesForAPI);
} else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use original filename
const originalFileName = filesWithHistory[0]?.name || 'document.pdf';
const originalFileName = filesForAPI[0]?.name || 'document.pdf';
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
processedFiles = [singleFile];
} else {
@ -234,10 +231,11 @@ export const useToolOperation = <TParams>(
}
}
break;
}
case ToolType.custom:
actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, filesWithHistory);
processedFiles = await config.customProcessor(params, filesForAPI);
break;
}
@ -260,21 +258,17 @@ export const useToolOperation = <TParams>(
// Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds: FileId[] = [];
const inputFileRecords: FileRecord[] = [];
const inputStirlingFileStubs: StirlingFileStub[] = [];
// Build parallel arrays of IDs and records for undo tracking
for (const file of validFiles) {
const fileId = findFileId(file);
if (fileId) {
const record = selectors.getFileRecord(fileId);
const fileId = file.fileId;
const record = selectors.getStirlingFileStub(fileId);
if (record) {
inputFileIds.push(fileId);
inputFileRecords.push(record);
inputStirlingFileStubs.push(record);
} else {
console.warn(`No file record found for file: ${file.name}`);
}
} else {
console.warn(`No file ID found for file: ${file.name}`);
console.warn(`No file stub found for file: ${file.name}`);
}
}
@ -282,24 +276,22 @@ export const useToolOperation = <TParams>(
// Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = {
inputFiles: validFiles, // Keep original File objects for undo
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), // Deep copy to avoid reference issues
outputFileIds
};
markOperationApplied(fileId, operationId);
}
} catch (error: any) {
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
actions.setError(errorMessage);
actions.setStatus('');
markOperationFailed(fileId, operationId, errorMessage);
} finally {
actions.setLoading(false);
actions.setProgress(null);
}
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
const cancelOperation = useCallback(() => {
cancelApiCalls();
@ -328,10 +320,10 @@ export const useToolOperation = <TParams>(
return;
}
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
const { inputFiles, inputStirlingFileStubs, outputFileIds } = lastOperationRef.current;
// Validate that we have data to undo
if (inputFiles.length === 0 || inputFileRecords.length === 0) {
if (inputFiles.length === 0 || inputStirlingFileStubs.length === 0) {
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
return;
}
@ -343,7 +335,8 @@ export const useToolOperation = <TParams>(
try {
// Undo the consume operation
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
// Clear results and operation tracking
resetResults();

View File

@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters';
// Static function that can be used by both the hook and automation executor
export const buildSingleLargePageFormData = (parameters: SingleLargePageParameters, file: File): FormData => {
export const buildSingleLargePageFormData = (_parameters: SingleLargePageParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;

View File

@ -70,7 +70,7 @@ export const useSplitOperation = () => {
// Custom response handler that extracts ZIP files
// Can't add to exported config because it requires access to the zip code so must be part of the hook
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
const responseHandler = useCallback(async (blob: Blob, _originalFiles: File[]): Promise<File[]> => {
// Split operations return ZIP files with multiple PDF pages
return await extractZipFiles(blob);
}, [extractZipFiles]);

View File

@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters';
// Static function that can be used by both the hook and automation executor
export const buildUnlockPdfFormsFormData = (parameters: UnlockPdfFormsParameters, file: File): FormData => {
export const buildUnlockPdfFormsFormData = (_parameters: UnlockPdfFormsParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;

View File

@ -184,11 +184,6 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
// Force show after initialization
setTimeout(() => {
window.CookieConsent.show();
// Debug: Check if modal elements exist
const ccMain = document.getElementById('cc-main');
const consentModal = document.querySelector('.cm-wrapper');
}, 200);
} catch (error) {

View File

@ -105,7 +105,6 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
};
useEffect(() => {
const endpointsKey = endpoints.join(',');
fetchAllEndpointStatuses();
}, [endpoints.join(',')]); // Re-run when endpoints array changes

View File

@ -135,7 +135,7 @@ export function useEnhancedProcessedFiles(
updatedFiles.set(file, processed);
hasNewFiles = true;
}
} catch (error) {
} catch {
// Ignore errors in completion check
}
}

View File

@ -0,0 +1,160 @@
/**
* Custom hook for on-demand file history loading
* Replaces automatic history extraction during file loading
*/
import { useState, useCallback } from 'react';
import { FileId } from '../types/file';
import { StirlingFileStub } from '../types/fileContext';
import { loadFileHistoryOnDemand } from '../utils/fileHistoryUtils';
interface FileHistoryState {
originalFileId?: string;
versionNumber?: number;
parentFileId?: FileId;
toolHistory?: Array<{
toolName: string;
timestamp: number;
parameters?: Record<string, any>;
}>;
}
interface UseFileHistoryResult {
historyData: FileHistoryState | null;
isLoading: boolean;
error: string | null;
loadHistory: (file: File, fileId: FileId, updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void) => Promise<void>;
clearHistory: () => void;
}
export function useFileHistory(): UseFileHistoryResult {
const [historyData, setHistoryData] = useState<FileHistoryState | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadHistory = useCallback(async (
file: File,
fileId: FileId,
updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
) => {
setIsLoading(true);
setError(null);
try {
const history = await loadFileHistoryOnDemand(file, fileId, updateFileStub);
setHistoryData(history);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load file history';
setError(errorMessage);
setHistoryData(null);
} finally {
setIsLoading(false);
}
}, []);
const clearHistory = useCallback(() => {
setHistoryData(null);
setError(null);
setIsLoading(false);
}, []);
return {
historyData,
isLoading,
error,
loadHistory,
clearHistory
};
}
/**
* Hook for managing history state of multiple files
*/
export function useMultiFileHistory() {
const [historyCache, setHistoryCache] = useState<Map<FileId, FileHistoryState>>(new Map());
const [loadingFiles, setLoadingFiles] = useState<Set<FileId>>(new Set());
const [errors, setErrors] = useState<Map<FileId, string>>(new Map());
const loadFileHistory = useCallback(async (
file: File,
fileId: FileId,
updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
) => {
// Don't reload if already loaded or currently loading
if (historyCache.has(fileId) || loadingFiles.has(fileId)) {
return historyCache.get(fileId) || null;
}
setLoadingFiles(prev => new Set(prev).add(fileId));
setErrors(prev => {
const newErrors = new Map(prev);
newErrors.delete(fileId);
return newErrors;
});
try {
const history = await loadFileHistoryOnDemand(file, fileId, updateFileStub);
if (history) {
setHistoryCache(prev => new Map(prev).set(fileId, history));
}
return history;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load file history';
setErrors(prev => new Map(prev).set(fileId, errorMessage));
return null;
} finally {
setLoadingFiles(prev => {
const newSet = new Set(prev);
newSet.delete(fileId);
return newSet;
});
}
}, [historyCache, loadingFiles]);
const getHistory = useCallback((fileId: FileId) => {
return historyCache.get(fileId) || null;
}, [historyCache]);
const isLoadingHistory = useCallback((fileId: FileId) => {
return loadingFiles.has(fileId);
}, [loadingFiles]);
const getError = useCallback((fileId: FileId) => {
return errors.get(fileId) || null;
}, [errors]);
const clearHistory = useCallback((fileId: FileId) => {
setHistoryCache(prev => {
const newCache = new Map(prev);
newCache.delete(fileId);
return newCache;
});
setErrors(prev => {
const newErrors = new Map(prev);
newErrors.delete(fileId);
return newErrors;
});
setLoadingFiles(prev => {
const newSet = new Set(prev);
newSet.delete(fileId);
return newSet;
});
}, []);
const clearAllHistory = useCallback(() => {
setHistoryCache(new Map());
setLoadingFiles(new Set());
setErrors(new Map());
}, []);
return {
loadFileHistory,
getHistory,
isLoadingHistory,
getError,
clearHistory,
clearAllHistory
};
}

View File

@ -1,8 +1,7 @@
import { useState, useCallback } from 'react';
import { useIndexedDB } from '../contexts/IndexedDBContext';
import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
import { FileId } from '../types/file';
import { FileId } from '../types/fileContext';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
@ -30,8 +29,8 @@ export const useFileManager = () => {
return [];
}
// Load regular files metadata only
const storedFileMetadata = await indexedDB.loadAllMetadata();
// Load only leaf files metadata (processed files that haven't been used as input for other tools)
const storedFileMetadata = await indexedDB.loadLeafMetadata();
// For now, only regular files - drafts will be handled separately in the future
const allFiles = storedFileMetadata;

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