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 5b76408cc8..5e54457699 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 0e38b82fb5..b38abe5dc9 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 6ab09796f2..4f627148eb 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 0000000000..7922013cb8 --- /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 7e6d23d505..342f0512f7 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 160c96c18b..d73e9ad974 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 fe3e314e08..c0d6929fdb 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 7922013cb8..a8042355fa 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 37da2dc78d..7a695ed777 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 0999c5c29b..57fc841e00 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 a8042355fa..d39d66276a 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 aaac698000..7ab425e195 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 d39d66276a..98acb81c4d 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 f9f067c304..65d0c6519b 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 2048c021fb..9251998a3a 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 98acb81c4d..6bce88115c 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 53a01cb77e..d4350044e9 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 55fe7f046e..38a0c19233 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 6bce88115c..1405d64c29 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 e65843fa4d..8447a891cb 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 1405d64c29..7d0e65171e 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 d8d35176d8..3005d5c35c 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 65d0c6519b..11d534a4fc 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 5376926003..1705bb54ee 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 7d0e65171e..502ec4f71f 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 32543ec91e..798fdc5b41 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 502ec4f71f..d823a6af98 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 fca3b5e566..c1299a7492 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 6f8856a8b8..91355cb817 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 8a30d38696..c3cbf3e890 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 bf95d97966..901eb20ca2 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 e82483898a..bfeb404c5b 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 1eda1f6c85..ad81ce4631 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 b620c87b89..3a4d490531 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 73ae01dba4..6c63af42e9 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 1a43196d6b..2d9cba6403 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 e1a662bd27..49e057a1ce 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 9b375fc2f6..a083d65efc 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 b062e9c02f..8c188d4a97 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 b6a7594c62..83057c13ad 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 0932e995bb..53d9e98e26 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 39faa06434..80735de580 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 93c06c4d24..4c4196764c 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 e551085539..3901fabeed 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 e1b8e5cc45..1907c8fb8f 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 2111693cfc..a004831cc7 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 9986fe5858..901a16943b 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 64e9af59eb..643e9bca5e 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 3005d5c35c..7bfcc3d326 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 f47430fd2c..8df1e27540 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 fd2f2d604b..5176c1225e 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 4f0d77c0e8..a6251db3cc 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 ab3b5e0077..b88bfbe2b6 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 17f90f2d93..b14c1a6374 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 8eba26214d..3106340453 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 11d534a4fc..2b6b18c8c1 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 065f532103..7ede9334d3 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 41a768838a..4efb41d7e2 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 4e9fb7908e..2904135e0c 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 0000000000..80b3c74cf8 --- /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 9210f9ce97..f5d4cef0a7 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 0000000000..08888d4c0c --- /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 0000000000..3fda8a4a58 --- /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 d823a6af98..c0f9fd6786 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 0fd42a4dfe..d99414b668 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 7ab425e195..3daf04d8d8 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 dc0001b4ee..63ca5c5ece 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 901eb20ca2..8f71f59b01 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 bfeb404c5b..7e7370785e 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 dcd460644e..b1e7e93e32 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 ad81ce4631..be926f85da 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 07823414ab..2c85d8f814 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 2a932ae507..3e36b06b61 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 0b2782ba18..dfa098af1e 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 3a4d490531..0b7aa00b45 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 2d9cba6403..431c5bdedd 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 04fee9b35c..0d3c3bee42 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 d3a346a8ed..f2fddf2124 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 8447a891cb..142920942b 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 24f927443c..ee0f9b911c 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 8362c82245..3a0d5fe150 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 ee4a8fe5c8..dc9fd6af02 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 de95f0b94e..98d1d96f3d 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 92373f60db..b483eca748 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 96ce6aad51..36ef3ce011 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 84bbb296af..04949c27c8 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 5278ca3323..cee909fcad 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 5dfdc04689..b5e793ccb3 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 7a33bd65e3..30c2804918 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 1a3cb1263b..2d6bda6fc6 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 b2aa1c975d..4fce30b3db 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 1090bd11f5..071e27cfdd 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 b4d9aa766e..6009888b9e 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 f34e3f2e6f..cb1f480d37 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 a083d65efc..354613ecb3 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 364077e4fc..6dcb6fc6cd 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 4201fd82da..2fa8eb2daf 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 83057c13ad..84051f4261 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 a7535e0e0c..4d92d4798b 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 87dfef9262..11bf7009f6 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 cc8697d7a6..d7ca66f256 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 53d9e98e26..dfcd5dc7df 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 80735de580..3c75b20807 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 abbdfcfbd9..5a609e63ed 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 dfd2ac5f2b..b6a0b67979 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 3b65f01d11..f9e8ba9a59 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 b16261bc9e..4473dc020c 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 3901fabeed..552ce0b1f4 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 1907c8fb8f..7d7f9b23e4 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 901a16943b..c65fec127b 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 93b55bc26a..53f6f7854e 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 112bafbd23..3e51a615f9 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 410ad32172..8ad7a80b6f 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 7250726f1c..970c14375e 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 8327dd6985..c7080048f6 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 6de20282f1..56dcbb204b 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 2a7b7d5232..74a1bd3a13 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 798fdc5b41..e208d44794 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 1061502813..91eed974af 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 44fcc9b70b..d195ee881a 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 7bfcc3d326..263217e42d 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 ef304fa095..35eaec079c 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 394fb694d1..b18b7c1f57 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 d47800b34c..faaeae428a 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 3eacaa7bd8..dd00f43962 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 5419f35062..7516826ede 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 ebdff4bf5e..2f0ab923d0 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 8df1e27540..f3dedf5e40 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 a6251db3cc..cd497561b7 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 b14c1a6374..77b7f79aca 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 3106340453..6a22fbcc95 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 f73f458db1..3239cbaaaf 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 d6eae6d8b8..1afd66835b 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 3651c1d47d..51ef2e9c3e 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 961662123d..26d190dfa2 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 2b6b18c8c1..bee6e200a4 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 1705bb54ee..5655902a04 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 c109cff1f2..be822b846c 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 e500f54b51..e6dbbb4649 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 c0d6929fdb..aa20f4cfc3 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 57fc841e00..dda434049a 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 0b706cd96e..872157d51d 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 3b406a91e6..b640be3b67 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 60e2c4849a..5e250030ec 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 4efb41d7e2..bf2c46662e 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 2904135e0c..52826ce3f5 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 2cd70d645e..b7cd70a187 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 adbc25b5a1..c29491437e 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 7065b2a5bf..3bafbd3292 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 589c386786..db89db162b 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 5ced396701..05fd875316 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 fcfb968417..c8a30fea4b 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 6a0cef6977..f22ee9159a 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 08888d4c0c..13aac82ddc 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 124f065a73..3feb9b4129 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 45abbaafcc..d81dd3a1ba 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 472cccb05a..659c2948dd 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 e4a48f9fd9..5f4cac3e61 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 0000000000..ef1501bfc9 --- /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 be4e922012..a806d00988 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 637ab59e1e..5f61f7544b 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 0000000000..8386ad4939 --- /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 0000000000..a60e5a4bcc --- /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 68883fe924..f4d0f444ca 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 0000000000..82eaba2581 --- /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 0000000000..98c5b96559 --- /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 0000000000..691a733f9d --- /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 c1b5774d20..62fad704ed 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 142920942b..2ebe450020 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 3a0d5fe150..0146d86e0b 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 c1299a7492..18cf8a0d5c 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 bd54f7e130..ee9c6062cf 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 f4d0f444ca..282653f8f0 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 0000000000..fb60e2502e --- /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 8478bbc6b9..377cf12456 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 0000000000..704fd50261 --- /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 0000000000..0c70cfca82 --- /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