From 295e682e03953929d60e085ee690ceb870087af0 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 4 Sep 2025 14:08:28 +0100 Subject: [PATCH 01/13] 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) --- .editorconfig | 2 +- .github/workflows/build.yml | 2 + .vscode/extensions.json | 1 + frontend/eslint.config.mjs | 32 + frontend/package-lock.json | 934 +++++++++++++++++++++- frontend/package.json | 6 +- frontend/src/services/pdfExportService.ts | 4 +- 7 files changed, 949 insertions(+), 32 deletions(-) create mode 100644 frontend/eslint.config.mjs diff --git a/.editorconfig b/.editorconfig index 5b76408cc..5e5445769 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e38b82fb..b38abe5dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 6ab09796f..4f627148e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -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 ] } diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 000000000..7922013cb --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,32 @@ +// @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-empty": "off", // Temporarily disabled until codebase conformant + "no-empty-pattern": "off", // Temporarily disabled until codebase conformant + "no-undef": "off", // Temporarily disabled until codebase conformant + "no-useless-escape": "off", // Temporarily disabled until codebase conformant + "no-case-declarations": "off", // Temporarily disabled until codebase conformant + "prefer-const": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/ban-ts-comment": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-empty-object-type": "off", // Temporarily disabled until codebase conformant + "@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-expressions": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-unused-vars": "off", // Temporarily disabled until codebase conformant + }, + } +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e6d23d50..342f0512f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "web-vitals": "^2.1.4" }, "devDependencies": { + "@eslint/js": "^9.34.0", "@iconify-json/material-symbols": "^1.2.33", "@iconify/utils": "^3.0.1", "@playwright/test": "^1.40.0", @@ -49,6 +50,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", @@ -56,7 +58,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" } @@ -1164,6 +1167,173 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", @@ -1217,6 +1387,72 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@iconify-json/material-symbols": { "version": "1.2.33", "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.33.tgz", @@ -2659,6 +2895,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", @@ -2709,15 +2952,80 @@ "@types/react": "*" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", - "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.42.0.tgz", + "integrity": "sha512-Aq2dPqsQkxHOLfb2OPv43RnIvfj05nw8v/6n3B2NABIPpHnjQnaLo9QGMTvml+tv4korl/Cjfrb/BYhoL8UUTQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.40.0", - "@typescript-eslint/types": "^8.40.0", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/type-utils": "8.42.0", + "@typescript-eslint/utils": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.42.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.42.0.tgz", + "integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz", + "integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.42.0", + "@typescript-eslint/types": "^8.42.0", "debug": "^4.3.4" }, "engines": { @@ -2731,10 +3039,28 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz", + "integrity": "sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", - "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz", + "integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==", "dev": true, "license": "MIT", "engines": { @@ -2748,10 +3074,35 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.42.0.tgz", + "integrity": "sha512-9KChw92sbPTYVFw3JLRH1ockhyR3zqqn9lQXol3/YbI6jVxzWoGcT3AsAW0mu1MY0gYtsXnUGV/AKpkAj5tVlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/utils": "8.42.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", - "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz", + "integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==", "dev": true, "license": "MIT", "engines": { @@ -2763,16 +3114,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", - "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz", + "integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.40.0", - "@typescript-eslint/tsconfig-utils": "8.40.0", - "@typescript-eslint/types": "8.40.0", - "@typescript-eslint/visitor-keys": "8.40.0", + "@typescript-eslint/project-service": "8.42.0", + "@typescript-eslint/tsconfig-utils": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2817,14 +3168,38 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", - "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "node_modules/@typescript-eslint/utils": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.42.0.tgz", + "integrity": "sha512-JnIzu7H3RH5BrKC4NoZqRfmjqCIS1u3hGZltDYJgkVdqAezl4L9d1ZLw+36huCujtSBSAirGINF/S4UxOcR+/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.40.0", + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz", + "integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -3136,6 +3511,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", @@ -3162,6 +3547,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3236,6 +3638,13 @@ "node": ">=10" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -4028,6 +4437,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -4492,6 +4908,84 @@ "node": ">=0.10.0" } }, + "node_modules/eslint": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.34.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -4505,6 +4999,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -4519,6 +5031,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -4592,6 +5130,13 @@ "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", "dev": true }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4622,6 +5167,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4638,6 +5197,19 @@ "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-selector": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", @@ -4705,6 +5277,44 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -4971,6 +5581,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -5014,6 +5637,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5246,6 +5876,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -5267,6 +5907,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -5558,6 +6208,19 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "23.2.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", @@ -5635,12 +6298,33 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5705,12 +6389,36 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", "dev": true }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/license-checker": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/license-checker/-/license-checker-25.0.1.tgz", @@ -6105,12 +6813,35 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -6535,6 +7266,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6736,6 +7474,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -6805,6 +7561,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-manager-detector": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", @@ -6869,6 +7670,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7493,6 +8304,16 @@ "node": ">=18" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -9022,6 +9843,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", @@ -9045,9 +9879,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -9058,6 +9892,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.42.0.tgz", + "integrity": "sha512-ozR/rQn+aQXQxh1YgbCzQWDFrsi9mcg+1PM3l/z5o1+20P7suOIaNg515bpr/OYt6FObz/NHcBstydDLHWeEKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.42.0", + "@typescript-eslint/parser": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/utils": "8.42.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/ufo": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", @@ -9111,6 +9969,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -10541,6 +11409,16 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 160c96c18..d73e9ad97 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index fe3e314e0..c0d6929fd 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -130,7 +130,7 @@ export class PDFExportService { newDoc.setModificationDate(new Date()); const pdfBytes = await newDoc.save(); - return new Blob([pdfBytes], { type: 'application/pdf' }); + return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }); } /** @@ -176,7 +176,7 @@ export class PDFExportService { newDoc.setModificationDate(new Date()); const pdfBytes = await newDoc.save(); - return new Blob([pdfBytes], { type: 'application/pdf' }); + return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }); } From 6d3b08d9b6674a8c6ccab2e84474e560f4a0a159 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 4 Sep 2025 15:59:31 +0100 Subject: [PATCH 02/13] Enable ESLint no-empty rule (#4342) # Description of Changes Enable ESLint [no-empty rule](https://eslint.org/docs/latest/rules/no-empty) --- frontend/eslint.config.mjs | 1 - frontend/src/components/shared/fitText/textFit.ts | 4 ++-- frontend/src/services/pdfWorkerManager.ts | 12 +++++++----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 7922013cb..a8042355f 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -15,7 +15,6 @@ export default defineConfig( }, { rules: { - "no-empty": "off", // Temporarily disabled until codebase conformant "no-empty-pattern": "off", // Temporarily disabled until codebase conformant "no-undef": "off", // Temporarily disabled until codebase conformant "no-useless-escape": "off", // Temporarily disabled until codebase conformant diff --git a/frontend/src/components/shared/fitText/textFit.ts b/frontend/src/components/shared/fitText/textFit.ts index 37da2dc78..7a695ed77 100644 --- a/frontend/src/components/shared/fitText/textFit.ts +++ b/frontend/src/components/shared/fitText/textFit.ts @@ -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 */ } }; } diff --git a/frontend/src/services/pdfWorkerManager.ts b/frontend/src/services/pdfWorkerManager.ts index 0999c5c29..57fc841e0 100644 --- a/frontend/src/services/pdfWorkerManager.ts +++ b/frontend/src/services/pdfWorkerManager.ts @@ -1,6 +1,6 @@ /** * PDF.js Worker Manager - Centralized worker lifecycle management - * + * * Prevents infinite worker creation by managing PDF.js workers globally * and ensuring proper cleanup when operations complete. */ @@ -86,7 +86,7 @@ class PDFWorkerManager { const pdf = await loadingTask.promise; this.activeDocuments.add(pdf); this.workerCount++; - + return pdf; } catch (error) { // If document creation fails, make sure to clean up the loading task @@ -94,6 +94,7 @@ class PDFWorkerManager { try { loadingTask.destroy(); } catch (destroyError) { + // Ignore errors } } throw error; @@ -125,7 +126,7 @@ class PDFWorkerManager { documentsToDestroy.forEach(pdf => { this.destroyDocument(pdf); }); - + this.activeDocuments.clear(); this.workerCount = 0; } @@ -166,9 +167,10 @@ class PDFWorkerManager { try { pdf.destroy(); } catch (error) { + // Ignore errors } }); - + this.activeDocuments.clear(); this.workerCount = 0; } @@ -182,4 +184,4 @@ class PDFWorkerManager { } // Export singleton instance -export const pdfWorkerManager = PDFWorkerManager.getInstance(); \ No newline at end of file +export const pdfWorkerManager = PDFWorkerManager.getInstance(); From 003285506f0d06e9ba5e58334f93b4ed0959ab3b Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 4 Sep 2025 16:04:49 +0100 Subject: [PATCH 03/13] 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) --- frontend/eslint.config.mjs | 1 - frontend/scripts/generate-licenses.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index a8042355f..d39d66276 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -17,7 +17,6 @@ export default defineConfig( rules: { "no-empty-pattern": "off", // Temporarily disabled until codebase conformant "no-undef": "off", // Temporarily disabled until codebase conformant - "no-useless-escape": "off", // Temporarily disabled until codebase conformant "no-case-declarations": "off", // Temporarily disabled until codebase conformant "prefer-const": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/ban-ts-comment": "off", // Temporarily disabled until codebase conformant diff --git a/frontend/scripts/generate-licenses.js b/frontend/scripts/generate-licenses.js index aaac69800..7ab425e19 100644 --- a/frontend/scripts/generate-licenses.js +++ b/frontend/scripts/generate-licenses.js @@ -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]]; } From 74609e54fe7f6050084bdd713aa8cb8e441c59bb Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 4 Sep 2025 16:09:29 +0100 Subject: [PATCH 04/13] Enable ESLint prefer-const rule (#4349) # Description of Changes Enable ESLint [prefer-const rule](https://eslint.org/docs/latest/rules/prefer-const) --- frontend/eslint.config.mjs | 1 - .../src/services/enhancedPDFProcessingService.ts | 7 ++----- frontend/src/services/indexedDBManager.ts | 12 +++++------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index d39d66276..98acb81c4 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -18,7 +18,6 @@ export default defineConfig( "no-empty-pattern": "off", // Temporarily disabled until codebase conformant "no-undef": "off", // Temporarily disabled until codebase conformant "no-case-declarations": "off", // Temporarily disabled until codebase conformant - "prefer-const": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/ban-ts-comment": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-empty-object-type": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index f9f067c30..65d0c6519 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -182,7 +182,7 @@ export class EnhancedPDFProcessingService { ): Promise { const arrayBuffer = await file.arrayBuffer(); const pdf = await pdfWorkerManager.createDocument(arrayBuffer); - + try { const totalPages = pdf.numPages; @@ -519,10 +519,7 @@ export class EnhancedPDFProcessingService { this.notifyListeners(); // Force memory cleanup hint - if (typeof window !== 'undefined' && window.gc) { - let gc = window.gc; - setTimeout(() => gc(), 100); - } + setTimeout(() => window.gc?.(), 100); } /** diff --git a/frontend/src/services/indexedDBManager.ts b/frontend/src/services/indexedDBManager.ts index 2048c021f..9251998a3 100644 --- a/frontend/src/services/indexedDBManager.ts +++ b/frontend/src/services/indexedDBManager.ts @@ -73,7 +73,7 @@ class IndexedDBManager { request.onsuccess = () => { const db = request.result; console.log(`Successfully opened ${config.name}`); - + // Set up close handler to clean up our references db.onclose = () => { console.log(`Database ${config.name} closed`); @@ -87,13 +87,11 @@ class IndexedDBManager { request.onupgradeneeded = (event) => { const db = request.result; const oldVersion = event.oldVersion; - + console.log(`Upgrading ${config.name} from v${oldVersion} to v${config.version}`); // Create or update object stores config.stores.forEach(storeConfig => { - let store: IDBObjectStore; - if (db.objectStoreNames.contains(storeConfig.name)) { // Store exists - for now, just continue (could add migration logic here) console.log(`Object store '${storeConfig.name}' already exists`); @@ -109,7 +107,7 @@ class IndexedDBManager { options.autoIncrement = storeConfig.autoIncrement; } - store = db.createObjectStore(storeConfig.name, options); + const store = db.createObjectStore(storeConfig.name, options); console.log(`Created object store '${storeConfig.name}'`); // Create indexes @@ -168,7 +166,7 @@ class IndexedDBManager { return new Promise((resolve, reject) => { const deleteRequest = indexedDB.deleteDatabase(name); - + deleteRequest.onerror = () => reject(deleteRequest.error); deleteRequest.onsuccess = () => { console.log(`Deleted database: ${name}`); @@ -224,4 +222,4 @@ export const DATABASE_CONFIGS = { } as DatabaseConfig } as const; -export const indexedDBManager = IndexedDBManager.getInstance(); \ No newline at end of file +export const indexedDBManager = IndexedDBManager.getInstance(); From 94e8f603ffe9f1970f1f27fdacf20536b13df90f Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 4 Sep 2025 16:12:38 +0100 Subject: [PATCH 05/13] Enable ESLint no-unused-expressions rule (#4363) # Description of Changes Enable ESLint [no-unused-expressions rule](https://typescript-eslint.io/rules/no-unused-expressions/) --- frontend/eslint.config.mjs | 1 - frontend/src/components/tools/toolPicker/ToolSearch.tsx | 2 +- frontend/src/index.tsx | 7 +++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 98acb81c4..6bce88115 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -22,7 +22,6 @@ export default defineConfig( "@typescript-eslint/no-empty-object-type": "off", // Temporarily disabled until codebase conformant "@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-expressions": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-unused-vars": "off", // Temporarily disabled until codebase conformant }, } diff --git a/frontend/src/components/tools/toolPicker/ToolSearch.tsx b/frontend/src/components/tools/toolPicker/ToolSearch.tsx index 53a01cb77..d4350044e 100644 --- a/frontend/src/components/tools/toolPicker/ToolSearch.tsx +++ b/frontend/src/components/tools/toolPicker/ToolSearch.tsx @@ -126,7 +126,7 @@ const ToolSearch = ({ key={id} variant="subtle" onClick={() => { - onToolSelect && onToolSelect(id); + onToolSelect?.(id); setDropdownOpen(false); }} leftSection={
{tool.icon}
} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 55fe7f046..38a0c1923 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -35,8 +35,11 @@ function updatePosthogConsent(){ return; } const optIn = (window.CookieConsent as any).acceptedCategory('analytics'); - optIn? - posthog.opt_in_capturing() : posthog.opt_out_capturing(); + if (optIn) { + posthog.opt_in_capturing(); + } else { + posthog.opt_out_capturing(); + } console.log("Updated analytics consent: ", optIn? "opted in" : "opted out"); } From b9cf7e78201f0ec836daa0e65e7c8e2ebf48eb6c Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 5 Sep 2025 10:55:03 +0100 Subject: [PATCH 06/13] 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) --- frontend/eslint.config.mjs | 1 - frontend/src/components/shared/QuickAccessBar.tsx | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 6bce88115..1405d64c2 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -15,7 +15,6 @@ export default defineConfig( }, { rules: { - "no-empty-pattern": "off", // Temporarily disabled until codebase conformant "no-undef": "off", // Temporarily disabled until codebase conformant "no-case-declarations": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/ban-ts-comment": "off", // Temporarily disabled until codebase conformant diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index e65843fa4..8447a891c 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -17,8 +17,7 @@ import { getActiveNavButton, } from './quickAccessBar/QuickAccessBar'; -const QuickAccessBar = forwardRef(({ -}, ref) => { +const QuickAccessBar = forwardRef((_, ref) => { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); From cd1fc682ab95dc076f40f193b912d14861689c6e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 5 Sep 2025 10:58:14 +0100 Subject: [PATCH 07/13] 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) --- frontend/eslint.config.mjs | 1 - .../hooks/tools/shared/useToolOperation.ts | 26 ++++++++++--------- .../services/enhancedPDFProcessingService.ts | 3 ++- frontend/src/services/fileAnalyzer.ts | 6 +++-- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 1405d64c2..7d0e65171 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -16,7 +16,6 @@ export default defineConfig( { rules: { "no-undef": "off", // Temporarily disabled until codebase conformant - "no-case-declarations": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/ban-ts-comment": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-empty-object-type": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index d8d35176d..3005d5c35 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -174,7 +174,7 @@ export const useToolOperation = ( let processedFiles: File[]; switch (config.toolType) { - case ToolType.singleFile: + case ToolType.singleFile: { // Individual file processing - separate API call per file const apiCallsConfig: ApiCallsConfig = { endpoint: config.endpoint, @@ -190,8 +190,9 @@ export const useToolOperation = ( 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, validFiles); @@ -204,7 +205,7 @@ export const useToolOperation = ( // Use custom responseHandler for multi-file (handles ZIP extraction) processedFiles = await config.responseHandler(response.data, validFiles); } else if (response.data.type === 'application/pdf' || - (response.headers && response.headers['content-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 = validFiles[0]?.name || 'document.pdf'; const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' }); @@ -219,6 +220,7 @@ export const useToolOperation = ( } } break; + } case ToolType.custom: actions.setStatus('Processing files...'); @@ -243,7 +245,7 @@ export const useToolOperation = ( // Replace input files with processed files (consumeFiles handles pinning) const inputFileIds: FileId[] = []; const inputFileRecords: FileRecord[] = []; - + // Build parallel arrays of IDs and records for undo tracking for (const file of validFiles) { const fileId = findFileId(file); @@ -259,9 +261,9 @@ export const useToolOperation = ( console.warn(`No file ID found for file: ${file.name}`); } } - + const outputFileIds = await consumeFiles(inputFileIds, processedFiles); - + // Store operation data for undo (only store what we need to avoid memory bloat) lastOperationRef.current = { inputFiles: validFiles, // Keep original File objects for undo @@ -326,17 +328,17 @@ export const useToolOperation = ( try { // Undo the consume operation await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds); - + // Clear results and operation tracking resetResults(); lastOperationRef.current = null; - + // Show success message actions.setStatus(t('undoSuccess', 'Operation undone successfully')); - + } catch (error: any) { let errorMessage = extractErrorMessage(error); - + // Provide more specific error messages based on error type if (error.message?.includes('Mismatch between input files')) { errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted'); @@ -345,9 +347,9 @@ export const useToolOperation = ( } else if (error.name === 'QuotaExceededError') { errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space'); } - + actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`); - + // Don't clear the operation data if undo failed - user might want to try again } }, [undoConsumeFiles, resetResults, actions, t]); diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index 65d0c6519..11d534a4f 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -459,11 +459,12 @@ export class EnhancedPDFProcessingService { case 'failed': this.metrics.failedFiles++; break; - case 'cacheHit': + case 'cacheHit': { // Update cache hit rate const totalAttempts = this.metrics.totalFiles + 1; this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts; break; + } } } diff --git a/frontend/src/services/fileAnalyzer.ts b/frontend/src/services/fileAnalyzer.ts index 537692600..1705bb54e 100644 --- a/frontend/src/services/fileAnalyzer.ts +++ b/frontend/src/services/fileAnalyzer.ts @@ -148,15 +148,17 @@ export class FileAnalyzer { case 'immediate_full': return pageCount * baseTime; - case 'priority_pages': + case 'priority_pages': { // Estimate time for priority pages (first 10) const priorityPages = Math.min(pageCount, 10); return priorityPages * baseTime; + } - case 'progressive_chunked': + case 'progressive_chunked': { // Estimate time for first chunk (20 pages) const firstChunk = Math.min(pageCount, 20); return firstChunk * baseTime; + } default: return pageCount * baseTime; From d558bb5faccbca978e025b23a7be9aa67a789411 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 5 Sep 2025 11:02:00 +0100 Subject: [PATCH 08/13] Enable ESLint ban-ts-comment rule (#4350) # Description of Changes Enable ESLint [ban-ts-comment rule](https://typescript-eslint.io/rules/ban-ts-comment/) --- frontend/eslint.config.mjs | 1 - .../tools/convert/useConvertParametersAutoDetection.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 7d0e65171..502ec4f71 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -16,7 +16,6 @@ export default defineConfig( { rules: { "no-undef": "off", // Temporarily disabled until codebase conformant - "@typescript-eslint/ban-ts-comment": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-empty-object-type": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant diff --git a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts index 32543ec91..798fdc5b4 100644 --- a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts +++ b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts @@ -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 } ]; From 5caec41d9628c4b91a8e55fcfe70f293a70e3d70 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 5 Sep 2025 11:15:36 +0100 Subject: [PATCH 09/13] 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/) --- frontend/eslint.config.mjs | 8 +++++++- .../components/tools/shared/SuggestedToolsSection.tsx | 2 -- frontend/src/types/parameters.ts | 9 ++++----- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 502ec4f71..d823a6af9 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -16,7 +16,13 @@ export default defineConfig( { rules: { "no-undef": "off", // Temporarily disabled until codebase conformant - "@typescript-eslint/no-empty-object-type": "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": "off", // Temporarily disabled until codebase conformant diff --git a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx index fca3b5e56..c1299a749 100644 --- a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx +++ b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx @@ -3,8 +3,6 @@ import { Stack, Text, Divider, Card, Group } 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(); diff --git a/frontend/src/types/parameters.ts b/frontend/src/types/parameters.ts index 6f8856a8b..91355cb81 100644 --- a/frontend/src/types/parameters.ts +++ b/frontend/src/types/parameters.ts @@ -1,7 +1,6 @@ // Base parameter interfaces for reusable patterns -export interface BaseParameters { - // Base interface that all tool parameters should extend - // Provides a foundation for adding common properties across all tools - // Examples of future additions: userId, sessionId, commonFlags, etc. -} \ No newline at end of file +// Base interface that all tool parameters should extend +// Provides a foundation for adding common properties across all tools +// Examples of future additions: userId, sessionId, commonFlags, etc. +export type BaseParameters = object From 87c63efcecd0af940531f66893e6c273eef05480 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:33:03 +0100 Subject: [PATCH 10/13] 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 --- frontend/src/App.tsx | 3 + frontend/src/components/FileManager.tsx | 2 +- .../src/components/fileEditor/FileEditor.tsx | 54 +++--- .../fileEditor/FileEditorThumbnail.tsx | 4 +- .../components/pageEditor/FileThumbnail.tsx | 4 +- .../pageEditor/hooks/usePageDocument.ts | 22 +-- frontend/src/components/shared/FileCard.tsx | 6 +- frontend/src/components/shared/FileGrid.tsx | 24 ++- frontend/src/components/shared/RightRail.tsx | 17 +- .../tools/convert/ConvertSettings.tsx | 23 +-- .../tools/convert/ConvertToPdfaSettings.tsx | 3 +- .../tools/shared/FileStatusIndicator.tsx | 7 +- .../components/tools/shared/FilesToolStep.tsx | 3 +- .../tools/shared/createToolFlow.tsx | 3 +- frontend/src/components/viewer/Viewer.tsx | 3 +- frontend/src/contexts/FileContext.tsx | 54 +++--- frontend/src/contexts/file/FileReducer.ts | 18 +- frontend/src/contexts/file/fileActions.ts | 71 ++++---- frontend/src/contexts/file/fileHooks.ts | 20 +-- frontend/src/contexts/file/fileSelectors.ts | 58 ++++--- frontend/src/contexts/file/lifecycle.ts | 4 +- .../src/hooks/tools/shared/useBaseTool.ts | 3 +- .../hooks/tools/shared/useToolOperation.ts | 63 ++++--- frontend/src/hooks/useFileManager.ts | 2 +- frontend/src/hooks/useFileWithUrl.ts | 5 +- frontend/src/hooks/useIndexedDBThumbnail.ts | 5 +- frontend/src/hooks/usePDFProcessor.ts | 3 +- .../src/hooks/usePdfSignatureDetection.ts | 3 +- frontend/src/hooks/useThumbnailGeneration.ts | 5 +- .../services/enhancedPDFProcessingService.ts | 13 +- frontend/src/services/pdfProcessingService.ts | 3 +- .../tests/convert/ConvertIntegration.test.tsx | 12 +- .../ConvertSmartDetectionIntegration.test.tsx | 64 +++---- frontend/src/tests/utils/testFileHelpers.ts | 28 ++++ frontend/src/types/fileContext.ts | 157 ++++++++++++------ frontend/src/types/fileIdSafety.d.ts | 49 ++++++ frontend/src/utils/fileIdSafety.ts | 14 ++ 37 files changed, 493 insertions(+), 339 deletions(-) create mode 100644 frontend/src/tests/utils/testFileHelpers.ts create mode 100644 frontend/src/types/fileIdSafety.d.ts create mode 100644 frontend/src/utils/fileIdSafety.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a30d3869..c3cbf3e89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => (
= ({ selectedTool }) => { const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); // Wrapper for storeFile that generates UUID - const storeFileWithId = useCallback(async (file: File) => { + const storeStirlingFile = useCallback(async (file: File) => { const fileId = createFileId(); // Generate UUID for storage return await storeFile(file, fileId); }, [storeFile]); diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index bf95d9796..901eb20ca 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -16,12 +16,12 @@ 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?: (file: StirlingFile) => void; + onMergeFiles?: (files: StirlingFile[]) => void; toolMode?: boolean; showUpload?: boolean; showBulkActions?: boolean; @@ -50,7 +50,7 @@ const FileEditor = ({ // 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; @@ -92,10 +92,10 @@ const FileEditor = ({ const contextSelectedIdsRef = useRef([]); 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; @@ -253,26 +253,26 @@ const FileEditor = ({ }, [addFiles]); const selectAll = useCallback(() => { - setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly - }, [activeFileRecords, setSelectedFiles]); + setSelectedFiles(activeStirlingFileStubs.map(r => r.id)); // Use StirlingFileStub IDs directly + }, [activeStirlingFileStubs, setSelectedFiles]); const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]); const closeAllFiles = useCallback(() => { - if (activeFileRecords.length === 0) return; + if (activeStirlingFileStubs.length === 0) return; // Remove all files from context but keep in storage - const allFileIds = activeFileRecords.map(record => record.id); + const allFileIds = activeStirlingFileStubs.map(record => record.id); removeFiles(allFileIds, false); // false = keep in storage // Clear selections setSelectedFiles([]); - }, [activeFileRecords, removeFiles, setSelectedFiles]); + }, [activeStirlingFileStubs, 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 @@ -302,7 +302,7 @@ 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 => { @@ -316,7 +316,7 @@ const FileEditor = ({ // 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); @@ -368,13 +368,13 @@ 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) { @@ -405,27 +405,27 @@ const FileEditor = ({ 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]); + }, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]); const handleMergeFromHere = useCallback((fileId: FileId) => { - const startIndex = activeFileRecords.findIndex(r => r.id === fileId); + const startIndex = activeStirlingFileStubs.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[]; + const recordsToMerge = activeStirlingFileStubs.slice(startIndex); + const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as StirlingFile[]; if (onMergeFiles) { onMergeFiles(filesToMerge); } - }, [activeFileRecords, selectors, onMergeFiles]); + }, [activeStirlingFileStubs, selectors, onMergeFiles]); const handleSplitFile = useCallback((fileId: FileId) => { const file = selectors.getFile(fileId); @@ -467,7 +467,7 @@ const FileEditor = ({ - {activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? ( + {activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
📁 @@ -475,7 +475,7 @@ const FileEditor = ({ Upload PDF files, ZIP archives, or load from storage to get started
- ) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? ( + ) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? ( @@ -522,7 +522,7 @@ const FileEditor = ({ pointerEvents: 'auto' }} > - {activeFileRecords.map((record, index) => { + {activeStirlingFileStubs.map((record, index) => { const fileItem = recordToFileItem(record); if (!fileItem) return null; @@ -531,7 +531,7 @@ const FileEditor = ({ key={record.id} file={fileItem} index={index} - totalFiles={activeFileRecords.length} + totalFiles={activeStirlingFileStubs.length} selectedFiles={localSelectedIds} selectionMode={selectionMode} onToggleFile={toggleFile} diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index e82483898..bfeb404c5 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -61,8 +61,8 @@ 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; const downloadSelectedFile = useCallback(() => { diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index 1eda1f6c8..ad81ce463 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -61,8 +61,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(() => { diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts index b620c87b8..3a4d49053 100644 --- a/frontend/src/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -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, []); @@ -72,12 +72,12 @@ export function usePageDocument(): PageDocumentHook { // 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) { @@ -159,7 +159,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(() => { diff --git a/frontend/src/components/shared/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx index 73ae01dba..6c63af42e 100644 --- a/frontend/src/components/shared/FileCard.tsx +++ b/frontend/src/components/shared/FileCard.tsx @@ -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); diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx index 1a43196d6..2d9cba640 100644 --- a/frontend/src/components/shared/FileGrid.tsx +++ b/frontend/src/components/shared/FileGrid.tsx @@ -4,15 +4,15 @@ 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 ( { @@ -85,7 +84,7 @@ export default function RightRail() { if (currentView === 'fileEditor' || currentView === 'viewer') { // Download selected files (or all if none selected) const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles; - + filesToDownload.forEach(file => { const link = document.createElement('a'); link.href = URL.createObjectURL(file); @@ -206,8 +205,8 @@ export default function RightRail() { )} {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */} -
@@ -358,14 +357,14 @@ export default function RightRail() { 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) } position="left" offset={12} arrow>
- 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); }; diff --git a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx index e1a662bd2..49e057a1c 100644 --- a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx @@ -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; } diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index 9b375fc2f..a083d65ef 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -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(null); @@ -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 ( diff --git a/frontend/src/components/tools/shared/FilesToolStep.tsx b/frontend/src/components/tools/shared/FilesToolStep.tsx index b062e9c02..8c188d4a9 100644 --- a/frontend/src/components/tools/shared/FilesToolStep.tsx +++ b/frontend/src/components/tools/shared/FilesToolStep.tsx @@ -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; diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index b6a7594c6..83057c13a 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -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; diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index 0932e995b..53d9e98e2 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -15,6 +15,7 @@ import { fileStorage } from "../../services/fileStorage"; import SkeletonLoader from '../shared/SkeletonLoader'; import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext"; import { useFileWithUrl } from "../../hooks/useFileWithUrl"; +import { isFileObject } from "../../types/fileContext"; import { FileId } from "../../types/file"; @@ -201,7 +202,7 @@ const Viewer = ({ const effectiveFile = React.useMemo(() => { if (previewFile) { // Validate the preview file - if (!(previewFile instanceof File)) { + if (!isFileObject(previewFile)) { return null; } diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 39faa0643..80735de58 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -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'; @@ -79,7 +81,7 @@ function FileContextInner({ } // File operations using unified addFiles helper with persistence - const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => { + const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => { const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager); // Auto-select the newly added files if requested @@ -98,15 +100,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 => { + const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise => { 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 => { + const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise => { const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager); // Auto-select the newly added files if requested @@ -114,7 +116,7 @@ function FileContextInner({ selectFiles(result); } - return result.map(({ file }) => file); + return result.map(({ file, id }) => createStirlingFile(file, id)); }, []); // Action creators @@ -122,11 +124,11 @@ function FileContextInner({ // Helper functions for pinned files const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise => { - 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 => { - return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB); + const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise => { + return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, stateRef, filesRef, dispatch, indexedDB); }, [indexedDB]); // Helper to find FileId from File object @@ -140,24 +142,14 @@ function FileContextInner({ }); }, []); - // 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]); + // File pinning functions - use StirlingFile directly + const pinFileWrapper = useCallback((file: StirlingFile) => { + baseActions.pinFile(file.fileId); + }, [baseActions]); - 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(() => ({ @@ -178,8 +170,8 @@ function FileContextInner({ } } }, - updateFileRecord: (fileId: FileId, updates: Partial) => - lifecycleManager.updateFileRecord(fileId, updates, stateRef), + updateStirlingFileStub: (fileId: FileId, updates: Partial) => + lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef), reorderFiles: (orderedFileIds: FileId[]) => { dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } }); }, @@ -303,7 +295,7 @@ export { useFileSelection, useFileManagement, useFileUI, - useFileRecord, + useStirlingFileStub, useAllFiles, useSelectedFiles, // Primary API hooks for tools diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts index 93c06c4d2..4c4196764 100644 --- a/frontend/src/contexts/file/FileReducer.ts +++ b/frontend/src/contexts/file/FileReducer.ts @@ -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 = { ...state.files.byId }; + const newById: Record = { ...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); @@ -233,13 +233,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': { diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index e55108553..3901fabee 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -3,10 +3,10 @@ */ import { - FileRecord, + StirlingFileStub, FileContextAction, FileContextState, - toFileRecord, + toStirlingFileStub, createFileId, createQuickKey } from '../../types/fileContext'; @@ -109,8 +109,8 @@ export async function addFiles( await addFilesMutex.lock(); try { - const fileRecords: FileRecord[] = []; - const addedFiles: AddedFile[] = []; + const stirlingFileStubs: StirlingFileStub[] = []; + const addedFiles: AddedFile[] = []; // Build quickKey lookup from existing files for deduplication const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId); @@ -163,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,7 +184,7 @@ export async function addFiles( } existingQuickKeys.add(quickKey); - fileRecords.push(record); + stirlingFileStubs.push(record); addedFiles.push({ file, id: fileId, thumbnail }); } break; @@ -205,7 +205,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) @@ -226,7 +226,7 @@ export async function addFiles( } existingQuickKeys.add(quickKey); - fileRecords.push(record); + stirlingFileStubs.push(record); addedFiles.push({ file, id: fileId, thumbnail }); } break; @@ -254,7 +254,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; @@ -301,7 +301,7 @@ export async function addFiles( } existingQuickKeys.add(quickKey); - fileRecords.push(record); + stirlingFileStubs.push(record); addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail }); } @@ -310,9 +310,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; @@ -328,7 +328,7 @@ export async function addFiles( async function processFilesIntoRecords( files: File[], filesRef: React.MutableRefObject> -): Promise> { +): Promise> { return Promise.all( files.map(async (file) => { const fileId = createFileId(); @@ -347,7 +347,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; } @@ -365,10 +365,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 } ): Promise { - 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) { @@ -383,7 +383,6 @@ async function persistFilesToIndexedDB( export async function consumeFiles( inputFileIds: FileId[], outputFiles: File[], - stateRef: React.MutableRefObject, filesRef: React.MutableRefObject>, dispatch: React.Dispatch, indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise } | null @@ -391,11 +390,11 @@ export async function consumeFiles( 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 if (indexedDB) { - await persistFilesToIndexedDB(outputFileRecords, indexedDB); + await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB); } // Dispatch the consume action @@ -403,21 +402,21 @@ 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>, indexedDB?: { deleteFile: (fileId: FileId) => Promise } | null @@ -440,7 +439,7 @@ async function restoreFilesAndCleanup( if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`); return; } - + // Restore the file to filesRef if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`); filesRef.current.set(record.id, file); @@ -455,7 +454,7 @@ async function restoreFilesAndCleanup( throw error; // Re-throw to trigger rollback }) ); - + // Execute all IndexedDB operations await Promise.all(indexedDBPromises); } @@ -466,28 +465,28 @@ async function restoreFilesAndCleanup( */ export async function undoConsumeFiles( inputFiles: File[], - inputFileRecords: FileRecord[], + inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[], stateRef: React.MutableRefObject, filesRef: React.MutableRefObject>, dispatch: React.Dispatch, indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise; deleteFile: (fileId: FileId) => Promise } | null ): Promise { - 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 const backupFilesRef = new Map(filesRef.current); - + try { // 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 @@ -502,13 +501,13 @@ 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); diff --git a/frontend/src/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts index e1b8e5cc4..1907c8fb8 100644 --- a/frontend/src/contexts/file/fileHooks.ts +++ b/frontend/src/contexts/file/fileHooks.ts @@ -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]); } diff --git a/frontend/src/contexts/file/fileSelectors.ts b/frontend/src/contexts/file/fileSelectors.ts index 2111693cf..a004831cc 100644 --- a/frontend/src/contexts/file/fileSelectors.ts +++ b/frontend/src/contexts/file/fileSelectors.ts @@ -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> ): 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): Set { +export function buildQuickKeySet(stirlingFileStubs: Record): Set { const quickKeys = new Set(); - 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, filesRef: React.MutableRefObject> -): { file?: File; record?: FileRecord } { +): { file?: File; record?: StirlingFileStub } { const primaryFileId = stateRef.current.files.ids[0]; if (!primaryFileId) return {}; diff --git a/frontend/src/contexts/file/lifecycle.ts b/frontend/src/contexts/file/lifecycle.ts index 9986fe585..901a16943 100644 --- a/frontend/src/contexts/file/lifecycle.ts +++ b/frontend/src/contexts/file/lifecycle.ts @@ -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'; @@ -166,7 +166,7 @@ export class FileLifecycleManager { /** * Update file record with race condition guards */ - updateFileRecord = (fileId: FileId, updates: Partial, stateRef?: React.MutableRefObject): void => { + updateStirlingFileStub = (fileId: FileId, updates: Partial, stateRef?: React.MutableRefObject): 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}`); diff --git a/frontend/src/hooks/tools/shared/useBaseTool.ts b/frontend/src/hooks/tools/shared/useBaseTool.ts index 64e9af59e..643e9bca5 100644 --- a/frontend/src/hooks/tools/shared/useBaseTool.ts +++ b/frontend/src/hooks/tools/shared/useBaseTool.ts @@ -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 { // File management - selectedFiles: File[]; + selectedFiles: StirlingFile[]; // Tool-specific hooks params: BaseParametersHook; diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 3005d5c35..7bfcc3d32 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -6,10 +6,8 @@ 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'; // Re-export for backwards compatibility export type { ProcessingProgress, ResponseHandler }; @@ -104,7 +102,7 @@ export interface ToolOperationHook { progress: ProcessingProgress | null; // Actions - executeOperation: (params: TParams, selectedFiles: File[]) => Promise; + executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise; resetResults: () => void; clearError: () => void; cancelOperation: () => void; @@ -130,7 +128,7 @@ export const useToolOperation = ( config: ToolOperationConfig ): ToolOperationHook => { const { t } = useTranslation(); - const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext(); + const { addFiles, consumeFiles, undoConsumeFiles, actions: fileActions, selectors } = useFileContext(); // Composed hooks const { state, actions } = useToolState(); @@ -140,13 +138,13 @@ export const useToolOperation = ( // 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 => { // Validation if (selectedFiles.length === 0) { @@ -160,9 +158,6 @@ export const useToolOperation = ( return; } - // Setup operation tracking - const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles); - recordOperation(fileId, operation); // Reset state actions.setLoading(true); @@ -173,6 +168,9 @@ export const useToolOperation = ( try { let processedFiles: File[]; + // Convert StirlingFile to regular File objects for API processing + const validRegularFiles = extractFiles(validFiles); + switch (config.toolType) { case ToolType.singleFile: { // Individual file processing - separate API call per file @@ -184,7 +182,7 @@ export const useToolOperation = ( }; processedFiles = await processFiles( params, - validFiles, + validRegularFiles, apiCallsConfig, actions.setProgress, actions.setStatus @@ -195,7 +193,7 @@ export const useToolOperation = ( case ToolType.multiFile: { // Multi-file processing - single API call with all files actions.setStatus('Processing files...'); - const formData = config.buildFormData(params, validFiles); + const formData = config.buildFormData(params, validRegularFiles); const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; const response = await axios.post(endpoint, formData, { responseType: 'blob' }); @@ -203,11 +201,11 @@ export const useToolOperation = ( // 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, validFiles); + processedFiles = await config.responseHandler(response.data, validRegularFiles); } 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 = validFiles[0]?.name || 'document.pdf'; + const originalFileName = validRegularFiles[0]?.name || 'document.pdf'; const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' }); processedFiles = [singleFile]; } else { @@ -224,7 +222,7 @@ export const useToolOperation = ( case ToolType.custom: actions.setStatus('Processing files...'); - processedFiles = await config.customProcessor(params, validFiles); + processedFiles = await config.customProcessor(params, validRegularFiles); break; } @@ -244,21 +242,17 @@ export const useToolOperation = ( // 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); - if (record) { - inputFileIds.push(fileId); - inputFileRecords.push(record); - } else { - console.warn(`No file record found for file: ${file.name}`); - } + const fileId = file.fileId; + const record = selectors.getStirlingFileStub(fileId); + if (record) { + inputFileIds.push(fileId); + inputStirlingFileStubs.push(record); } else { - console.warn(`No file ID found for file: ${file.name}`); + console.warn(`No file stub found for file: ${file.name}`); } } @@ -266,24 +260,22 @@ export const useToolOperation = ( // 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(); @@ -312,10 +304,10 @@ export const useToolOperation = ( 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; } @@ -327,7 +319,8 @@ export const useToolOperation = ( try { // Undo the consume operation - await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds); + await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds); + // Clear results and operation tracking resetResults(); diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index f47430fd2..8df1e2754 100644 --- a/frontend/src/hooks/useFileManager.ts +++ b/frontend/src/hooks/useFileManager.ts @@ -2,7 +2,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); diff --git a/frontend/src/hooks/useFileWithUrl.ts b/frontend/src/hooks/useFileWithUrl.ts index fd2f2d604..5176c1225 100644 --- a/frontend/src/hooks/useFileWithUrl.ts +++ b/frontend/src/hooks/useFileWithUrl.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { isFileObject } from '../types/fileContext'; /** * Hook to convert a File object to { file: File; url: string } format @@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u return useMemo(() => { if (!file) return null; - // Validate that file is a proper File or Blob object - if (!(file instanceof File) && !(file instanceof Blob)) { + // Validate that file is a proper File, StirlingFile, or Blob object + if (!isFileObject(file) && !(file instanceof Blob)) { console.warn('useFileWithUrl: Expected File or Blob, got:', file); return null; } diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index 4f0d77c0e..a6251db3c 100644 --- a/frontend/src/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/hooks/useIndexedDBThumbnail.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { FileMetadata } from "../types/file"; import { useIndexedDB } from "../contexts/IndexedDBContext"; import { generateThumbnailForFile } from "../utils/thumbnailUtils"; +import { FileId } from "../types/fileContext"; /** * Calculate optimal scale for thumbnail generation @@ -53,7 +54,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { // Try to load file from IndexedDB using new context if (file.id && indexedDB) { - const loadedFile = await indexedDB.loadFile(file.id); + const loadedFile = await indexedDB.loadFile(file.id as FileId); if (!loadedFile) { throw new Error('File not found in IndexedDB'); } @@ -70,7 +71,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { // Save thumbnail to IndexedDB for persistence if (file.id && indexedDB && thumbnail) { try { - await indexedDB.updateThumbnail(file.id, thumbnail); + await indexedDB.updateThumbnail(file.id as FileId, thumbnail); } catch (error) { console.warn('Failed to save thumbnail to IndexedDB:', error); } diff --git a/frontend/src/hooks/usePDFProcessor.ts b/frontend/src/hooks/usePDFProcessor.ts index ab3b5e007..b88bfbe2b 100644 --- a/frontend/src/hooks/usePDFProcessor.ts +++ b/frontend/src/hooks/usePDFProcessor.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { PDFDocument, PDFPage } from '../types/pageEditor'; import { pdfWorkerManager } from '../services/pdfWorkerManager'; +import { createQuickKey } from '../types/fileContext'; export function usePDFProcessor() { const [loading, setLoading] = useState(false); @@ -75,7 +76,7 @@ export function usePDFProcessor() { // Create pages without thumbnails initially - load them lazily for (let i = 1; i <= totalPages; i++) { pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, originalPageNumber: i, thumbnail: null, // Will be loaded lazily diff --git a/frontend/src/hooks/usePdfSignatureDetection.ts b/frontend/src/hooks/usePdfSignatureDetection.ts index 17f90f2d9..b14c1a637 100644 --- a/frontend/src/hooks/usePdfSignatureDetection.ts +++ b/frontend/src/hooks/usePdfSignatureDetection.ts @@ -1,13 +1,14 @@ import { useState, useEffect } from 'react'; import * as pdfjsLib from 'pdfjs-dist'; import { pdfWorkerManager } from '../services/pdfWorkerManager'; +import { StirlingFile } from '../types/fileContext'; export interface PdfSignatureDetectionResult { hasDigitalSignatures: boolean; isChecking: boolean; } -export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => { +export const usePdfSignatureDetection = (files: StirlingFile[]): PdfSignatureDetectionResult => { const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false); const [isChecking, setIsChecking] = useState(false); diff --git a/frontend/src/hooks/useThumbnailGeneration.ts b/frontend/src/hooks/useThumbnailGeneration.ts index 8eba26214..310634045 100644 --- a/frontend/src/hooks/useThumbnailGeneration.ts +++ b/frontend/src/hooks/useThumbnailGeneration.ts @@ -1,5 +1,6 @@ import { useCallback, useRef } from 'react'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; +import { createQuickKey } from '../types/fileContext'; import { FileId } from '../types/file'; // Request queue to handle concurrent thumbnail requests @@ -71,8 +72,8 @@ async function processRequestQueue() { console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`); - // Use file name as fileId for PDF document caching - const fileId = file.name + '_' + file.size + '_' + file.lastModified as FileId; + // Use quickKey for PDF document caching (same metadata, consistent format) + const fileId = createQuickKey(file) as FileId; const results = await thumbnailGenerationService.generateThumbnails( fileId, diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index 11d534a4f..2b6b18c8c 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -5,6 +5,7 @@ import { FileHasher } from '../utils/fileHash'; import { FileAnalyzer } from './fileAnalyzer'; import { ProcessingErrorHandler } from './processingErrorHandler'; import { pdfWorkerManager } from './pdfWorkerManager'; +import { createQuickKey } from '../types/fileContext'; export class EnhancedPDFProcessingService { private static instance: EnhancedPDFProcessingService; @@ -201,7 +202,7 @@ export class EnhancedPDFProcessingService { const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, rotation: 0, @@ -251,7 +252,7 @@ export class EnhancedPDFProcessingService { const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, rotation: 0, @@ -266,7 +267,7 @@ export class EnhancedPDFProcessingService { // Create placeholder pages for remaining pages for (let i = priorityCount + 1; i <= totalPages; i++) { pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail: null, // Will be loaded lazily rotation: 0, @@ -313,7 +314,7 @@ export class EnhancedPDFProcessingService { const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, rotation: 0, @@ -334,7 +335,7 @@ export class EnhancedPDFProcessingService { // Create placeholders for remaining pages for (let i = firstChunkEnd + 1; i <= totalPages; i++) { pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail: null, rotation: 0, @@ -368,7 +369,7 @@ export class EnhancedPDFProcessingService { const pages: PDFPage[] = []; for (let i = 1; i <= totalPages; i++) { pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail: null, rotation: 0, diff --git a/frontend/src/services/pdfProcessingService.ts b/frontend/src/services/pdfProcessingService.ts index 065f53210..7ede9334d 100644 --- a/frontend/src/services/pdfProcessingService.ts +++ b/frontend/src/services/pdfProcessingService.ts @@ -1,6 +1,7 @@ import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing'; import { ProcessingCache } from './processingCache'; import { pdfWorkerManager } from './pdfWorkerManager'; +import { createQuickKey } from '../types/fileContext'; export class PDFProcessingService { private static instance: PDFProcessingService; @@ -113,7 +114,7 @@ export class PDFProcessingService { const thumbnail = canvas.toDataURL(); pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, rotation: 0, diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index 41a768838..4efb41d7e 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -18,6 +18,8 @@ import { FileContextProvider } from '../../contexts/FileContext'; import { I18nextProvider } from 'react-i18next'; import i18n from '../../i18n/config'; import axios from 'axios'; +import { createTestStirlingFile } from '../utils/testFileHelpers'; +import { StirlingFile } from '../../types/fileContext'; // Mock axios vi.mock('axios'); @@ -55,9 +57,9 @@ const createTestFile = (name: string, content: string, type: string): File => { return new File([content], name, { type }); }; -const createPDFFile = (): File => { +const createPDFFile = (): StirlingFile => { const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF'; - return createTestFile('test.pdf', pdfContent, 'application/pdf'); + return createTestStirlingFile('test.pdf', pdfContent, 'application/pdf'); }; // Test wrapper component @@ -162,7 +164,7 @@ describe('Convert Tool Integration Tests', () => { wrapper: TestWrapper }); - const testFile = createTestFile('invalid.txt', 'not a pdf', 'text/plain'); + const testFile = createTestStirlingFile('invalid.txt', 'not a pdf', 'text/plain'); const parameters: ConvertParameters = { fromExtension: 'pdf', toExtension: 'png', @@ -426,7 +428,7 @@ describe('Convert Tool Integration Tests', () => { }); const files = [ createPDFFile(), - createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf') + createTestStirlingFile('test2.pdf', '%PDF-1.4...', 'application/pdf') ] const parameters: ConvertParameters = { fromExtension: 'pdf', @@ -527,7 +529,7 @@ describe('Convert Tool Integration Tests', () => { wrapper: TestWrapper }); - const corruptedFile = createTestFile('corrupted.pdf', 'not-a-pdf', 'application/pdf'); + const corruptedFile = createTestStirlingFile('corrupted.pdf', 'not-a-pdf', 'application/pdf'); const parameters: ConvertParameters = { fromExtension: 'pdf', toExtension: 'png', diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx index 4e9fb7908..2904135e0 100644 --- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -14,6 +14,8 @@ import i18n from '../../i18n/config'; import axios from 'axios'; import { detectFileExtension } from '../../utils/fileUtils'; import { FIT_OPTIONS } from '../../constants/convertConstants'; +import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers'; +import { StirlingFile } from '../../types/fileContext'; // Mock axios vi.mock('axios'); @@ -81,7 +83,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mock DOCX file - const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); + const docxFile = createTestStirlingFile('document.docx', 'docx content', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); // Test auto-detection act(() => { @@ -117,7 +119,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mock unknown file - const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' }); + const unknownFile = createTestStirlingFile('document.xyz', 'unknown content', 'application/octet-stream'); // Test auto-detection act(() => { @@ -156,11 +158,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mock image files - const imageFiles = [ - new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }), - new File(['png content'], 'photo2.png', { type: 'image/png' }), - new File(['gif content'], 'photo3.gif', { type: 'image/gif' }) - ]; + const imageFiles = createTestFilesWithId([ + { name: 'photo1.jpg', content: 'jpg content', type: 'image/jpeg' }, + { name: 'photo2.png', content: 'png content', type: 'image/png' }, + { name: 'photo3.gif', content: 'gif content', type: 'image/gif' } + ]); // Test smart detection for all images act(() => { @@ -202,11 +204,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mixed file types - const mixedFiles = [ - new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }), - new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), - new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }) - ]; + const mixedFiles = createTestFilesWithId([ + { name: 'document.pdf', content: 'pdf content', type: 'application/pdf' }, + { name: 'spreadsheet.xlsx', content: 'docx content', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + { name: 'presentation.pptx', content: 'pptx content', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' } + ]); // Test smart detection for mixed types act(() => { @@ -243,10 +245,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mock web files - const webFiles = [ - new File(['content'], 'page1.html', { type: 'text/html' }), - new File(['zip content'], 'site.zip', { type: 'application/zip' }) - ]; + const webFiles = createTestFilesWithId([ + { name: 'page1.html', content: 'content', type: 'text/html' }, + { name: 'site.zip', content: 'zip content', type: 'application/zip' } + ]); // Test smart detection for web files act(() => { @@ -288,7 +290,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const htmlFile = new File(['content'], 'page.html', { type: 'text/html' }); + const htmlFile = createTestStirlingFile('page.html', 'content', 'text/html'); // Set up HTML conversion parameters act(() => { @@ -318,7 +320,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' }); + const emlFile = createTestStirlingFile('email.eml', 'email content', 'message/rfc822'); // Set up email conversion parameters act(() => { @@ -355,7 +357,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }); + const pdfFile = createTestStirlingFile('document.pdf', 'pdf content', 'application/pdf'); // Set up PDF/A conversion parameters act(() => { @@ -392,10 +394,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const imageFiles = [ - new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), - new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) - ]; + const imageFiles = createTestFilesWithId([ + { name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' }, + { name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' } + ]); // Set up image conversion parameters act(() => { @@ -432,10 +434,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const imageFiles = [ - new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), - new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) - ]; + const imageFiles = createTestFilesWithId([ + { name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' }, + { name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' } + ]); // Set up for separate processing act(() => { @@ -477,10 +479,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }) .mockRejectedValueOnce(new Error('File 2 failed')); - const mixedFiles = [ - new File(['file1'], 'doc1.txt', { type: 'text/plain' }), - new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' }) - ]; + const mixedFiles = createTestFilesWithId([ + { name: 'doc1.txt', content: 'file1', type: 'text/plain' }, + { name: 'doc2.xyz', content: 'file2', type: 'application/octet-stream' } + ]); // Set up for separate processing (mixed smart detection) act(() => { diff --git a/frontend/src/tests/utils/testFileHelpers.ts b/frontend/src/tests/utils/testFileHelpers.ts new file mode 100644 index 000000000..80b3c74cf --- /dev/null +++ b/frontend/src/tests/utils/testFileHelpers.ts @@ -0,0 +1,28 @@ +/** + * Test utilities for creating StirlingFile objects in tests + */ + +import { StirlingFile, createStirlingFile } from '../../types/fileContext'; + +/** + * Create a StirlingFile object for testing purposes + */ +export function createTestStirlingFile( + name: string, + content: string = 'test content', + type: string = 'application/pdf' +): StirlingFile { + const file = new File([content], name, { type }); + return createStirlingFile(file); +} + +/** + * Create multiple StirlingFile objects for testing + */ +export function createTestFilesWithId( + files: Array<{ name: string; content?: string; type?: string }> +): StirlingFile[] { + return files.map(({ name, content = 'test content', type = 'application/pdf' }) => + createTestStirlingFile(name, content, type) + ); +} \ No newline at end of file diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 9210f9ce9..f5d4cef0a 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -5,6 +5,9 @@ import { PageOperation } from './pageEditor'; import { FileId, FileMetadata } from './file'; +// Re-export FileId for convenience +export type { FileId }; + export type ModeType = | 'viewer' | 'pageEditor' @@ -41,25 +44,32 @@ export interface ProcessedFileMetadata { [key: string]: any; } -export interface FileRecord { - id: FileId; - name: string; - size: number; - type: string; - lastModified: number; - quickKey?: string; // Fast deduplication key: name|size|lastModified - thumbnailUrl?: string; - blobUrl?: string; - createdAt?: number; - processedFile?: ProcessedFileMetadata; - insertAfterPageId?: string; // Page ID after which this file should be inserted - isPinned?: boolean; +/** + * StirlingFileStub - Metadata record for files in the active workbench session + * + * Contains UI display data and processing state. Actual File objects stored + * separately in refs for memory efficiency. Supports multi-tool workflows + * where files persist across tool operations. + */ +export interface StirlingFileStub { + id: FileId; // UUID primary key for collision-free operations + name: string; // Display name for UI + size: number; // File size for progress indicators + type: string; // MIME type for format validation + lastModified: number; // Original timestamp for deduplication + quickKey?: string; // Fast deduplication key: name|size|lastModified + thumbnailUrl?: string; // Generated thumbnail blob URL for visual display + blobUrl?: string; // File access blob URL for downloads/processing + createdAt?: number; // When added to workbench for sorting + processedFile?: ProcessedFileMetadata; // PDF page data and processing results + insertAfterPageId?: string; // Page ID after which this file should be inserted + isPinned?: boolean; // Protected from tool consumption (replace/remove) // Note: File object stored in provider ref, not in state } export interface FileContextNormalizedFiles { ids: FileId[]; - byId: Record; + byId: Record; } // Helper functions - UUID-based primary keys (zero collisions, synchronous) @@ -82,9 +92,68 @@ export function createQuickKey(file: File): string { return `${file.name}|${file.size}|${file.lastModified}`; } +// Stirling PDF file with embedded UUID - replaces loose File + FileId parameter passing +export interface StirlingFile extends File { + readonly fileId: FileId; + readonly quickKey: string; // Fast deduplication key: name|size|lastModified +} + +// Type guard to check if a File object has an embedded fileId +export function isStirlingFile(file: File): file is StirlingFile { + return 'fileId' in file && typeof (file as any).fileId === 'string' && + 'quickKey' in file && typeof (file as any).quickKey === 'string'; +} + +// Create a StirlingFile from a regular File object +export function createStirlingFile(file: File, id?: FileId): StirlingFile { + const fileId = id || createFileId(); + const quickKey = createQuickKey(file); + + // Use Object.defineProperty to add properties while preserving the original File object + // This maintains proper method binding and avoids "Illegal invocation" errors + Object.defineProperty(file, 'fileId', { + value: fileId, + writable: false, + enumerable: true, + configurable: false + }); + + Object.defineProperty(file, 'quickKey', { + value: quickKey, + writable: false, + enumerable: true, + configurable: false + }); + + return file as StirlingFile; +} + +// Extract FileIds from StirlingFile array +export function extractFileIds(files: StirlingFile[]): FileId[] { + return files.map(file => file.fileId); +} + +// Extract regular File objects from StirlingFile array +export function extractFiles(files: StirlingFile[]): File[] { + return files as File[]; +} + +// Check if an object is a File or StirlingFile (replaces instanceof File checks) +export function isFileObject(obj: any): obj is File | StirlingFile { + return obj && + typeof obj.name === 'string' && + typeof obj.size === 'number' && + typeof obj.type === 'string' && + typeof obj.lastModified === 'number' && + typeof obj.arrayBuffer === 'function'; +} -export function toFileRecord(file: File, id?: FileId): FileRecord { + +export function toStirlingFileStub( + file: File, + id?: FileId +): StirlingFileStub { const fileId = id || createFileId(); return { id: fileId, @@ -97,7 +166,7 @@ export function toFileRecord(file: File, id?: FileId): FileRecord { }; } -export function revokeFileResources(record: FileRecord): void { +export function revokeFileResources(record: StirlingFileStub): void { // Only revoke blob: URLs to prevent errors on other schemes if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) { try { @@ -171,7 +240,7 @@ export interface FileContextState { // Core file management - lightweight file IDs only files: { ids: FileId[]; - byId: Record; + byId: Record; }; // Pinned files - files that won't be consumed by tools @@ -190,16 +259,16 @@ export interface FileContextState { // Action types for reducer pattern export type FileContextAction = // File management actions - | { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } } + | { type: 'ADD_FILES'; payload: { stirlingFileStubs: StirlingFileStub[] } } | { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } } - | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial } } + | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial } } | { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } } // Pinned files actions | { type: 'PIN_FILE'; payload: { fileId: FileId } } | { type: 'UNPIN_FILE'; payload: { fileId: FileId } } - | { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } } - | { type: 'UNDO_CONSUME_FILES'; payload: { inputFileRecords: FileRecord[]; outputFileIds: FileId[] } } + | { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputStirlingFileStubs: StirlingFileStub[] } } + | { type: 'UNDO_CONSUME_FILES'; payload: { inputStirlingFileStubs: StirlingFileStub[]; outputFileIds: FileId[] } } // UI actions | { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } } @@ -215,22 +284,22 @@ export type FileContextAction = export interface FileContextActions { // File management - lightweight actions only - addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise; - addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; - addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise; + addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise; + addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; + addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise; - updateFileRecord: (id: FileId, updates: Partial) => void; + updateStirlingFileStub: (id: FileId, updates: Partial) => void; reorderFiles: (orderedFileIds: FileId[]) => void; clearAllFiles: () => Promise; clearAllData: () => Promise; - // File pinning - pinFile: (file: File) => void; - unpinFile: (file: File) => void; + // File pinning - accepts StirlingFile for safer type checking + pinFile: (file: StirlingFile) => void; + unpinFile: (file: StirlingFile) => void; // File consumption (replace unpinned files with outputs) consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise; - undoConsumeFiles: (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]) => Promise; + undoConsumeFiles: (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]) => Promise; // Selection management setSelectedFiles: (fileIds: FileId[]) => void; setSelectedPages: (pageNumbers: number[]) => void; @@ -253,26 +322,17 @@ export interface FileContextActions { // File selectors (separate from actions to avoid re-renders) export interface FileContextSelectors { - // File access - no state dependency, uses ref - getFile: (id: FileId) => File | undefined; - getFiles: (ids?: FileId[]) => File[]; - - // Record access - uses normalized state - getFileRecord: (id: FileId) => FileRecord | undefined; - getFileRecords: (ids?: FileId[]) => FileRecord[]; - - // Derived selectors + getFile: (id: FileId) => StirlingFile | undefined; + getFiles: (ids?: FileId[]) => StirlingFile[]; + getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined; + getStirlingFileStubs: (ids?: FileId[]) => StirlingFileStub[]; getAllFileIds: () => FileId[]; - getSelectedFiles: () => File[]; - getSelectedFileRecords: () => FileRecord[]; - - // Pinned files selectors + getSelectedFiles: () => StirlingFile[]; + getSelectedStirlingFileStubs: () => StirlingFileStub[]; getPinnedFileIds: () => FileId[]; - getPinnedFiles: () => File[]; - getPinnedFileRecords: () => FileRecord[]; - isFilePinned: (file: File) => boolean; - - // Stable signature for effect dependencies + getPinnedFiles: () => StirlingFile[]; + getPinnedStirlingFileStubs: () => StirlingFileStub[]; + isFilePinned: (file: StirlingFile) => boolean; getFilesSignature: () => string; } @@ -293,6 +353,3 @@ export interface FileContextActionsValue { actions: FileContextActions; dispatch: (action: FileContextAction) => void; } - -// TODO: URL parameter types will be redesigned for new routing system - diff --git a/frontend/src/types/fileIdSafety.d.ts b/frontend/src/types/fileIdSafety.d.ts new file mode 100644 index 000000000..08888d4c0 --- /dev/null +++ b/frontend/src/types/fileIdSafety.d.ts @@ -0,0 +1,49 @@ +/** + * Type safety declarations to prevent file.name/UUID confusion + */ + +import { FileId, StirlingFile, OperationType, FileOperation } from './fileContext'; + +declare global { + namespace FileIdSafety { + // Mark functions that should never accept file.name as parameters + type SafeFileIdFunction any> = T extends (...args: infer P) => infer R + ? P extends readonly [string, ...any[]] + ? never // Reject string parameters in first position for FileId functions + : T + : T; + + // Mark functions that should only accept StirlingFile, not regular File + type StirlingFileOnlyFunction any> = T extends (...args: infer P) => infer R + ? P extends readonly [File, ...any[]] + ? never // Reject File parameters in first position for StirlingFile functions + : T + : T; + + // Utility type to enforce StirlingFile usage + type RequireStirlingFile = T extends File ? StirlingFile : T; + } + + // Extend Window interface for debugging + interface Window { + __FILE_ID_DEBUG?: boolean; + } +} + +// Augment FileContext types to prevent bypassing StirlingFile +declare module '../contexts/FileContext' { + export interface StrictFileContextActions { + pinFile: (file: StirlingFile) => void; // Must be StirlingFile + unpinFile: (file: StirlingFile) => void; // Must be StirlingFile + addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise; // Returns StirlingFile + consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise; // Returns StirlingFile + } + + export interface StrictFileContextSelectors { + getFile: (id: FileId) => StirlingFile | undefined; // Returns StirlingFile + getFiles: (ids?: FileId[]) => StirlingFile[]; // Returns StirlingFile[] + isFilePinned: (file: StirlingFile) => boolean; // Must be StirlingFile + } +} + +export {}; \ No newline at end of file diff --git a/frontend/src/utils/fileIdSafety.ts b/frontend/src/utils/fileIdSafety.ts new file mode 100644 index 000000000..3fda8a4a5 --- /dev/null +++ b/frontend/src/utils/fileIdSafety.ts @@ -0,0 +1,14 @@ +/** + * Runtime validation utilities for FileId safety + */ + +import { FileId } from '../types/fileContext'; + +// Validate that a string is a proper FileId (has UUID format) +export function isValidFileId(id: string): id is FileId { + // Check UUID v4 format: 8-4-4-4-12 hex digits + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(id); +} + + From bd13f6bf57898493e6fc7b91e0d1ad583b4a3ac2 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 5 Sep 2025 12:16:17 +0100 Subject: [PATCH 11/13] Enable ESLint no-unused-vars rule (#4367) # Description of Changes Enable ESLint [no-unused-vars rule](https://typescript-eslint.io/rules/no-unused-vars/) --- frontend/eslint.config.mjs | 13 +- frontend/scripts/generate-icons.js | 2 +- frontend/scripts/generate-licenses.js | 2 +- frontend/src/components/FileManager.tsx | 9 +- .../src/components/fileEditor/FileEditor.tsx | 133 +------------ .../fileEditor/FileEditorThumbnail.tsx | 1 - .../components/fileManager/FileDetails.tsx | 37 ++-- .../components/fileManager/MobileLayout.tsx | 26 +-- .../history/FileOperationHistory.tsx | 181 ------------------ frontend/src/components/layout/Workbench.tsx | 7 +- .../components/pageEditor/DragDropGrid.tsx | 41 ++-- .../components/pageEditor/FileThumbnail.tsx | 37 +--- .../src/components/pageEditor/PageEditor.tsx | 40 +--- .../pageEditor/PageEditorControls.tsx | 9 - .../components/pageEditor/PageThumbnail.tsx | 5 +- .../pageEditor/hooks/usePageDocument.ts | 3 - frontend/src/components/shared/FileGrid.tsx | 4 +- .../src/components/shared/LandingPage.tsx | 2 +- .../components/shared/LanguageSelector.tsx | 17 +- frontend/src/components/shared/LocalIcon.tsx | 12 +- .../src/components/shared/QuickAccessBar.tsx | 1 - frontend/src/components/shared/RightRail.tsx | 2 +- .../quickAccessBar/ActiveToolButton.tsx | 13 +- .../src/components/tools/SearchResults.tsx | 8 +- frontend/src/components/tools/ToolPanel.tsx | 6 +- .../addPassword/AddPasswordSettings.test.tsx | 1 - .../tools/addPassword/AddPasswordSettings.tsx | 3 +- .../addWatermark/WatermarkTypeSettings.tsx | 3 +- .../tools/addWatermark/WatermarkWording.tsx | 2 +- .../tools/automate/AutomationCreation.tsx | 12 +- .../tools/automate/AutomationEntry.tsx | 20 +- .../tools/automate/AutomationRun.tsx | 40 ++-- .../tools/automate/ToolConfigurationModal.tsx | 8 +- .../ChangePermissionsSettings.tsx | 2 +- .../src/components/tools/ocr/OCRSettings.tsx | 2 +- .../RemoveCertificateSignSettings.tsx | 8 +- .../removePassword/RemovePasswordSettings.tsx | 2 +- .../tools/repair/RepairSettings.tsx | 8 +- .../tools/shared/FileStatusIndicator.tsx | 4 +- .../tools/shared/ReviewToolStep.tsx | 4 +- .../src/components/tools/shared/ToolStep.tsx | 4 +- .../tools/shared/createToolFlow.tsx | 2 +- .../tools/shared/renderToolButtons.tsx | 3 +- .../SingleLargePageSettings.tsx | 8 +- .../unlockPdfForms/UnlockPdfFormsSettings.tsx | 8 +- frontend/src/components/viewer/Viewer.tsx | 16 +- frontend/src/contexts/FileContext.tsx | 14 +- frontend/src/contexts/FileManagerContext.tsx | 2 +- frontend/src/contexts/IndexedDBContext.tsx | 4 +- frontend/src/contexts/NavigationContext.tsx | 2 +- frontend/src/contexts/ToolWorkflowContext.tsx | 2 + frontend/src/contexts/file/fileActions.ts | 6 +- frontend/src/contexts/file/fileHooks.ts | 6 +- frontend/src/contexts/file/lifecycle.ts | 10 +- .../useAddPasswordOperation.test.ts | 4 +- .../tools/automate/useAutomateOperation.ts | 1 - .../tools/automate/useSavedAutomations.ts | 10 +- .../tools/automate/useSuggestedAutomations.ts | 1 - .../tools/compress/useCompressOperation.ts | 2 +- .../tools/convert/useConvertOperation.ts | 3 +- .../tools/convert/useConvertParameters.ts | 29 ++- .../useConvertParametersAutoDetection.test.ts | 2 +- .../useRemoveCertificateSignOperation.ts | 2 +- .../hooks/tools/repair/useRepairOperation.ts | 2 +- .../hooks/tools/shared/useToolOperation.ts | 6 +- .../useSingleLargePageOperation.ts | 2 +- .../hooks/tools/split/useSplitOperation.ts | 2 +- .../useUnlockPdfFormsOperation.ts | 2 +- frontend/src/hooks/useCookieConsent.ts | 5 - frontend/src/hooks/useEndpointConfig.ts | 23 ++- .../src/hooks/useEnhancedProcessedFiles.ts | 42 ++-- frontend/src/hooks/useFileManager.ts | 1 - frontend/src/hooks/useIndexedDBThumbnail.ts | 16 +- .../src/hooks/usePdfSignatureDetection.ts | 7 +- frontend/src/hooks/useThumbnailGeneration.ts | 2 +- frontend/src/hooks/useToolManagement.tsx | 11 +- frontend/src/hooks/useToolParameters.ts | 10 +- frontend/src/hooks/useTooltipPosition.ts | 12 +- frontend/src/pages/HomePage.tsx | 2 - .../services/enhancedPDFProcessingService.ts | 7 +- frontend/src/services/fileAnalyzer.ts | 4 +- .../src/services/fileProcessingService.ts | 1 - frontend/src/services/fileStorage.ts | 2 +- frontend/src/services/pdfExportService.ts | 8 +- frontend/src/services/pdfWorkerManager.ts | 6 +- frontend/src/services/zipFileService.ts | 4 +- frontend/src/setupTests.ts | 2 +- frontend/src/tests/convert/ConvertE2E.spec.ts | 3 - .../tests/convert/ConvertIntegration.test.tsx | 6 +- .../ConvertSmartDetectionIntegration.test.tsx | 3 +- frontend/src/theme/mantineTheme.ts | 8 +- frontend/src/tools/AddPassword.tsx | 6 +- frontend/src/tools/AddWatermark.tsx | 4 +- frontend/src/tools/Automate.tsx | 3 +- frontend/src/tools/Convert.tsx | 4 +- frontend/src/tools/OCR.tsx | 1 - frontend/src/tools/Split.tsx | 1 - frontend/src/types/fileIdSafety.d.ts | 10 +- frontend/src/utils/automationExecutor.ts | 1 - frontend/src/utils/automationFileProcessor.ts | 2 +- frontend/src/utils/fileResponseUtils.ts | 10 +- frontend/src/utils/thumbnailUtils.ts | 16 +- 102 files changed, 303 insertions(+), 825 deletions(-) delete mode 100644 frontend/src/components/history/FileOperationHistory.tsx diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index d823a6af9..c0f9fd678 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -25,7 +25,18 @@ export default defineConfig( ], "@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": "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) + }, + ], }, } ); diff --git a/frontend/scripts/generate-icons.js b/frontend/scripts/generate-icons.js index 0fd42a4df..d99414b66 100644 --- a/frontend/scripts/generate-icons.js +++ b/frontend/scripts/generate-icons.js @@ -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; } diff --git a/frontend/scripts/generate-licenses.js b/frontend/scripts/generate-licenses.js index 7ab425e19..3daf04d8d 100644 --- a/frontend/scripts/generate-licenses.js +++ b/frontend/scripts/generate-licenses.js @@ -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' }); } diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index dc0001b4e..63ca5c5ec 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -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 = ({ selectedTool }) => { const [isDragging, setIsDragging] = useState(false); const [isMobile, setIsMobile] = useState(false); - const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); - - // Wrapper for storeFile that generates UUID - const storeStirlingFile = 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) => { diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 901eb20ca..8f71f59b0 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -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, StirlingFile } from '../../types/fileContext'; - interface FileEditorProps { - onOpenPageEditor?: (file: StirlingFile) => 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 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 @@ -161,29 +144,9 @@ 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); } - }; - - if (extractionResult.errors.length > 0) { - errors.push(...extractionResult.errors); - } } else { errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); } @@ -213,25 +176,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`); @@ -252,23 +196,6 @@ const FileEditor = ({ } }, [addFiles]); - const selectAll = useCallback(() => { - setSelectedFiles(activeStirlingFileStubs.map(r => r.id)); // Use StirlingFileStub IDs directly - }, [activeStirlingFileStubs, setSelectedFiles]); - - const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]); - - const closeAllFiles = useCallback(() => { - if (activeStirlingFileStubs.length === 0) return; - - // Remove all files from context but keep in storage - const allFileIds = activeStirlingFileStubs.map(record => record.id); - removeFiles(allFileIds, false); // false = keep in storage - - // Clear selections - setSelectedFiles([]); - }, [activeStirlingFileStubs, removeFiles, setSelectedFiles]); - const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; @@ -304,15 +231,6 @@ const FileEditor = ({ setSelectedFiles(newSelection); }, [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[]) => { @@ -378,27 +296,8 @@ const FileEditor = ({ 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 @@ -416,24 +315,6 @@ const FileEditor = ({ } }, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]); - const handleMergeFromHere = useCallback((fileId: FileId) => { - const startIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId); - if (startIndex === -1) return; - - const recordsToMerge = activeStirlingFileStubs.slice(startIndex); - const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as StirlingFile[]; - if (onMergeFiles) { - onMergeFiles(filesToMerge); - } - }, [activeStirlingFileStubs, selectors, onMergeFiles]); - - const handleSplitFile = useCallback((fileId: FileId) => { - const file = selectors.getFile(fileId); - if (file && onOpenPageEditor) { - onOpenPageEditor(file); - } - }, [selectors, onOpenPageEditor]); - const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { if (selectedFiles.length === 0) return; diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index bfeb404c5..7e7370785 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -44,7 +44,6 @@ const FileEditorThumbnail = ({ selectedFiles, onToggleFile, onDeleteFile, - onViewFile, onSetStatus, onReorderFiles, onDownloadFile, diff --git a/frontend/src/components/fileManager/FileDetails.tsx b/frontend/src/components/fileManager/FileDetails.tsx index dcd460644..b1e7e93e3 100644 --- a/frontend/src/components/fileManager/FileDetails.tsx +++ b/frontend/src/components/fileManager/FileDetails.tsx @@ -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'; @@ -11,27 +11,26 @@ interface FileDetailsProps { compact?: boolean; } -const FileDetails: React.FC = ({ +const FileDetails: React.FC = ({ compact = false }) => { const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext(); const { t } = useTranslation(); const [currentFileIndex, setCurrentFileIndex] = useState(0); const [isAnimating, setIsAnimating] = useState(false); - + // 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); - + // Get thumbnail for current file const getCurrentThumbnail = () => { return currentThumbnail; }; - + const handlePrevious = () => { if (isAnimating) return; setIsAnimating(true); @@ -40,7 +39,7 @@ const FileDetails: React.FC = ({ setIsAnimating(false); }, 150); }; - + const handleNext = () => { if (isAnimating) return; setIsAnimating(true); @@ -49,14 +48,14 @@ const FileDetails: React.FC = ({ setIsAnimating(false); }, 150); }; - + // Reset index when selection changes React.useEffect(() => { if (currentFileIndex >= selectedFiles.length) { setCurrentFileIndex(0); } }, [selectedFiles.length, currentFileIndex]); - + if (compact) { return ( = ({ onNext={handleNext} /> - + {/* Section 2: File Details */} - -
); diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index ad81ce463..be926f85d 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -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, @@ -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; diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 07823414a..2c85d8f81 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -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(null); - const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false); - const [showResumeModal, setShowResumeModal] = useState(false); - const [foundDraft, setFoundDraft] = useState(null); - const autoSaveTimer = useRef(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(() => { diff --git a/frontend/src/components/pageEditor/PageEditorControls.tsx b/frontend/src/components/pageEditor/PageEditorControls.tsx index 2a932ae50..3e36b06b6 100644 --- a/frontend/src/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/components/pageEditor/PageEditorControls.tsx @@ -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, diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 0b2782ba1..dfa098af1 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -52,16 +52,13 @@ const PageThumbnail: React.FC = ({ pageRefs, onReorderPages, onTogglePage, - onAnimateReorder, onExecuteCommand, onSetStatus, onSetMovingPage, onDeletePage, createRotateCommand, - createDeleteCommand, createSplitCommand, pdfDocument, - setPdfDocument, splitPositions, onInsertFiles, }: PageThumbnailProps) => { @@ -172,7 +169,7 @@ const PageThumbnail: React.FC = ({ type: 'page', pageNumber: page.pageNumber }), - onDrop: ({ source }) => {} + onDrop: (_) => {} }); (element as any).__dragCleanup = () => { diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts index 3a4d49053..0b7aa00b4 100644 --- a/frontend/src/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -68,7 +68,6 @@ 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[] => { @@ -144,8 +143,6 @@ export function usePageDocument(): PageDocumentHook { }); } - totalPageCount = pages.length; - if (pages.length === 0) { return null; } diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx index 2d9cba640..431c5bded 100644 --- a/frontend/src/components/shared/FileGrid.tsx +++ b/frontend/src/components/shared/FileGrid.tsx @@ -1,5 +1,5 @@ -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"; diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 04fee9b35..0d3c3bee4 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -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'; diff --git a/frontend/src/components/shared/LanguageSelector.tsx b/frontend/src/components/shared/LanguageSelector.tsx index d3a346a8e..f2fddf212 100644 --- a/frontend/src/components/shared/LanguageSelector.tsx +++ b/frontend/src/components/shared/LanguageSelector.tsx @@ -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(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); @@ -54,7 +51,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal }, 200); }; - const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] || + const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] || supportedLanguages['en-GB']; // Trigger animation when dropdown opens @@ -77,8 +74,8 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal } `} - = ({ icon, ...props }) => { // Convert our icon naming convention to the local collection format - const iconName = icon.startsWith('material-symbols:') - ? icon + const iconName = icon.startsWith('material-symbols:') + ? icon : `material-symbols:${icon}`; - + // Development logging (only in dev mode) if (process.env.NODE_ENV === 'development') { const logKey = `icon-${iconName}`; @@ -44,9 +44,9 @@ export const LocalIcon: React.FC = ({ icon, ...props }) => { sessionStorage.setItem(logKey, 'logged'); } } - + // Always render the icon - Iconify will use local if available, CDN if not return ; }; -export default LocalIcon; \ No newline at end of file +export default LocalIcon; diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 8447a891c..142920942 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -3,7 +3,6 @@ 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'; diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index 24f927443..ee0f9b911 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -29,7 +29,7 @@ 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(); diff --git a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx index 8362c8224..3a0d5fe15 100644 --- a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx +++ b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx @@ -1,10 +1,10 @@ /** * ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar - * + * * When a user selects a tool from the All Tools list, this component displays the tool's * icon and name at the top of the navigation bar. It provides a quick way to see which * tool is currently active and offers a back button to return to the All Tools list. - * + * * Features: * - Shows tool icon and name when a tool is selected * - Hover to reveal back arrow for returning to All Tools @@ -26,7 +26,7 @@ interface ActiveToolButtonProps { const NAV_IDS = ['read', 'sign', 'automate']; -const ActiveToolButton: React.FC = ({ activeButton, setActiveButton }) => { +const ActiveToolButton: React.FC = ({ setActiveButton }) => { const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow(); // Determine if the indicator should be visible (do not require selectedTool to be resolved yet) @@ -38,7 +38,6 @@ const ActiveToolButton: React.FC = ({ activeButton, setAc const [indicatorTool, setIndicatorTool] = useState(null); const [indicatorVisible, setIndicatorVisible] = useState(false); const [replayAnim, setReplayAnim] = useState(false); - const [isAnimating, setIsAnimating] = useState(false); const [isBackHover, setIsBackHover] = useState(false); const prevKeyRef = useRef(null); const collapseTimeoutRef = useRef(null); @@ -71,11 +70,9 @@ const ActiveToolButton: React.FC = ({ 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 +81,8 @@ const ActiveToolButton: React.FC = ({ 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 +90,9 @@ const ActiveToolButton: React.FC = ({ 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 } diff --git a/frontend/src/components/tools/SearchResults.tsx b/frontend/src/components/tools/SearchResults.tsx index ee4a8fe5c..dc9fd6af0 100644 --- a/frontend/src/components/tools/SearchResults.tsx +++ b/frontend/src/components/tools/SearchResults.tsx @@ -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 = ({ filteredTools, onSelect } ))} - {/* global spacer to allow scrolling past last row in search mode */} + {/* Global spacer to allow scrolling past last row in search mode */}
); }; export default SearchResults; - - diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx index de95f0b94..98d1d96f3 100644 --- a/frontend/src/components/tools/ToolPanel.tsx +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -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(); diff --git a/frontend/src/components/tools/addPassword/AddPasswordSettings.test.tsx b/frontend/src/components/tools/addPassword/AddPasswordSettings.test.tsx index 92373f60d..b483eca74 100644 --- a/frontend/src/components/tools/addPassword/AddPasswordSettings.test.tsx +++ b/frontend/src/components/tools/addPassword/AddPasswordSettings.test.tsx @@ -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}`); diff --git a/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx b/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx index 96ce6aad5..36ef3ce01 100644 --- a/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx +++ b/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx @@ -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"; diff --git a/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx b/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx index 84bbb296a..04949c27c 100644 --- a/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx +++ b/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx @@ -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 { diff --git a/frontend/src/components/tools/addWatermark/WatermarkWording.tsx b/frontend/src/components/tools/addWatermark/WatermarkWording.tsx index 5278ca332..cee909fca 100644 --- a/frontend/src/components/tools/addWatermark/WatermarkWording.tsx +++ b/frontend/src/components/tools/addWatermark/WatermarkWording.tsx @@ -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"; diff --git a/frontend/src/components/tools/automate/AutomationCreation.tsx b/frontend/src/components/tools/automate/AutomationCreation.tsx index 5dfdc0468..b5e793ccb 100644 --- a/frontend/src/components/tools/automate/AutomationCreation.tsx +++ b/frontend/src/components/tools/automate/AutomationCreation.tsx @@ -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(); diff --git a/frontend/src/components/tools/automate/AutomationEntry.tsx b/frontend/src/components/tools/automate/AutomationEntry.tsx index 7a33bd65e..30c280491 100644 --- a/frontend/src/components/tools/automate/AutomationEntry.tsx +++ b/frontend/src/components/tools/automate/AutomationEntry.tsx @@ -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'; @@ -69,11 +69,11 @@ export default function AutomationEntry({ const toolChain = operations.map((op, index) => ( - 0; - + return shouldShowTooltip ? ( - {boxContent} diff --git a/frontend/src/components/tools/automate/AutomationRun.tsx b/frontend/src/components/tools/automate/AutomationRun.tsx index 1a3cb1263..2d6bda6fc 100644 --- a/frontend/src/components/tools/automate/AutomationRun.tsx +++ b/frontend/src/components/tools/automate/AutomationRun.tsx @@ -20,11 +20,11 @@ export default function AutomationRun({ automation, onComplete, automateOperatio const { selectedFiles } = useFileSelection(); const toolRegistry = useFlatToolRegistry(); const cleanup = useResourceCleanup(); - + // Progress tracking state const [executionSteps, setExecutionSteps] = useState([]); const [currentStepIndex, setCurrentStepIndex] = useState(-1); - + // Use the operation hook's loading state const isExecuting = automateOperation?.isLoading || false; const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null; @@ -74,15 +74,15 @@ export default function AutomationRun({ automation, onComplete, automateOperatio try { // Use the automateOperation.executeOperation to handle file consumption properly 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 )); @@ -95,7 +95,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio }, selectedFiles ); - + // Mark all as completed and reset current step setCurrentStepIndex(-1); console.log(`✅ Automation completed successfully`); @@ -118,20 +118,20 @@ export default function AutomationRun({ automation, onComplete, automateOperatio case EXECUTION_STATUS.ERROR: return ; case EXECUTION_STATUS.RUNNING: - return
; default: - return
; } }; @@ -170,8 +170,8 @@ export default function AutomationRun({ automation, onComplete, automateOperatio {getStepIcon(step)}
-
); -} \ No newline at end of file +} diff --git a/frontend/src/components/tools/automate/ToolConfigurationModal.tsx b/frontend/src/components/tools/automate/ToolConfigurationModal.tsx index b2aa1c975..4fce30b3d 100644 --- a/frontend/src/components/tools/automate/ToolConfigurationModal.tsx +++ b/frontend/src/components/tools/automate/ToolConfigurationModal.tsx @@ -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({}); - 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); - } + onSave(parameters); }; return ( @@ -127,7 +124,6 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, diff --git a/frontend/src/components/tools/changePermissions/ChangePermissionsSettings.tsx b/frontend/src/components/tools/changePermissions/ChangePermissionsSettings.tsx index 1090bd11f..071e27cfd 100644 --- a/frontend/src/components/tools/changePermissions/ChangePermissionsSettings.tsx +++ b/frontend/src/components/tools/changePermissions/ChangePermissionsSettings.tsx @@ -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"; diff --git a/frontend/src/components/tools/ocr/OCRSettings.tsx b/frontend/src/components/tools/ocr/OCRSettings.tsx index b4d9aa766..6009888b9 100644 --- a/frontend/src/components/tools/ocr/OCRSettings.tsx +++ b/frontend/src/components/tools/ocr/OCRSettings.tsx @@ -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'; diff --git a/frontend/src/components/tools/removeCertificateSign/RemoveCertificateSignSettings.tsx b/frontend/src/components/tools/removeCertificateSign/RemoveCertificateSignSettings.tsx index f34e3f2e6..cb1f480d3 100644 --- a/frontend/src/components/tools/removeCertificateSign/RemoveCertificateSignSettings.tsx +++ b/frontend/src/components/tools/removeCertificateSign/RemoveCertificateSignSettings.tsx @@ -8,11 +8,7 @@ interface RemoveCertificateSignSettingsProps { disabled?: boolean; } -const RemoveCertificateSignSettings: React.FC = ({ - parameters, - onParameterChange, // Unused - kept for interface consistency and future extensibility - disabled = false -}) => { +const RemoveCertificateSignSettings: React.FC = (_) => { const { t } = useTranslation(); return ( @@ -24,4 +20,4 @@ const RemoveCertificateSignSettings: React.FC = ({ - parameters, - onParameterChange, - disabled = false -}) => { +const RepairSettings: React.FC = (_) => { const { t } = useTranslation(); return ( @@ -24,4 +20,4 @@ const RepairSettings: React.FC = ({ ); }; -export default RepairSettings; \ No newline at end of file +export default RepairSettings; diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index a083d65ef..354613ecb 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -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'; @@ -28,7 +28,7 @@ const FileStatusIndicator = ({ try { const recentFiles = await loadRecentFiles(); setHasRecentFiles(recentFiles.length > 0); - } catch (error) { + } catch { setHasRecentFiles(false); } }; diff --git a/frontend/src/components/tools/shared/ReviewToolStep.tsx b/frontend/src/components/tools/shared/ReviewToolStep.tsx index 364077e4f..6dcb6fc6c 100644 --- a/frontend/src/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/src/components/tools/shared/ReviewToolStep.tsx @@ -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"; diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index 4201fd82d..2fa8eb2da 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -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'; diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index 83057c13a..84051f426 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -81,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, diff --git a/frontend/src/components/tools/shared/renderToolButtons.tsx b/frontend/src/components/tools/shared/renderToolButtons.tsx index a7535e0e0..4d92d4798 100644 --- a/frontend/src/components/tools/shared/renderToolButtons.tsx +++ b/frontend/src/components/tools/shared/renderToolButtons.tsx @@ -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'; diff --git a/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx b/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx index 87dfef926..11bf7009f 100644 --- a/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx +++ b/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx @@ -8,11 +8,7 @@ interface SingleLargePageSettingsProps { disabled?: boolean; } -const SingleLargePageSettings: React.FC = ({ - parameters, - onParameterChange, - disabled = false -}) => { +const SingleLargePageSettings: React.FC = (_) => { const { t } = useTranslation(); return ( @@ -24,4 +20,4 @@ const SingleLargePageSettings: React.FC = ({ ); }; -export default SingleLargePageSettings; \ No newline at end of file +export default SingleLargePageSettings; diff --git a/frontend/src/components/tools/unlockPdfForms/UnlockPdfFormsSettings.tsx b/frontend/src/components/tools/unlockPdfForms/UnlockPdfFormsSettings.tsx index cc8697d7a..d7ca66f25 100644 --- a/frontend/src/components/tools/unlockPdfForms/UnlockPdfFormsSettings.tsx +++ b/frontend/src/components/tools/unlockPdfForms/UnlockPdfFormsSettings.tsx @@ -8,11 +8,7 @@ interface UnlockPdfFormsSettingsProps { disabled?: boolean; } -const UnlockPdfFormsSettings: React.FC = ({ - parameters, - onParameterChange, // Unused - kept for interface consistency and future extensibility - disabled = false -}) => { +const UnlockPdfFormsSettings: React.FC = (_) => { const { t } = useTranslation(); return ( @@ -24,4 +20,4 @@ const UnlockPdfFormsSettings: React.FC = ({ ); }; -export default UnlockPdfFormsSettings; \ No newline at end of file +export default UnlockPdfFormsSettings; diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index 53d9e98e2..dfcd5dc7d 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -1,19 +1,17 @@ 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"; @@ -142,8 +140,6 @@ export interface ViewerProps { } const Viewer = ({ - sidebarsVisible, - setSidebarsVisible, onClose, previewFile, }: ViewerProps) => { @@ -152,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 @@ -406,7 +396,7 @@ const Viewer = ({ // Start progressive preloading after a short delay setTimeout(() => startProgressivePreload(), 100); } - } catch (error) { + } catch { if (!cancelled) { setPageImages([]); setNumPages(0); diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 80735de58..3c75b2080 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -39,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); @@ -128,20 +127,9 @@ function FileContextInner({ }, [indexedDB]); const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise => { - return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, stateRef, filesRef, dispatch, indexedDB); + 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); diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index abbdfcfbd..5a609e63e 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -1,6 +1,6 @@ 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'; diff --git a/frontend/src/contexts/IndexedDBContext.tsx b/frontend/src/contexts/IndexedDBContext.tsx index dfd2ac5f2..b6a0b6797 100644 --- a/frontend/src/contexts/IndexedDBContext.tsx +++ b/frontend/src/contexts/IndexedDBContext.tsx @@ -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'; @@ -61,7 +61,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() }); diff --git a/frontend/src/contexts/NavigationContext.tsx b/frontend/src/contexts/NavigationContext.tsx index 3b65f01d1..f9e8ba9a5 100644 --- a/frontend/src/contexts/NavigationContext.tsx +++ b/frontend/src/contexts/NavigationContext.tsx @@ -103,7 +103,7 @@ const NavigationActionsContext = createContext = ({ children, enableUrlSync = true }) => { +}> = ({ children }) => { const [state, dispatch] = useReducer(navigationReducer, initialState); const toolRegistry = useFlatToolRegistry(); diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index b16261bc9..4473dc020 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -89,6 +89,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { clearToolSelection: () => void; // Tool Reset Actions + toolResetFunctions: Record 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, diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 3901fabee..552ce0b1f 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -13,8 +13,7 @@ import { 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 { buildQuickKeySet } from './fileSelectors'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -407,7 +406,6 @@ export async function consumeFiles( }); 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 outputStirlingFileStubs.map(({ fileId }) => fileId); } @@ -467,7 +465,6 @@ export async function undoConsumeFiles( inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[], - stateRef: React.MutableRefObject, filesRef: React.MutableRefObject>, dispatch: React.Dispatch, indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise; deleteFile: (fileId: FileId) => Promise } | null @@ -507,7 +504,6 @@ export async function undoConsumeFiles( }); 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); diff --git a/frontend/src/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts index 1907c8fb8..7d7f9b23e 100644 --- a/frontend/src/contexts/file/fileHooks.ts +++ b/frontend/src/contexts/file/fileHooks.ts @@ -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) => { diff --git a/frontend/src/contexts/file/lifecycle.ts b/frontend/src/contexts/file/lifecycle.ts index 901a16943..c65fec127 100644 --- a/frontend/src/contexts/file/lifecycle.ts +++ b/frontend/src/contexts/file/lifecycle.ts @@ -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 } } diff --git a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts index 93b55bc26..53f6f7854 100644 --- a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -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 () => { diff --git a/frontend/src/hooks/tools/automate/useAutomateOperation.ts b/frontend/src/hooks/tools/automate/useAutomateOperation.ts index 112bafbd2..3e51a615f 100644 --- a/frontend/src/hooks/tools/automate/useAutomateOperation.ts +++ b/frontend/src/hooks/tools/automate/useAutomateOperation.ts @@ -3,7 +3,6 @@ import { useCallback } from 'react'; import { executeAutomationSequence } from '../../../utils/automationExecutor'; import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry'; import { AutomateParameters } from '../../../types/automation'; -import { AUTOMATION_CONSTANTS } from '../../../constants/automation'; export function useAutomateOperation() { const toolRegistry = useFlatToolRegistry(); diff --git a/frontend/src/hooks/tools/automate/useSavedAutomations.ts b/frontend/src/hooks/tools/automate/useSavedAutomations.ts index 410ad3217..8ad7a80b6 100644 --- a/frontend/src/hooks/tools/automate/useSavedAutomations.ts +++ b/frontend/src/hooks/tools/automate/useSavedAutomations.ts @@ -44,9 +44,9 @@ export function useSavedAutomations() { const copyFromSuggested = useCallback(async (suggestedAutomation: SuggestedAutomation) => { try { 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': @@ -60,7 +60,7 @@ export function useSavedAutomations() { return 'SettingsIcon'; // Default fallback } }; - + // Convert suggested automation to saved automation format const savedAutomation = { name: suggestedAutomation.name, @@ -68,7 +68,7 @@ export function useSavedAutomations() { icon: getIconKey(suggestedAutomation.icon), operations: suggestedAutomation.operations }; - + await automationStorage.saveAutomation(savedAutomation); // Refresh the list after saving refreshAutomations(); @@ -91,4 +91,4 @@ export function useSavedAutomations() { deleteAutomation, copyFromSuggested }; -} \ No newline at end of file +} diff --git a/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts b/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts index 7250726f1..970c14375 100644 --- a/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts +++ b/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts @@ -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' }); diff --git a/frontend/src/hooks/tools/compress/useCompressOperation.ts b/frontend/src/hooks/tools/compress/useCompressOperation.ts index 8327dd698..c7080048f 100644 --- a/frontend/src/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/src/hooks/tools/compress/useCompressOperation.ts @@ -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'; diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index 6de20282f..56dcbb204 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -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 diff --git a/frontend/src/hooks/tools/convert/useConvertParameters.ts b/frontend/src/hooks/tools/convert/useConvertParameters.ts index 2a7b7d523..74a1bd3a1 100644 --- a/frontend/src/hooks/tools/convert/useConvertParameters.ts +++ b/frontend/src/hooks/tools/convert/useConvertParameters.ts @@ -2,7 +2,6 @@ import { COLOR_TYPES, OUTPUT_OPTIONS, FIT_OPTIONS, - TO_FORMAT_OPTIONS, CONVERSION_MATRIX, type ColorType, type OutputOption, @@ -127,7 +126,7 @@ export const useConvertParameters = (): ConvertParametersHook => { endpointName: getEndpointName, validateFn: validateParameters, }), []); - + const baseHook = useBaseParameters(config); const getEndpoint = () => { @@ -166,7 +165,7 @@ export const useConvertParameters = (): ConvertParametersHook => { if (prev.isSmartDetection === false && prev.smartDetectionType === 'none') { return prev; // No change needed } - + return { ...prev, isSmartDetection: false, @@ -290,13 +289,13 @@ export const useConvertParameters = (): ConvertParametersHook => { // All files are images - use image-to-pdf conversion baseHook.setParameters(prev => { // Only update if something actually changed - if (prev.isSmartDetection === true && - prev.smartDetectionType === 'images' && - prev.fromExtension === 'image' && + if (prev.isSmartDetection === true && + prev.smartDetectionType === 'images' && + prev.fromExtension === 'image' && prev.toExtension === 'pdf') { return prev; // No change needed } - + return { ...prev, isSmartDetection: true, @@ -309,13 +308,13 @@ export const useConvertParameters = (): ConvertParametersHook => { // All files are web files - use html-to-pdf conversion baseHook.setParameters(prev => { // Only update if something actually changed - if (prev.isSmartDetection === true && - prev.smartDetectionType === 'web' && - prev.fromExtension === 'html' && + if (prev.isSmartDetection === true && + prev.smartDetectionType === 'web' && + prev.fromExtension === 'html' && prev.toExtension === 'pdf') { return prev; // No change needed } - + return { ...prev, isSmartDetection: true, @@ -328,13 +327,13 @@ export const useConvertParameters = (): ConvertParametersHook => { // Mixed non-image types - use file-to-pdf conversion baseHook.setParameters(prev => { // Only update if something actually changed - if (prev.isSmartDetection === true && - prev.smartDetectionType === 'mixed' && - prev.fromExtension === 'any' && + if (prev.isSmartDetection === true && + prev.smartDetectionType === 'mixed' && + prev.fromExtension === 'any' && prev.toExtension === 'pdf') { return prev; // No change needed } - + return { ...prev, isSmartDetection: true, diff --git a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts index 798fdc5b4..e208d4479 100644 --- a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts +++ b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts @@ -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', () => { diff --git a/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts b/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts index 106150281..91eed974a 100644 --- a/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts +++ b/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts @@ -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; diff --git a/frontend/src/hooks/tools/repair/useRepairOperation.ts b/frontend/src/hooks/tools/repair/useRepairOperation.ts index 44fcc9b70..d195ee881 100644 --- a/frontend/src/hooks/tools/repair/useRepairOperation.ts +++ b/frontend/src/hooks/tools/repair/useRepairOperation.ts @@ -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; diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 7bfcc3d32..263217e42 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -128,7 +128,7 @@ export const useToolOperation = ( config: ToolOperationConfig ): ToolOperationHook => { const { t } = useTranslation(); - const { addFiles, consumeFiles, undoConsumeFiles, actions: fileActions, selectors } = useFileContext(); + const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext(); // Composed hooks const { state, actions } = useToolState(); @@ -243,7 +243,7 @@ export const useToolOperation = ( // Replace input files with processed files (consumeFiles handles pinning) const inputFileIds: FileId[] = []; const inputStirlingFileStubs: StirlingFileStub[] = []; - + // Build parallel arrays of IDs and records for undo tracking for (const file of validFiles) { const fileId = file.fileId; @@ -320,7 +320,7 @@ export const useToolOperation = ( try { // Undo the consume operation await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds); - + // Clear results and operation tracking resetResults(); diff --git a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts index ef304fa09..35eaec079 100644 --- a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts +++ b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts @@ -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; diff --git a/frontend/src/hooks/tools/split/useSplitOperation.ts b/frontend/src/hooks/tools/split/useSplitOperation.ts index 394fb694d..b18b7c1f5 100644 --- a/frontend/src/hooks/tools/split/useSplitOperation.ts +++ b/frontend/src/hooks/tools/split/useSplitOperation.ts @@ -71,7 +71,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 => { + const responseHandler = useCallback(async (blob: Blob, _originalFiles: File[]): Promise => { // Split operations return ZIP files with multiple PDF pages return await extractZipFiles(blob); }, [extractZipFiles]); diff --git a/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts b/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts index d47800b34..faaeae428 100644 --- a/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts +++ b/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts @@ -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; diff --git a/frontend/src/hooks/useCookieConsent.ts b/frontend/src/hooks/useCookieConsent.ts index 3eacaa7bd..dd00f4396 100644 --- a/frontend/src/hooks/useCookieConsent.ts +++ b/frontend/src/hooks/useCookieConsent.ts @@ -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) { diff --git a/frontend/src/hooks/useEndpointConfig.ts b/frontend/src/hooks/useEndpointConfig.ts index 5419f3506..7516826ed 100644 --- a/frontend/src/hooks/useEndpointConfig.ts +++ b/frontend/src/hooks/useEndpointConfig.ts @@ -19,17 +19,17 @@ export function useEndpointEnabled(endpoint: string): { setLoading(false); return; } - + try { setLoading(true); setError(null); - + const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`); - + if (!response.ok) { throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`); } - + const isEnabled: boolean = await response.json(); setEnabled(isEnabled); } catch (err) { @@ -72,27 +72,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { setLoading(false); return; } - + try { setLoading(true); setError(null); - + // Use batch API for efficiency const endpointsParam = endpoints.join(','); - + const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`); - + if (!response.ok) { throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`); } - + const statusMap: Record = await response.json(); setEndpointStatus(statusMap); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; setError(errorMessage); console.error('Failed to check multiple endpoints:', err); - + // Fallback: assume all endpoints are disabled on error const fallbackStatus = endpoints.reduce((acc, endpoint) => { acc[endpoint] = false; @@ -105,7 +105,6 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { }; useEffect(() => { - const endpointsKey = endpoints.join(','); fetchAllEndpointStatuses(); }, [endpoints.join(',')]); // Re-run when endpoints array changes @@ -115,4 +114,4 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { error, refetch: fetchAllEndpointStatuses, }; -} \ No newline at end of file +} diff --git a/frontend/src/hooks/useEnhancedProcessedFiles.ts b/frontend/src/hooks/useEnhancedProcessedFiles.ts index ebdff4bf5..2f0ab923d 100644 --- a/frontend/src/hooks/useEnhancedProcessedFiles.ts +++ b/frontend/src/hooks/useEnhancedProcessedFiles.ts @@ -49,7 +49,7 @@ export function useEnhancedProcessedFiles( // Process files when activeFiles changes useEffect(() => { console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files'); - + if (activeFiles.length === 0) { console.log('useEnhancedProcessedFiles: No active files, clearing processed cache'); setProcessedFiles(new Map()); @@ -60,15 +60,15 @@ export function useEnhancedProcessedFiles( const processFiles = async () => { const newProcessedFiles = new Map(); - + for (const file of activeFiles) { // Generate hash for this file const fileHash = await FileHasher.generateHybridHash(file); fileHashMapRef.current.set(file, fileHash); - + // First, check if we have this exact File object cached let existing = processedFiles.get(file); - + // If not found by File object, try to find by hash in case File was recreated if (!existing) { for (const [cachedFile, processed] of processedFiles.entries()) { @@ -79,7 +79,7 @@ export function useEnhancedProcessedFiles( } } } - + if (existing) { newProcessedFiles.set(file, existing); continue; @@ -94,11 +94,11 @@ export function useEnhancedProcessedFiles( console.error(`Failed to start processing for ${file.name}:`, error); } } - + // Only update if the content actually changed const hasChanged = newProcessedFiles.size !== processedFiles.size || Array.from(newProcessedFiles.keys()).some(file => !processedFiles.has(file)); - + if (hasChanged) { setProcessedFiles(newProcessedFiles); } @@ -112,20 +112,20 @@ export function useEnhancedProcessedFiles( const checkForCompletedFiles = async () => { let hasNewFiles = false; const updatedFiles = new Map(processedFiles); - + // Generate file keys for all files first const fileKeyPromises = activeFiles.map(async (file) => ({ file, key: await FileHasher.generateHybridHash(file) })); - + const fileKeyPairs = await Promise.all(fileKeyPromises); - + for (const { file, key } of fileKeyPairs) { // Only check files that don't have processed results yet if (!updatedFiles.has(file)) { const processingState = processingStates.get(key); - + // Check for both processing and recently completed files // This ensures we catch completed files before they're cleaned up if (processingState?.status === 'processing' || processingState?.status === 'completed') { @@ -135,13 +135,13 @@ export function useEnhancedProcessedFiles( updatedFiles.set(file, processed); hasNewFiles = true; } - } catch (error) { + } catch { // Ignore errors in completion check } } } } - + if (hasNewFiles) { setProcessedFiles(updatedFiles); } @@ -158,11 +158,11 @@ export function useEnhancedProcessedFiles( const currentFiles = new Set(activeFiles); const previousFiles = Array.from(processedFiles.keys()); const removedFiles = previousFiles.filter(file => !currentFiles.has(file)); - + if (removedFiles.length > 0) { // Clean up processing service cache enhancedPDFProcessingService.cleanup(removedFiles); - + // Update local state setProcessedFiles(prev => { const updated = new Map(); @@ -179,10 +179,10 @@ export function useEnhancedProcessedFiles( // Calculate derived state const isProcessing = processingStates.size > 0; const hasProcessingErrors = Array.from(processingStates.values()).some(state => state.status === 'error'); - + // Calculate overall progress const processingProgress = calculateProcessingProgress(processingStates); - + // Get cache stats and metrics const cacheStats = enhancedPDFProcessingService.getCacheStats(); const metrics = enhancedPDFProcessingService.getMetrics(); @@ -192,7 +192,7 @@ export function useEnhancedProcessedFiles( cancelProcessing: (fileKey: string) => { enhancedPDFProcessingService.cancelProcessing(fileKey); }, - + retryProcessing: async (file: File) => { try { await enhancedPDFProcessingService.processFile(file, config); @@ -200,7 +200,7 @@ export function useEnhancedProcessedFiles( console.error(`Failed to retry processing for ${file.name}:`, error); } }, - + clearCache: () => { enhancedPDFProcessingService.clearAll(); } @@ -279,7 +279,7 @@ export function useEnhancedProcessedFile( }; } { const result = useEnhancedProcessedFiles(file ? [file] : [], config); - + const processedFile = file ? result.processedFiles.get(file) || null : null; // Note: This is async but we can't await in hook return - consider refactoring if needed const fileKey = file ? '' : ''; @@ -309,4 +309,4 @@ export function useEnhancedProcessedFile( canRetry, actions }; -} \ No newline at end of file +} diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index 8df1e2754..f3dedf5e4 100644 --- a/frontend/src/hooks/useFileManager.ts +++ b/frontend/src/hooks/useFileManager.ts @@ -1,7 +1,6 @@ import { useState, useCallback } from 'react'; import { useIndexedDB } from '../contexts/IndexedDBContext'; import { FileMetadata } from '../types/file'; -import { generateThumbnailForFile } from '../utils/thumbnailUtils'; import { FileId } from '../types/fileContext'; export const useFileManager = () => { diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index a6251db3c..cd497561b 100644 --- a/frontend/src/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/hooks/useIndexedDBThumbnail.ts @@ -4,20 +4,6 @@ import { useIndexedDB } from "../contexts/IndexedDBContext"; import { generateThumbnailForFile } from "../utils/thumbnailUtils"; import { FileId } from "../types/fileContext"; -/** - * Calculate optimal scale for thumbnail generation - * Ensures high quality while preventing oversized renders - */ -function calculateThumbnailScale(pageViewport: { width: number; height: number }): number { - const maxWidth = 400; // Max thumbnail width - const maxHeight = 600; // Max thumbnail height - - const scaleX = maxWidth / pageViewport.width; - const scaleY = maxHeight / pageViewport.height; - - // Don't upscale, only downscale if needed - return Math.min(scaleX, scaleY, 1.0); -} /** * Hook for IndexedDB-aware thumbnail loading @@ -67,7 +53,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { const thumbnail = await generateThumbnailForFile(fileObject); if (!cancelled) { setThumb(thumbnail); - + // Save thumbnail to IndexedDB for persistence if (file.id && indexedDB && thumbnail) { try { diff --git a/frontend/src/hooks/usePdfSignatureDetection.ts b/frontend/src/hooks/usePdfSignatureDetection.ts index b14c1a637..77b7f79ac 100644 --- a/frontend/src/hooks/usePdfSignatureDetection.ts +++ b/frontend/src/hooks/usePdfSignatureDetection.ts @@ -1,5 +1,4 @@ import { useState, useEffect } from 'react'; -import * as pdfjsLib from 'pdfjs-dist'; import { pdfWorkerManager } from '../services/pdfWorkerManager'; import { StirlingFile } from '../types/fileContext'; @@ -26,7 +25,7 @@ export const usePdfSignatureDetection = (files: StirlingFile[]): PdfSignatureDet for (const file of files) { const arrayBuffer = await file.arrayBuffer(); - + try { const pdf = await pdfWorkerManager.createDocument(arrayBuffer); @@ -42,7 +41,7 @@ export const usePdfSignatureDetection = (files: StirlingFile[]): PdfSignatureDet if (foundSignature) break; } - + // Clean up PDF document using worker manager pdfWorkerManager.destroyDocument(pdf); } catch (error) { @@ -66,4 +65,4 @@ export const usePdfSignatureDetection = (files: StirlingFile[]): PdfSignatureDet hasDigitalSignatures, isChecking }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useThumbnailGeneration.ts b/frontend/src/hooks/useThumbnailGeneration.ts index 310634045..6a22fbcc9 100644 --- a/frontend/src/hooks/useThumbnailGeneration.ts +++ b/frontend/src/hooks/useThumbnailGeneration.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; import { createQuickKey } from '../types/fileContext'; import { FileId } from '../types/file'; diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index f73f458db..3239cbaaa 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy"; @@ -20,15 +20,6 @@ export const useToolManagement = (): ToolManagementResult => { // Build endpoints list from registry entries with fallback to legacy mapping const baseRegistry = useFlatToolRegistry(); - const registryDerivedEndpoints = useMemo(() => { - const endpointsByTool: Record = {}; - Object.entries(baseRegistry).forEach(([key, entry]) => { - if (entry.endpoints && entry.endpoints.length > 0) { - endpointsByTool[key] = entry.endpoints; - } - }); - return endpointsByTool; - }, [baseRegistry]); const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]); const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); diff --git a/frontend/src/hooks/useToolParameters.ts b/frontend/src/hooks/useToolParameters.ts index d6eae6d8b..1afd66835 100644 --- a/frontend/src/hooks/useToolParameters.ts +++ b/frontend/src/hooks/useToolParameters.ts @@ -10,8 +10,8 @@ type ToolParameterValues = Record; * Register tool parameters and get current values */ export function useToolParameters( - toolName: string, - parameters: Record + _toolName: string, + _parameters: Record ): [ToolParameterValues, (updates: Partial) => void] { // Return empty values and noop updater @@ -30,9 +30,9 @@ export function useToolParameter( definition: any ): [T, (value: T) => void] { const [allParams, updateParams] = useToolParameters(toolName, { [paramName]: definition }); - + const value = allParams[paramName] as T; - + const setValue = useCallback((newValue: T) => { updateParams({ [paramName]: newValue }); }, [paramName, updateParams]); @@ -48,4 +48,4 @@ export function useGlobalParameters() { const updateParameters = useCallback(() => {}, []); return [currentValues, updateParameters]; -} \ No newline at end of file +} diff --git a/frontend/src/hooks/useTooltipPosition.ts b/frontend/src/hooks/useTooltipPosition.ts index 3651c1d47..51ef2e9c3 100644 --- a/frontend/src/hooks/useTooltipPosition.ts +++ b/frontend/src/hooks/useTooltipPosition.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { clamp } from '../utils/genericUtils'; import { getSidebarInfo } from '../utils/sidebarUtils'; import { SidebarRefs, SidebarState } from '../types/sidebar'; @@ -65,10 +65,10 @@ export function useTooltipPosition({ sidebarRefs?: SidebarRefs; sidebarState?: SidebarState; }): PositionState { - const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({ - top: 0, - left: 0, - arrowOffset: null + const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({ + top: 0, + left: 0, + arrowOffset: null }); const [positionReady, setPositionReady] = useState(false); @@ -174,4 +174,4 @@ export function useTooltipPosition({ }, [open, sidebarLeft, position, gap, sidebarTooltip]); return { coords, positionReady }; -} \ No newline at end of file +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 961662123..26d190dfa 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,4 +1,3 @@ -import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useToolWorkflow } from "../contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; @@ -11,7 +10,6 @@ import Workbench from "../components/layout/Workbench"; import QuickAccessBar from "../components/shared/QuickAccessBar"; import RightRail from "../components/shared/RightRail"; import FileManager from "../components/FileManager"; -import Footer from "../components/shared/Footer"; export default function HomePage() { diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index 2b6b18c8c..bee6e200a 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -1,5 +1,4 @@ -import * as pdfjsLib from 'pdfjs-dist'; -import { ProcessedFile, ProcessingState, PDFPage, ProcessingStrategy, ProcessingConfig, ProcessingMetrics } from '../types/processing'; +import { ProcessedFile, ProcessingState, PDFPage, ProcessingConfig, ProcessingMetrics } from '../types/processing'; import { ProcessingCache } from './processingCache'; import { FileHasher } from '../utils/fileHash'; import { FileAnalyzer } from './fileAnalyzer'; @@ -355,7 +354,7 @@ export class EnhancedPDFProcessingService { */ private async processMetadataOnly( file: File, - config: ProcessingConfig, + _config: ProcessingConfig, state: ProcessingState ): Promise { const arrayBuffer = await file.arrayBuffer(); @@ -510,7 +509,7 @@ export class EnhancedPDFProcessingService { */ clearAllProcessing(): void { // Cancel all ongoing processing - this.processing.forEach((state, key) => { + this.processing.forEach((state) => { if (state.cancellationToken) { state.cancellationToken.abort(); } diff --git a/frontend/src/services/fileAnalyzer.ts b/frontend/src/services/fileAnalyzer.ts index 1705bb54e..5655902a0 100644 --- a/frontend/src/services/fileAnalyzer.ts +++ b/frontend/src/services/fileAnalyzer.ts @@ -128,7 +128,7 @@ export class FileAnalyzer { * Estimate processing time based on file characteristics and strategy */ private static estimateProcessingTime( - fileSize: number, + _fileSize: number, pageCount: number = 0, strategy: ProcessingStrategy ): number { @@ -234,7 +234,7 @@ export class FileAnalyzer { const headerString = String.fromCharCode(...headerBytes); return headerString.startsWith('%PDF-'); - } catch (error) { + } catch { return false; } } diff --git a/frontend/src/services/fileProcessingService.ts b/frontend/src/services/fileProcessingService.ts index c109cff1f..be822b846 100644 --- a/frontend/src/services/fileProcessingService.ts +++ b/frontend/src/services/fileProcessingService.ts @@ -4,7 +4,6 @@ * Called when files are added to FileContext, before any view sees them */ -import * as pdfjsLib from 'pdfjs-dist'; import { generateThumbnailForFile } from '../utils/thumbnailUtils'; import { pdfWorkerManager } from './pdfWorkerManager'; import { FileId } from '../types/file'; diff --git a/frontend/src/services/fileStorage.ts b/frontend/src/services/fileStorage.ts index e500f54b5..e6dbbb464 100644 --- a/frontend/src/services/fileStorage.ts +++ b/frontend/src/services/fileStorage.ts @@ -496,7 +496,7 @@ class FileStorageService { async updateThumbnail(id: FileId, thumbnail: string): Promise { const db = await this.getDatabase(); - return new Promise((resolve, reject) => { + return new Promise((resolve, _reject) => { try { const transaction = db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index c0d6929fd..aa20f4cfc 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -31,7 +31,7 @@ export class PDFExportService { const originalPDFBytes = await pdfDocument.file.arrayBuffer(); const sourceDoc = await PDFLibDocument.load(originalPDFBytes); const blob = await this.createSingleDocument(sourceDoc, pagesToExport); - const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false); + const exportFilename = this.generateFilename(filename || pdfDocument.name); return { blob, filename: exportFilename }; } catch (error) { @@ -62,7 +62,7 @@ export class PDFExportService { } const blob = await this.createMultiSourceDocument(sourceFiles, pagesToExport); - const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false); + const exportFilename = this.generateFilename(filename || pdfDocument.name); return { blob, filename: exportFilename }; } catch (error) { @@ -183,7 +183,7 @@ export class PDFExportService { /** * Generate appropriate filename for export */ - private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string { + private generateFilename(originalName: string): string { const baseName = originalName.replace(/\.pdf$/i, ''); return `${baseName}.pdf`; } @@ -210,7 +210,7 @@ export class PDFExportService { /** * Download multiple files as a ZIP */ - async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise { + async downloadAsZip(blobs: Blob[], filenames: string[]): Promise { blobs.forEach((blob, index) => { setTimeout(() => { this.downloadFile(blob, filenames[index]); diff --git a/frontend/src/services/pdfWorkerManager.ts b/frontend/src/services/pdfWorkerManager.ts index 57fc841e0..dda434049 100644 --- a/frontend/src/services/pdfWorkerManager.ts +++ b/frontend/src/services/pdfWorkerManager.ts @@ -93,7 +93,7 @@ class PDFWorkerManager { if (loadingTask) { try { loadingTask.destroy(); - } catch (destroyError) { + } catch { // Ignore errors } } @@ -110,7 +110,7 @@ class PDFWorkerManager { pdf.destroy(); this.activeDocuments.delete(pdf); this.workerCount = Math.max(0, this.workerCount - 1); - } catch (error) { + } catch { // Still remove from tracking even if destroy failed this.activeDocuments.delete(pdf); this.workerCount = Math.max(0, this.workerCount - 1); @@ -166,7 +166,7 @@ class PDFWorkerManager { this.activeDocuments.forEach(pdf => { try { pdf.destroy(); - } catch (error) { + } catch { // Ignore errors } }); diff --git a/frontend/src/services/zipFileService.ts b/frontend/src/services/zipFileService.ts index 0b706cd96..872157d51 100644 --- a/frontend/src/services/zipFileService.ts +++ b/frontend/src/services/zipFileService.ts @@ -277,7 +277,7 @@ export class ZipFileService { bytes[2] === 0x44 && // D bytes[3] === 0x46 && // F bytes[4] === 0x2D; // - - } catch (error) { + } catch { return false; } } @@ -324,7 +324,7 @@ export class ZipFileService { await zip.loadAsync(file); // Check if any files are encrypted - for (const [filename, zipEntry] of Object.entries(zip.files)) { + for (const [_filename, zipEntry] of Object.entries(zip.files)) { if (zipEntry.options?.compression === 'STORE' && getData(zipEntry)?.compressedSize === 0) { // This might indicate encryption, but JSZip doesn't provide direct encryption detection // We'll handle this in the extraction phase diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 3b406a91e..b640be3b6 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -63,7 +63,7 @@ for (let i = 0; i < 32; i++) { Object.defineProperty(globalThis, 'crypto', { value: { subtle: { - digest: vi.fn().mockImplementation(async (algorithm: string, data: any) => { + digest: vi.fn().mockImplementation(async (_algorithm: string, _data: any) => { // Always return the mock hash buffer regardless of input return mockHashBuffer.slice(); }), diff --git a/frontend/src/tests/convert/ConvertE2E.spec.ts b/frontend/src/tests/convert/ConvertE2E.spec.ts index 60e2c4849..5e250030e 100644 --- a/frontend/src/tests/convert/ConvertE2E.spec.ts +++ b/frontend/src/tests/convert/ConvertE2E.spec.ts @@ -17,7 +17,6 @@ import * as fs from 'fs'; // Test configuration const BASE_URL = process.env.BASE_URL || 'http://localhost:5173'; -const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8080'; /** * Resolves test fixture paths dynamically based on current working directory. @@ -266,7 +265,6 @@ async function testConversion(page: Page, conversion: ConversionEndpoint) { } // Discover conversions at module level before tests are defined -let allConversions: ConversionEndpoint[] = []; let availableConversions: ConversionEndpoint[] = []; let unavailableConversions: ConversionEndpoint[] = []; @@ -275,7 +273,6 @@ let unavailableConversions: ConversionEndpoint[] = []; try { availableConversions = await conversionDiscovery.getAvailableConversions(); unavailableConversions = await conversionDiscovery.getUnavailableConversions(); - allConversions = [...availableConversions, ...unavailableConversions]; } catch (error) { console.error('Failed to discover conversions during module load:', error); } diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index 4efb41d7e..bf2c46662 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { describe, test, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react'; import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation'; import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameters'; import { FileContextProvider } from '../../contexts/FileContext'; @@ -53,10 +53,6 @@ vi.mock('../../services/thumbnailGenerationService', () => ({ })); // Create realistic test files -const createTestFile = (name: string, content: string, type: string): File => { - return new File([content], name, { type }); -}; - const createPDFFile = (): StirlingFile => { const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF'; return createTestStirlingFile('test.pdf', pdfContent, 'application/pdf'); diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx index 2904135e0..52826ce3f 100644 --- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -15,7 +15,6 @@ import axios from 'axios'; import { detectFileExtension } from '../../utils/fileUtils'; import { FIT_OPTIONS } from '../../constants/convertConstants'; import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers'; -import { StirlingFile } from '../../types/fileContext'; // Mock axios vi.mock('axios'); @@ -507,7 +506,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { describe('Real File Extension Detection', () => { test('should correctly detect various file extensions', async () => { - const { result } = renderHook(() => useConvertParameters(), { + renderHook(() => useConvertParameters(), { wrapper: TestWrapper }); diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index 2cd70d645..b7cd70a18 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -75,7 +75,7 @@ export const mantineTheme = createTheme({ }, variants: { // Custom button variant for PDF tools - pdfTool: (theme: any) => ({ + pdfTool: (_theme: any) => ({ root: { backgroundColor: 'var(--bg-surface)', border: '1px solid var(--border-default)', @@ -108,7 +108,7 @@ export const mantineTheme = createTheme({ }, }, Textarea: { - styles: (theme: any) => ({ + styles: (_theme: any) => ({ input: { backgroundColor: 'var(--bg-surface)', borderColor: 'var(--border-default)', @@ -126,7 +126,7 @@ export const mantineTheme = createTheme({ }, TextInput: { - styles: (theme: any) => ({ + styles: (_theme: any) => ({ input: { backgroundColor: 'var(--bg-surface)', borderColor: 'var(--border-default)', @@ -144,7 +144,7 @@ export const mantineTheme = createTheme({ }, PasswordInput: { - styles: (theme: any) => ({ + styles: (_theme: any) => ({ input: { backgroundColor: 'var(--bg-surface)', borderColor: 'var(--border-default)', diff --git a/frontend/src/tools/AddPassword.tsx b/frontend/src/tools/AddPassword.tsx index adbc25b5a..c29491437 100644 --- a/frontend/src/tools/AddPassword.tsx +++ b/frontend/src/tools/AddPassword.tsx @@ -1,15 +1,14 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileSelection } from "../contexts/FileContext"; -import { useNavigationActions } from "../contexts/NavigationContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings"; import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings"; -import { useAddPasswordParameters, defaultParameters } from "../hooks/tools/addPassword/useAddPasswordParameters"; +import { useAddPasswordParameters } from "../hooks/tools/addPassword/useAddPasswordParameters"; import { useAddPasswordOperation } from "../hooks/tools/addPassword/useAddPasswordOperation"; import { useAddPasswordTips } from "../components/tooltips/useAddPasswordTips"; import { useAddPasswordPermissionsTips } from "../components/tooltips/useAddPasswordPermissionsTips"; @@ -17,7 +16,6 @@ import { BaseToolProps, ToolComponent } from "../types/tool"; const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { actions } = useNavigationActions(); const { selectedFiles } = useFileSelection(); const [collapsedPermissions, setCollapsedPermissions] = useState(true); diff --git a/frontend/src/tools/AddWatermark.tsx b/frontend/src/tools/AddWatermark.tsx index 7065b2a5b..3bafbd329 100644 --- a/frontend/src/tools/AddWatermark.tsx +++ b/frontend/src/tools/AddWatermark.tsx @@ -1,8 +1,7 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileSelection } from "../contexts/FileContext"; -import { useNavigationActions } from "../contexts/NavigationContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -25,7 +24,6 @@ import { BaseToolProps, ToolComponent } from "../types/tool"; const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { actions } = useNavigationActions(); const { selectedFiles } = useFileSelection(); const [collapsedType, setCollapsedType] = useState(false); diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx index 589c38678..db89db162 100644 --- a/frontend/src/tools/Automate.tsx +++ b/frontend/src/tools/Automate.tsx @@ -1,6 +1,5 @@ -import React, { useState, useMemo, useEffect } from "react"; +import React, { useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useFileContext } from "../contexts/FileContext"; import { useFileSelection } from "../contexts/FileContext"; import { useNavigationActions } from "../contexts/NavigationContext"; import { useToolWorkflow } from "../contexts/ToolWorkflowContext"; diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx index 5ced39670..05fd87531 100644 --- a/frontend/src/tools/Convert.tsx +++ b/frontend/src/tools/Convert.tsx @@ -1,8 +1,7 @@ -import React, { useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileState, useFileSelection } from "../contexts/FileContext"; -import { useNavigationActions } from "../contexts/NavigationContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -15,7 +14,6 @@ import { BaseToolProps, ToolComponent } from "../types/tool"; const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { selectors } = useFileState(); - const { actions } = useNavigationActions(); const activeFiles = selectors.getFiles(); const { selectedFiles } = useFileSelection(); const scrollContainerRef = useRef(null); diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index fcfb96841..c8a30fea4 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileSelection } from "../contexts/FileContext"; -import { useNavigationActions } from "../contexts/NavigationContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index 6a0cef697..f22ee9159 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -1,4 +1,3 @@ -import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; import SplitSettings from "../components/tools/split/SplitSettings"; diff --git a/frontend/src/types/fileIdSafety.d.ts b/frontend/src/types/fileIdSafety.d.ts index 08888d4c0..13aac82dd 100644 --- a/frontend/src/types/fileIdSafety.d.ts +++ b/frontend/src/types/fileIdSafety.d.ts @@ -2,19 +2,19 @@ * Type safety declarations to prevent file.name/UUID confusion */ -import { FileId, StirlingFile, OperationType, FileOperation } from './fileContext'; +import { FileId, StirlingFile } from './fileContext'; declare global { namespace FileIdSafety { // Mark functions that should never accept file.name as parameters - type SafeFileIdFunction any> = T extends (...args: infer P) => infer R + type SafeFileIdFunction any> = T extends (...args: infer P) => infer _R ? P extends readonly [string, ...any[]] ? never // Reject string parameters in first position for FileId functions : T : T; // Mark functions that should only accept StirlingFile, not regular File - type StirlingFileOnlyFunction any> = T extends (...args: infer P) => infer R + type StirlingFileOnlyFunction any> = T extends (...args: infer P) => infer _R ? P extends readonly [File, ...any[]] ? never // Reject File parameters in first position for StirlingFile functions : T @@ -38,7 +38,7 @@ declare module '../contexts/FileContext' { addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise; // Returns StirlingFile consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise; // Returns StirlingFile } - + export interface StrictFileContextSelectors { getFile: (id: FileId) => StirlingFile | undefined; // Returns StirlingFile getFiles: (ids?: FileId[]) => StirlingFile[]; // Returns StirlingFile[] @@ -46,4 +46,4 @@ declare module '../contexts/FileContext' { } } -export {}; \ No newline at end of file +export {}; diff --git a/frontend/src/utils/automationExecutor.ts b/frontend/src/utils/automationExecutor.ts index 124f065a7..3feb9b412 100644 --- a/frontend/src/utils/automationExecutor.ts +++ b/frontend/src/utils/automationExecutor.ts @@ -1,6 +1,5 @@ import axios from 'axios'; import { ToolRegistry } from '../data/toolsTaxonomy'; -import { AutomationConfig, AutomationExecutionCallbacks } from '../types/automation'; import { AUTOMATION_CONSTANTS } from '../constants/automation'; import { AutomationFileProcessor } from './automationFileProcessor'; import { ResourceManager } from './resourceManager'; diff --git a/frontend/src/utils/automationFileProcessor.ts b/frontend/src/utils/automationFileProcessor.ts index 45abbaafc..d81dd3a1b 100644 --- a/frontend/src/utils/automationFileProcessor.ts +++ b/frontend/src/utils/automationFileProcessor.ts @@ -2,7 +2,7 @@ * File processing utilities specifically for automation workflows */ -import axios, { AxiosResponse } from 'axios'; +import axios from 'axios'; import { zipFileService } from '../services/zipFileService'; import { ResourceManager } from './resourceManager'; import { AUTOMATION_CONSTANTS } from '../constants/automation'; diff --git a/frontend/src/utils/fileResponseUtils.ts b/frontend/src/utils/fileResponseUtils.ts index 472cccb05..659c2948d 100644 --- a/frontend/src/utils/fileResponseUtils.ts +++ b/frontend/src/utils/fileResponseUtils.ts @@ -11,11 +11,11 @@ export const getFilenameFromHeaders = (contentDisposition: string = ''): string const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); if (match && match[1]) { const filename = match[1].replace(/['"]/g, ''); - + // Decode URL-encoded characters (e.g., %20 -> space) try { return decodeURIComponent(filename); - } catch (error) { + } catch { // If decoding fails, return the original filename return filename; } @@ -37,9 +37,9 @@ export const createFileFromApiResponse = ( ): File => { const contentType = headers?.['content-type'] || 'application/octet-stream'; const contentDisposition = headers?.['content-disposition'] || ''; - + const filename = getFilenameFromHeaders(contentDisposition) || fallbackFilename; const blob = new Blob([responseData], { type: contentType }); - + return new File([blob], filename, { type: contentType }); -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index e4a48f9fd..5f4cac3e6 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -346,12 +346,12 @@ export async function generateThumbnailForFile(file: File): Promise { // Handle PDF files if (file.type.startsWith('application/pdf')) { const scale = calculateScaleFromFileSize(file.size); - + // Only read first 2MB for thumbnail generation to save memory const chunkSize = 2 * 1024 * 1024; // 2MB const chunk = file.slice(0, Math.min(chunkSize, file.size)); const arrayBuffer = await chunk.arrayBuffer(); - + try { return await generatePDFThumbnail(arrayBuffer, file, scale); } catch (error) { @@ -361,7 +361,7 @@ export async function generateThumbnailForFile(file: File): Promise { // Try with full file instead of chunk const fullArrayBuffer = await file.arrayBuffer(); return await generatePDFThumbnail(fullArrayBuffer, file, scale); - } catch (fullFileError) { + } catch { console.warn(`Full file PDF processing also failed for ${file.name} - using placeholder`); return generatePlaceholderThumbnail(file); } @@ -392,11 +392,11 @@ export async function generateThumbnailWithMetadata(file: File): Promise Date: Fri, 5 Sep 2025 12:25:30 +0100 Subject: [PATCH 12/13] V2 flatten (#4358) # Description of Changes --- ## 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 Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> --- ADDING_TOOLS.md | 300 ++++++++++++++++++ CLAUDE.md | 1 + .../public/locales/en-GB/translation.json | 43 ++- .../tools/flatten/FlattenSettings.tsx | 35 ++ .../src/components/tooltips/useFlattenTips.ts | 34 ++ .../src/data/useTranslatedToolRegistry.tsx | 9 +- .../tools/flatten/useFlattenOperation.ts | 33 ++ .../tools/flatten/useFlattenParameters.ts | 19 ++ frontend/src/tools/Flatten.tsx | 62 ++++ 9 files changed, 534 insertions(+), 2 deletions(-) create mode 100644 ADDING_TOOLS.md create mode 100644 frontend/src/components/tools/flatten/FlattenSettings.tsx create mode 100644 frontend/src/components/tooltips/useFlattenTips.ts create mode 100644 frontend/src/hooks/tools/flatten/useFlattenOperation.ts create mode 100644 frontend/src/hooks/tools/flatten/useFlattenParameters.ts create mode 100644 frontend/src/tools/Flatten.tsx diff --git a/ADDING_TOOLS.md b/ADDING_TOOLS.md new file mode 100644 index 000000000..ef1501bfc --- /dev/null +++ b/ADDING_TOOLS.md @@ -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: , + 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 `
` 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 diff --git a/CLAUDE.md b/CLAUDE.md index be4e92201..a806d0098 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 637ab59e1..5f61f7544 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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", diff --git a/frontend/src/components/tools/flatten/FlattenSettings.tsx b/frontend/src/components/tools/flatten/FlattenSettings.tsx new file mode 100644 index 000000000..8386ad493 --- /dev/null +++ b/frontend/src/components/tools/flatten/FlattenSettings.tsx @@ -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: (key: K, value: FlattenParameters[K]) => void; + disabled?: boolean; +} + +const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: FlattenSettingsProps) => { + const { t } = useTranslation(); + + return ( + + + onParameterChange('flattenOnlyForms', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('flatten.options.flattenOnlyForms', 'Flatten only forms')} + + {t('flatten.options.flattenOnlyForms.desc', 'Only flatten form fields, leaving other interactive elements intact')} + +
+ } + /> +
+
+ ); +}; + +export default FlattenSettings; \ No newline at end of file diff --git a/frontend/src/components/tooltips/useFlattenTips.ts b/frontend/src/components/tooltips/useFlattenTips.ts new file mode 100644 index 000000000..a60e5a4bc --- /dev/null +++ b/frontend/src/components/tooltips/useFlattenTips.ts @@ -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") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 68883fe92..f4d0f444c 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -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: , 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: , diff --git a/frontend/src/hooks/tools/flatten/useFlattenOperation.ts b/frontend/src/hooks/tools/flatten/useFlattenOperation.ts new file mode 100644 index 000000000..82eaba258 --- /dev/null +++ b/frontend/src/hooks/tools/flatten/useFlattenOperation.ts @@ -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({ + ...flattenOperationConfig, + filePrefix: t('flatten.filenamePrefix', 'flattened') + '_', + getErrorMessage: createStandardErrorHandler(t('flatten.error.failed', 'An error occurred while flattening the PDF.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/flatten/useFlattenParameters.ts b/frontend/src/hooks/tools/flatten/useFlattenParameters.ts new file mode 100644 index 000000000..98c5b9655 --- /dev/null +++ b/frontend/src/hooks/tools/flatten/useFlattenParameters.ts @@ -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; + +export const useFlattenParameters = (): FlattenParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'flatten', + }); +}; \ No newline at end of file diff --git a/frontend/src/tools/Flatten.tsx b/frontend/src/tools/Flatten.tsx new file mode 100644 index 000000000..691a733f9 --- /dev/null +++ b/frontend/src/tools/Flatten.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import FlattenSettings from "../components/tools/flatten/FlattenSettings"; +import { useFlattenParameters } from "../hooks/tools/flatten/useFlattenParameters"; +import { useFlattenOperation } from "../hooks/tools/flatten/useFlattenOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { useFlattenTips } from "../components/tooltips/useFlattenTips"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const Flatten = (props: BaseToolProps) => { + const { t } = useTranslation(); + const flattenTips = useFlattenTips(); + + const base = useBaseTool( + 'flatten', + useFlattenParameters, + useFlattenOperation, + props + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + placeholder: t("flatten.files.placeholder", "Select a PDF file in the main view to get started"), + }, + steps: [ + { + title: t("flatten.options.stepTitle", "Flatten Options"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: flattenTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("flatten.submit", "Flatten PDF"), + 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("flatten.results.title", "Flatten Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +// Static method to get the operation hook for automation +Flatten.tool = () => useFlattenOperation; + +export default Flatten as ToolComponent; \ No newline at end of file From 1898df0df98b4a2250ca3896bdb7bd521d4b77cb Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 5 Sep 2025 12:35:17 +0100 Subject: [PATCH 13/13] Urls for tools for scrapers and open in new tab support (#4364) # Description of Changes --- ## 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> --- .../components/shared/AllToolsNavButton.tsx | 16 +++- .../src/components/shared/QuickAccessBar.tsx | 71 ++++++++++---- .../quickAccessBar/ActiveToolButton.tsx | 18 +++- .../tools/shared/SuggestedToolsSection.tsx | 31 +++--- .../tools/toolPicker/ToolButton.tsx | 96 +++++++++++++++---- .../src/data/useTranslatedToolRegistry.tsx | 2 + frontend/src/hooks/useSidebarNavigation.ts | 51 ++++++++++ frontend/src/hooks/useSuggestedTools.ts | 37 +++++-- frontend/src/hooks/useToolNavigation.ts | 45 +++++++++ frontend/src/utils/clickHandlers.ts | 31 ++++++ 10 files changed, 327 insertions(+), 71 deletions(-) create mode 100644 frontend/src/hooks/useSidebarNavigation.ts create mode 100644 frontend/src/hooks/useToolNavigation.ts create mode 100644 frontend/src/utils/clickHandlers.ts diff --git a/frontend/src/components/shared/AllToolsNavButton.tsx b/frontend/src/components/shared/AllToolsNavButton.tsx index c1b5774d2..62fad704e 100644 --- a/frontend/src/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/components/shared/AllToolsNavButton.tsx @@ -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 = ({ 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 = ({ 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 = ( @@ -31,18 +40,21 @@ const AllToolsNavButton: React.FC = ({ activeButton, set ); return ( -
diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 142920942..2ebe45002 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -6,6 +6,8 @@ import { useRainbowThemeContext } from "./RainbowThemeProvider"; 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'; @@ -21,6 +23,7 @@ const QuickAccessBar = forwardRef((_, ref) => { 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('tools'); const scrollableRef = useRef(null); @@ -35,6 +38,52 @@ const QuickAccessBar = forwardRef((_, ref) => { 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 ( +
+ 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`} + > + + {config.icon} + + + + {config.name} + +
+ ); + }; + const buttonConfigs: ButtonConfig[] = [ { @@ -151,27 +200,7 @@ const QuickAccessBar = forwardRef((_, ref) => { {buttonConfigs.slice(0, -1).map((config, index) => ( - -
- { - config.onClick(); - }} - style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} - className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''} - data-testid={`${config.id}-button`} - > - - {config.icon} - - - - {config.name} - -
- + {renderNavButton(config, index)} {/* Add divider after Automate button (index 1) and Files button (index 2) */} {index === 1 && ( diff --git a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx index 3a0d5fe15..0146d86e0 100644 --- a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx +++ b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx @@ -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'; @@ -28,6 +30,7 @@ const NAV_IDS = ['read', 'sign', 'automate']; const ActiveToolButton: React.FC = ({ 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( @@ -135,21 +138,26 @@ const ActiveToolButton: React.FC = ({ setActiveButton })
{ + 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' }} > diff --git a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx index c1299a749..18cf8a0d5 100644 --- a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx +++ b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx @@ -1,5 +1,5 @@ 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'; @@ -19,20 +19,25 @@ export function SuggestedToolsSection(): React.ReactElement { {suggestedTools.map((tool) => { const IconComponent = tool.icon; return ( - - - - - {tool.title} - - - + + + + + {tool.title} + + + + ); })} diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index bd54f7e13..ee9c6062c 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -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 = ({ id, tool, isSelected, onSelect }) => { const isUnavailable = !tool.component && !tool.link; + const { getToolNavigation } = useToolNavigation(); + const handleClick = (id: string) => { if (isUnavailable) return; if (tool.link) { @@ -25,32 +29,84 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect onSelect(id); }; + // Get navigation props for URL support + const navProps = !isUnavailable && !tool.link ? getToolNavigation(id, tool) : null; + const tooltipContent = isUnavailable ? (Coming soon: {tool.description}) : tool.description; + const buttonContent = ( + <> +
{tool.icon}
+ + + ); + + 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 + + ) : tool.link && !isUnavailable ? ( + // For external links, render Button as an anchor with proper href + + ) : ( + // For unavailable tools, use regular button + + ); + return ( - + {buttonElement} ); }; diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index f4d0f444c..282653f8f 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -362,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, }, @@ -688,6 +689,7 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, maxFiles: -1, + urlPath: '/ocr-pdf', operationConfig: ocrOperationConfig, settingsComponent: OCRSettings, }, diff --git a/frontend/src/hooks/useSidebarNavigation.ts b/frontend/src/hooks/useSidebarNavigation.ts new file mode 100644 index 000000000..fb60e2502 --- /dev/null +++ b/frontend/src/hooks/useSidebarNavigation.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; +import { useToolNavigation } from './useToolNavigation'; +import { useToolManagement } from './useToolManagement'; +import { handleUnlessSpecialClick } from '../utils/clickHandlers'; + +export interface SidebarNavigationProps { + /** Full URL for the navigation (for href attribute) */ + href: string; + /** Click handler that maintains SPA behavior */ + onClick: (e: React.MouseEvent) => void; +} + +/** + * Hook that provides URL and navigation handlers for sidebar navigation buttons + * Supports special routes like home ('/') and specific tool routes + */ +export function useSidebarNavigation(): { + getHomeNavigation: () => SidebarNavigationProps; + getToolNavigation: (toolId: string) => SidebarNavigationProps | null; +} { + const { getToolNavigation: getToolNavProps } = useToolNavigation(); + const { getSelectedTool } = useToolManagement(); + + const defaultNavClick = useCallback((e: React.MouseEvent) => { + handleUnlessSpecialClick(e, () => { + // SPA navigation will be handled by the calling component + }); + }, []); + + const getHomeNavigation = useCallback((): SidebarNavigationProps => { + const href = '/'; // SSR-safe relative path + return { href, onClick: defaultNavClick }; + }, [defaultNavClick]); + + const getToolNavigation = useCallback((toolId: string): SidebarNavigationProps | null => { + // Handle special nav sections that aren't tools + if (toolId === 'read') return { href: '/read', onClick: defaultNavClick }; + if (toolId === 'automate') return { href: '/automate', onClick: defaultNavClick }; + + const tool = getSelectedTool(toolId); + if (!tool) return null; + + // Delegate to useToolNavigation for true tools + return getToolNavProps(toolId, tool); + }, [getToolNavProps, getSelectedTool, defaultNavClick]); + + return { + getHomeNavigation, + getToolNavigation + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useSuggestedTools.ts b/frontend/src/hooks/useSuggestedTools.ts index 8478bbc6b..377cf1245 100644 --- a/frontend/src/hooks/useSuggestedTools.ts +++ b/frontend/src/hooks/useSuggestedTools.ts @@ -1,5 +1,7 @@ import { useMemo } from 'react'; -import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext'; +import { useNavigationState } from '../contexts/NavigationContext'; +import { useToolNavigation } from './useToolNavigation'; +import { useToolManagement } from './useToolManagement'; import { ToolId } from '../types/toolId'; // Material UI Icons @@ -13,10 +15,11 @@ export interface SuggestedTool { id: ToolId; title: string; icon: React.ComponentType; - navigate: () => void; + href: string; + onClick: (e: React.MouseEvent) => void; } -const ALL_SUGGESTED_TOOLS: Omit[] = [ +const ALL_SUGGESTED_TOOLS: Omit[] = [ { id: 'compress', title: 'Compress', @@ -45,17 +48,31 @@ const ALL_SUGGESTED_TOOLS: Omit[] = [ ]; export function useSuggestedTools(): SuggestedTool[] { - const { actions } = useNavigationActions(); const { selectedTool } = useNavigationState(); + const { getToolNavigation } = useToolNavigation(); + const { getSelectedTool } = useToolManagement(); return useMemo(() => { // Filter out the current tool const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedTool); - // Add navigation function to each tool - return filteredTools.map(tool => ({ - ...tool, - navigate: () => actions.setSelectedTool(tool.id) - })); - }, [selectedTool, actions]); + // Add navigation props to each tool + return filteredTools.map(tool => { + const toolRegistryEntry = getSelectedTool(tool.id); + if (!toolRegistryEntry) { + // Fallback for tools not in registry + return { + ...tool, + href: `/${tool.id}`, + onClick: (e: React.MouseEvent) => { e.preventDefault(); } + }; + } + + const navProps = getToolNavigation(tool.id, toolRegistryEntry); + return { + ...tool, + ...navProps + }; + }); + }, [selectedTool, getToolNavigation, getSelectedTool]); } diff --git a/frontend/src/hooks/useToolNavigation.ts b/frontend/src/hooks/useToolNavigation.ts new file mode 100644 index 000000000..704fd5026 --- /dev/null +++ b/frontend/src/hooks/useToolNavigation.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react'; +import { ToolRegistryEntry, getToolUrlPath } from '../data/toolsTaxonomy'; +import { useToolWorkflow } from '../contexts/ToolWorkflowContext'; +import { handleUnlessSpecialClick } from '../utils/clickHandlers'; + +export interface ToolNavigationProps { + /** Full URL for the tool (for href attribute) */ + href: string; + /** Click handler that maintains SPA behavior */ + onClick: (e: React.MouseEvent) => void; +} + +/** + * Hook that provides URL and navigation handlers for tools + * Enables right-click "Open in New Tab" while maintaining SPA behavior for regular clicks + */ +export function useToolNavigation(): { + getToolNavigation: (toolId: string, tool: ToolRegistryEntry) => ToolNavigationProps; +} { + const { handleToolSelect } = useToolWorkflow(); + + const getToolNavigation = useCallback((toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => { + // Generate SSR-safe relative path + const path = getToolUrlPath(toolId, tool); + const href = path; // Relative path, no window.location needed + + // Click handler that maintains SPA behavior + const onClick = (e: React.MouseEvent) => { + handleUnlessSpecialClick(e, () => { + // Handle external links normally + if (tool.link) { + window.open(tool.link, '_blank', 'noopener,noreferrer'); + return; + } + + // Use SPA navigation for internal tools + handleToolSelect(toolId); + }); + }; + + return { href, onClick }; + }, [handleToolSelect]); + + return { getToolNavigation }; +} \ No newline at end of file diff --git a/frontend/src/utils/clickHandlers.ts b/frontend/src/utils/clickHandlers.ts new file mode 100644 index 000000000..0c70cfca8 --- /dev/null +++ b/frontend/src/utils/clickHandlers.ts @@ -0,0 +1,31 @@ +/** + * Utility functions for handling click events in navigation components + */ + +/** + * Determines if a click event is a "special" click that should use browser's default navigation + * instead of SPA navigation. Special clicks include: + * - Ctrl+click (or Cmd+click on Mac) + * - Shift+click + * - Middle mouse button click + */ +export function isSpecialClick(e: React.MouseEvent): boolean { + return e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1; +} + +/** + * Handles a click event for SPA navigation, but allows special clicks to use browser defaults + * + * @param e - The click event + * @param handleClick - Function to execute for regular clicks (SPA navigation) + * @returns true if the event was handled as a special click, false if it was handled as regular click + */ +export function handleUnlessSpecialClick(e: React.MouseEvent, handleClick: () => void): boolean { + if (isSpecialClick(e)) { + return true; // Let browser handle via href + } + + e.preventDefault(); + handleClick(); + return false; +} \ No newline at end of file