diff --git a/.editorconfig b/.editorconfig index 5b76408cc..5e5445769 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,7 +24,7 @@ indent_size = 2 insert_final_newline = false trim_trailing_whitespace = false -[{*.js,*.jsx,*.ts,*.tsx}] +[{*.js,*.jsx,*.mjs,*.ts,*.tsx}] indent_size = 2 [*.css] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e38b82fb..b38abe5dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,6 +147,8 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install frontend dependencies run: cd frontend && npm ci + - name: Lint frontend + run: cd frontend && npm run lint - name: Build frontend run: cd frontend && npm run build - name: Run frontend tests diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 6ab09796f..4f627148e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -19,5 +19,6 @@ "yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing "stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting "redhat.vscode-yaml", // YAML extension for Visual Studio Code + "dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting ] } diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 000000000..d823a6af9 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,31 @@ +// @ts-check + +import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +export default defineConfig( + eslint.configs.recommended, + tseslint.configs.recommended, + { + ignores: [ + "dist", // Contains 3rd party code + "public", // Contains 3rd party code + ], + }, + { + rules: { + "no-undef": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-empty-object-type": [ + "error", + { + // Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future + allowInterfaces: 'with-single-extends', + }, + ], + "@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-unused-vars": "off", // Temporarily disabled until codebase conformant + }, + } +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e6d23d50..342f0512f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "web-vitals": "^2.1.4" }, "devDependencies": { + "@eslint/js": "^9.34.0", "@iconify-json/material-symbols": "^1.2.33", "@iconify/utils": "^3.0.1", "@playwright/test": "^1.40.0", @@ -49,6 +50,7 @@ "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.0", "@vitest/coverage-v8": "^1.0.0", + "eslint": "^9.34.0", "jsdom": "^23.0.0", "license-checker": "^25.0.1", "madge": "^8.0.0", @@ -56,7 +58,8 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", - "typescript": "^5.8.3", + "typescript": "^5.9.2", + "typescript-eslint": "^8.42.0", "vite": "^6.3.5", "vitest": "^1.0.0" } @@ -1164,6 +1167,173 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", @@ -1217,6 +1387,72 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@iconify-json/material-symbols": { "version": "1.2.33", "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.33.tgz", @@ -2659,6 +2895,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", @@ -2709,15 +2952,80 @@ "@types/react": "*" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", - "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.42.0.tgz", + "integrity": "sha512-Aq2dPqsQkxHOLfb2OPv43RnIvfj05nw8v/6n3B2NABIPpHnjQnaLo9QGMTvml+tv4korl/Cjfrb/BYhoL8UUTQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.40.0", - "@typescript-eslint/types": "^8.40.0", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/type-utils": "8.42.0", + "@typescript-eslint/utils": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.42.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.42.0.tgz", + "integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz", + "integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.42.0", + "@typescript-eslint/types": "^8.42.0", "debug": "^4.3.4" }, "engines": { @@ -2731,10 +3039,28 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz", + "integrity": "sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", - "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz", + "integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==", "dev": true, "license": "MIT", "engines": { @@ -2748,10 +3074,35 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.42.0.tgz", + "integrity": "sha512-9KChw92sbPTYVFw3JLRH1ockhyR3zqqn9lQXol3/YbI6jVxzWoGcT3AsAW0mu1MY0gYtsXnUGV/AKpkAj5tVlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/utils": "8.42.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", - "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz", + "integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==", "dev": true, "license": "MIT", "engines": { @@ -2763,16 +3114,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", - "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz", + "integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.40.0", - "@typescript-eslint/tsconfig-utils": "8.40.0", - "@typescript-eslint/types": "8.40.0", - "@typescript-eslint/visitor-keys": "8.40.0", + "@typescript-eslint/project-service": "8.42.0", + "@typescript-eslint/tsconfig-utils": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2817,14 +3168,38 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", - "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "node_modules/@typescript-eslint/utils": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.42.0.tgz", + "integrity": "sha512-JnIzu7H3RH5BrKC4NoZqRfmjqCIS1u3hGZltDYJgkVdqAezl4L9d1ZLw+36huCujtSBSAirGINF/S4UxOcR+/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.40.0", + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz", + "integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -3136,6 +3511,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", @@ -3162,6 +3547,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3236,6 +3638,13 @@ "node": ">=10" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -4028,6 +4437,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -4492,6 +4908,84 @@ "node": ">=0.10.0" } }, + "node_modules/eslint": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.34.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -4505,6 +4999,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -4519,6 +5031,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -4592,6 +5130,13 @@ "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", "dev": true }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4622,6 +5167,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4638,6 +5197,19 @@ "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-selector": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", @@ -4705,6 +5277,44 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -4971,6 +5581,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -5014,6 +5637,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5246,6 +5876,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -5267,6 +5907,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -5558,6 +6208,19 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "23.2.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", @@ -5635,12 +6298,33 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5705,12 +6389,36 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", "dev": true }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/license-checker": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/license-checker/-/license-checker-25.0.1.tgz", @@ -6105,12 +6813,35 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -6535,6 +7266,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6736,6 +7474,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -6805,6 +7561,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-manager-detector": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", @@ -6869,6 +7670,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7493,6 +8304,16 @@ "node": ">=18" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -9022,6 +9843,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", @@ -9045,9 +9879,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -9058,6 +9892,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.42.0.tgz", + "integrity": "sha512-ozR/rQn+aQXQxh1YgbCzQWDFrsi9mcg+1PM3l/z5o1+20P7suOIaNg515bpr/OYt6FObz/NHcBstydDLHWeEKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.42.0", + "@typescript-eslint/parser": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/utils": "8.42.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/ufo": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", @@ -9111,6 +9969,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -10541,6 +11409,16 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 160c96c18..d73e9ad97 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "predev": "npm run generate-icons", "dev": "npx tsc --noEmit && vite", "prebuild": "npm run generate-icons", + "lint": "npx eslint", "build": "npx tsc --noEmit && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", @@ -72,6 +73,7 @@ ] }, "devDependencies": { + "@eslint/js": "^9.34.0", "@iconify-json/material-symbols": "^1.2.33", "@iconify/utils": "^3.0.1", "@playwright/test": "^1.40.0", @@ -80,6 +82,7 @@ "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.0", "@vitest/coverage-v8": "^1.0.0", + "eslint": "^9.34.0", "jsdom": "^23.0.0", "license-checker": "^25.0.1", "madge": "^8.0.0", @@ -87,7 +90,8 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", - "typescript": "^5.8.3", + "typescript": "^5.9.2", + "typescript-eslint": "^8.42.0", "vite": "^6.3.5", "vitest": "^1.0.0" } diff --git a/frontend/scripts/generate-licenses.js b/frontend/scripts/generate-licenses.js index aaac69800..7ab425e19 100644 --- a/frontend/scripts/generate-licenses.js +++ b/frontend/scripts/generate-licenses.js @@ -224,7 +224,7 @@ function getLicenseUrl(licenseType) { // Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)" if (licenseType.includes('AND') || licenseType.includes('OR')) { // Extract the first license from compound expressions for URL - const match = licenseType.match(/\(?\s*([A-Za-z0-9\-\.]+)/); + const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/); if (match && licenseUrls[match[1]]) { return licenseUrls[match[1]]; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a30d3869..c3cbf3e89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,9 @@ import "./styles/cookieconsent.css"; import "./index.css"; import { RightRailProvider } from "./contexts/RightRailContext"; +// Import file ID debugging helpers (development only) +import "./utils/fileIdSafety"; + // Loading component for i18next suspense const LoadingFallback = () => (
= ({ selectedTool }) => { const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); // Wrapper for storeFile that generates UUID - const storeFileWithId = useCallback(async (file: File) => { + const storeStirlingFile = useCallback(async (file: File) => { const fileId = createFileId(); // Generate UUID for storage return await storeFile(file, fileId); }, [storeFile]); diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index bf95d9796..901eb20ca 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -16,12 +16,12 @@ import styles from './FileEditor.module.css'; import FileEditorThumbnail from './FileEditorThumbnail'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; -import { FileId } from '../../types/file'; +import { FileId, StirlingFile } from '../../types/fileContext'; interface FileEditorProps { - onOpenPageEditor?: (file: File) => void; - onMergeFiles?: (files: File[]) => void; + onOpenPageEditor?: (file: StirlingFile) => void; + onMergeFiles?: (files: StirlingFile[]) => void; toolMode?: boolean; showUpload?: boolean; showBulkActions?: boolean; @@ -50,7 +50,7 @@ const FileEditor = ({ // Extract needed values from state (memoized to prevent infinite loops) const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]); - const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]); + const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]); const selectedFileIds = state.ui.selectedFileIds; const isProcessing = state.ui.isProcessing; @@ -92,10 +92,10 @@ const FileEditor = ({ const contextSelectedIdsRef = useRef([]); contextSelectedIdsRef.current = contextSelectedIds; - // Use activeFileRecords directly - no conversion needed + // Use activeStirlingFileStubs directly - no conversion needed const localSelectedIds = contextSelectedIds; - // Helper to convert FileRecord to FileThumbnail format + // Helper to convert StirlingFileStub to FileThumbnail format const recordToFileItem = useCallback((record: any) => { const file = selectors.getFile(record.id); if (!file) return null; @@ -253,26 +253,26 @@ const FileEditor = ({ }, [addFiles]); const selectAll = useCallback(() => { - setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly - }, [activeFileRecords, setSelectedFiles]); + setSelectedFiles(activeStirlingFileStubs.map(r => r.id)); // Use StirlingFileStub IDs directly + }, [activeStirlingFileStubs, setSelectedFiles]); const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]); const closeAllFiles = useCallback(() => { - if (activeFileRecords.length === 0) return; + if (activeStirlingFileStubs.length === 0) return; // Remove all files from context but keep in storage - const allFileIds = activeFileRecords.map(record => record.id); + const allFileIds = activeStirlingFileStubs.map(record => record.id); removeFiles(allFileIds, false); // false = keep in storage // Clear selections setSelectedFiles([]); - }, [activeFileRecords, removeFiles, setSelectedFiles]); + }, [activeStirlingFileStubs, removeFiles, setSelectedFiles]); const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; - const targetRecord = activeFileRecords.find(r => r.id === fileId); + const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId); if (!targetRecord) return; const contextFileId = fileId; // No need to create a new ID @@ -302,7 +302,7 @@ const FileEditor = ({ // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); - }, [setSelectedFiles, toolMode, setStatus, activeFileRecords]); + }, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { @@ -316,7 +316,7 @@ const FileEditor = ({ // File reordering handler for drag and drop const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => { - const currentIds = activeFileRecords.map(r => r.id); + const currentIds = activeStirlingFileStubs.map(r => r.id); // Find indices const sourceIndex = currentIds.findIndex(id => id === sourceFileId); @@ -368,13 +368,13 @@ const FileEditor = ({ // Update status const moveCount = filesToMove.length; setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - }, [activeFileRecords, reorderFiles, setStatus]); + }, [activeStirlingFileStubs, reorderFiles, setStatus]); // File operations using context const handleDeleteFile = useCallback((fileId: FileId) => { - const record = activeFileRecords.find(r => r.id === fileId); + const record = activeStirlingFileStubs.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; if (record && file) { @@ -405,27 +405,27 @@ const FileEditor = ({ const currentSelected = selectedFileIds.filter(id => id !== contextFileId); setSelectedFiles(currentSelected); } - }, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]); + }, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]); const handleViewFile = useCallback((fileId: FileId) => { - const record = activeFileRecords.find(r => r.id === fileId); + const record = activeStirlingFileStubs.find(r => r.id === fileId); if (record) { // Set the file as selected in context and switch to viewer for preview setSelectedFiles([fileId]); navActions.setWorkbench('viewer'); } - }, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]); + }, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]); const handleMergeFromHere = useCallback((fileId: FileId) => { - const startIndex = activeFileRecords.findIndex(r => r.id === fileId); + const startIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId); if (startIndex === -1) return; - const recordsToMerge = activeFileRecords.slice(startIndex); - const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[]; + const recordsToMerge = activeStirlingFileStubs.slice(startIndex); + const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as StirlingFile[]; if (onMergeFiles) { onMergeFiles(filesToMerge); } - }, [activeFileRecords, selectors, onMergeFiles]); + }, [activeStirlingFileStubs, selectors, onMergeFiles]); const handleSplitFile = useCallback((fileId: FileId) => { const file = selectors.getFile(fileId); @@ -467,7 +467,7 @@ const FileEditor = ({ - {activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? ( + {activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
📁 @@ -475,7 +475,7 @@ const FileEditor = ({ Upload PDF files, ZIP archives, or load from storage to get started
- ) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? ( + ) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? ( @@ -522,7 +522,7 @@ const FileEditor = ({ pointerEvents: 'auto' }} > - {activeFileRecords.map((record, index) => { + {activeStirlingFileStubs.map((record, index) => { const fileItem = recordToFileItem(record); if (!fileItem) return null; @@ -531,7 +531,7 @@ const FileEditor = ({ key={record.id} file={fileItem} index={index} - totalFiles={activeFileRecords.length} + totalFiles={activeStirlingFileStubs.length} selectedFiles={localSelectedIds} selectionMode={selectionMode} onToggleFile={toggleFile} diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index e82483898..bfeb404c5 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -61,8 +61,8 @@ const FileEditorThumbnail = ({ // Resolve the actual File object for pin/unpin operations const actualFile = useMemo(() => { - return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); - }, [activeFiles, file.name, file.size]); + return activeFiles.find(f => f.fileId === file.id); + }, [activeFiles, file.id]); const isPinned = actualFile ? isFilePinned(actualFile) : false; const downloadSelectedFile = useCallback(() => { diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index 1eda1f6c8..ad81ce463 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -61,8 +61,8 @@ const FileThumbnail = ({ // Resolve the actual File object for pin/unpin operations const actualFile = useMemo(() => { - return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); - }, [activeFiles, file.name, file.size]); + return activeFiles.find(f => f.fileId === file.id); + }, [activeFiles, file.id]); const isPinned = actualFile ? isFilePinned(actualFile) : false; const downloadSelectedFile = useCallback(() => { diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts index b620c87b8..3a4d49053 100644 --- a/frontend/src/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook { const globalProcessing = state.ui.isProcessing; // Get primary file record outside useMemo to track processedFile changes - const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; - const processedFilePages = primaryFileRecord?.processedFile?.pages; - const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; + const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null; + const processedFilePages = primaryStirlingFileStub?.processedFile?.pages; + const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages; // Compute merged document with stable signature (prevents infinite loops) const mergedPdfDocument = useMemo((): PDFDocument | null => { @@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook { const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; // If we have file IDs but no file record, something is wrong - return null to show loading - if (!primaryFileRecord) { + if (!primaryStirlingFileStub) { console.log('🎬 PageEditor: No primary file record found, showing loading'); return null; } const name = activeFileIds.length === 1 - ? (primaryFileRecord.name ?? 'document.pdf') + ? (primaryStirlingFileStub.name ?? 'document.pdf') : activeFileIds - .map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) + .map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, '')) .join(' + '); // Build page insertion map from files with insertion positions @@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook { const originalFileIds: FileId[] = []; activeFileIds.forEach(fileId => { - const record = selectors.getFileRecord(fileId); + const record = selectors.getStirlingFileStub(fileId); if (record?.insertAfterPageId !== undefined) { if (!insertionMap.has(record.insertAfterPageId)) { insertionMap.set(record.insertAfterPageId, []); @@ -72,12 +72,12 @@ export function usePageDocument(): PageDocumentHook { // Helper function to create pages from a file const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => { - const fileRecord = selectors.getFileRecord(fileId); - if (!fileRecord) { + const stirlingFileStub = selectors.getStirlingFileStub(fileId); + if (!stirlingFileStub) { return []; } - const processedFile = fileRecord.processedFile; + const processedFile = stirlingFileStub.processedFile; let filePages: PDFPage[] = []; if (processedFile?.pages && processedFile.pages.length > 0) { @@ -159,7 +159,7 @@ export function usePageDocument(): PageDocumentHook { }; return mergedDoc; - }, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]); + }, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]); // Large document detection for smart loading const isVeryLargeDocument = useMemo(() => { diff --git a/frontend/src/components/shared/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx index 73ae01dba..6c63af42e 100644 --- a/frontend/src/components/shared/FileCard.tsx +++ b/frontend/src/components/shared/FileCard.tsx @@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage"; import VisibilityIcon from "@mui/icons-material/Visibility"; import EditIcon from "@mui/icons-material/Edit"; -import { FileRecord } from "../../types/fileContext"; +import { StirlingFileStub } from "../../types/fileContext"; import { getFileSize, getFileDate } from "../../utils/fileUtils"; import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; interface FileCardProps { file: File; - record?: FileRecord; + record?: StirlingFileStub; onRemove: () => void; onDoubleClick?: () => void; onView?: () => void; @@ -25,7 +25,7 @@ interface FileCardProps { const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { const { t } = useTranslation(); // Use record thumbnail if available, otherwise fall back to IndexedDB lookup - const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null; + const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null; const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata); const thumb = record?.thumbnailUrl || indexedDBThumb; const [isHovered, setIsHovered] = useState(false); diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx index 1a43196d6..2d9cba640 100644 --- a/frontend/src/components/shared/FileGrid.tsx +++ b/frontend/src/components/shared/FileGrid.tsx @@ -4,15 +4,15 @@ import { useTranslation } from "react-i18next"; import SearchIcon from "@mui/icons-material/Search"; import SortIcon from "@mui/icons-material/Sort"; import FileCard from "./FileCard"; -import { FileRecord } from "../../types/fileContext"; +import { StirlingFileStub } from "../../types/fileContext"; import { FileId } from "../../types/file"; interface FileGridProps { - files: Array<{ file: File; record?: FileRecord }>; + files: Array<{ file: File; record?: StirlingFileStub }>; onRemove?: (index: number) => void; - onDoubleClick?: (item: { file: File; record?: FileRecord }) => void; - onView?: (item: { file: File; record?: FileRecord }) => void; - onEdit?: (item: { file: File; record?: FileRecord }) => void; + onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void; + onView?: (item: { file: File; record?: StirlingFileStub }) => void; + onEdit?: (item: { file: File; record?: StirlingFileStub }) => void; onSelect?: (fileId: FileId) => void; selectedFiles?: FileId[]; showSearch?: boolean; @@ -123,9 +123,17 @@ const FileGrid = ({ h="30rem" style={{ overflowY: "auto", width: "100%" }} > - {displayFiles.map((item, idx) => { - const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */; - const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId); + {displayFiles + .filter(item => { + if (!item.record?.id) { + console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name); + return false; + } + return true; + }) + .map((item, idx) => { + const fileId = item.record!.id; // Safe to assert after filter + const originalIdx = files.findIndex(f => f.record?.id === fileId); const supported = isFileSupported ? isFileSupported(item.file.name) : true; return ( (({ -}, ref) => { +const QuickAccessBar = forwardRef((_, ref) => { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index b141ec493..24f927443 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -34,7 +34,6 @@ export default function RightRail() { const activeFiles = selectors.getFiles(); const filesSignature = selectors.getFilesSignature(); - const fileRecords = selectors.getFileRecords(); // Compute selection state and total items const getSelectionState = useCallback(() => { @@ -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>
- { 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/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx index bae2954a9..3a019f8da 100644 --- a/frontend/src/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertSettings.tsx @@ -22,13 +22,13 @@ import { OUTPUT_OPTIONS, FIT_OPTIONS } from "../../../constants/convertConstants"; -import { FileId } from "../../../types/file"; +import { StirlingFile } from "../../../types/fileContext"; interface ConvertSettingsProps { parameters: ConvertParameters; onParameterChange: (key: keyof ConvertParameters, value: any) => void; getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; - selectedFiles: File[]; + selectedFiles: StirlingFile[]; disabled?: boolean; } @@ -129,7 +129,7 @@ const ConvertSettings = ({ }; const filterFilesByExtension = (extension: string) => { - const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[]; + const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as StirlingFile[]; return files.filter(file => { const fileExtension = detectFileExtension(file.name); @@ -143,21 +143,8 @@ const ConvertSettings = ({ }); }; - const updateFileSelection = (files: File[]) => { - // Map File objects to their actual IDs in FileContext - const fileIds = files.map(file => { - // Find the file ID by matching file properties - const fileRecord = state.files.ids - .map(id => selectors.getFileRecord(id)) - .find(record => - record && - record.name === file.name && - record.size === file.size && - record.lastModified === file.lastModified - ); - return fileRecord?.id; - }).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings - + const updateFileSelection = (files: StirlingFile[]) => { + const fileIds = files.map(file => file.fileId); setSelectedFiles(fileIds); }; diff --git a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx index e1a662bd2..49e057a1c 100644 --- a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx @@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters'; import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection'; +import { StirlingFile } from '../../../types/fileContext'; interface ConvertToPdfaSettingsProps { parameters: ConvertParameters; onParameterChange: (key: keyof ConvertParameters, value: any) => void; - selectedFiles: File[]; + selectedFiles: StirlingFile[]; disabled?: boolean; } diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index 9b375fc2f..a083d65ef 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload'; import { useFilesModalContext } from "../../../contexts/FilesModalContext"; import { useAllFiles } from "../../../contexts/FileContext"; import { useFileManager } from "../../../hooks/useFileManager"; +import { StirlingFile } from "../../../types/fileContext"; export interface FileStatusIndicatorProps { - selectedFiles?: File[]; + selectedFiles?: StirlingFile[]; placeholder?: string; } @@ -17,7 +18,7 @@ const FileStatusIndicator = ({ }: FileStatusIndicatorProps) => { const { t } = useTranslation(); const { openFilesModal, onFilesSelect } = useFilesModalContext(); - const { files: workbenchFiles } = useAllFiles(); + const { files: stirlingFileStubs } = useAllFiles(); const { loadRecentFiles } = useFileManager(); const [hasRecentFiles, setHasRecentFiles] = useState(null); @@ -55,7 +56,7 @@ const FileStatusIndicator = ({ } // Check if there are no files in the workbench - if (workbenchFiles.length === 0) { + if (stirlingFileStubs.length === 0) { // If no recent files, show upload button if (!hasRecentFiles) { return ( diff --git a/frontend/src/components/tools/shared/FilesToolStep.tsx b/frontend/src/components/tools/shared/FilesToolStep.tsx index b062e9c02..8c188d4a9 100644 --- a/frontend/src/components/tools/shared/FilesToolStep.tsx +++ b/frontend/src/components/tools/shared/FilesToolStep.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import FileStatusIndicator from './FileStatusIndicator'; +import { StirlingFile } from '../../../types/fileContext'; export interface FilesToolStepProps { - selectedFiles: File[]; + selectedFiles: StirlingFile[]; isCollapsed?: boolean; onCollapsedClick?: () => void; placeholder?: string; diff --git a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx index fca3b5e56..c1299a749 100644 --- a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx +++ b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx @@ -3,8 +3,6 @@ import { Stack, Text, Divider, Card, Group } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useSuggestedTools } from '../../../hooks/useSuggestedTools'; -export interface SuggestedToolsSectionProps {} - export function SuggestedToolsSection(): React.ReactElement { const { t } = useTranslation(); const suggestedTools = useSuggestedTools(); diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index b6a7594c6..83057c13a 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep'; import OperationButton from './OperationButton'; import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation'; import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle'; +import { StirlingFile } from '../../../types/fileContext'; export interface FilesStepConfig { - selectedFiles: File[]; + selectedFiles: StirlingFile[]; isCollapsed?: boolean; placeholder?: string; onCollapsedClick?: () => void; diff --git a/frontend/src/components/tools/toolPicker/ToolSearch.tsx b/frontend/src/components/tools/toolPicker/ToolSearch.tsx index 53a01cb77..d4350044e 100644 --- a/frontend/src/components/tools/toolPicker/ToolSearch.tsx +++ b/frontend/src/components/tools/toolPicker/ToolSearch.tsx @@ -126,7 +126,7 @@ const ToolSearch = ({ key={id} variant="subtle" onClick={() => { - onToolSelect && onToolSelect(id); + onToolSelect?.(id); setDropdownOpen(false); }} leftSection={
{tool.icon}
} diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index 0932e995b..53d9e98e2 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -15,6 +15,7 @@ import { fileStorage } from "../../services/fileStorage"; import SkeletonLoader from '../shared/SkeletonLoader'; import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext"; import { useFileWithUrl } from "../../hooks/useFileWithUrl"; +import { isFileObject } from "../../types/fileContext"; import { FileId } from "../../types/file"; @@ -201,7 +202,7 @@ const Viewer = ({ const effectiveFile = React.useMemo(() => { if (previewFile) { // Validate the preview file - if (!(previewFile instanceof File)) { + if (!isFileObject(previewFile)) { return null; } diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 39faa0643..80735de58 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -19,7 +19,10 @@ import { FileContextStateValue, FileContextActionsValue, FileContextActions, - FileRecord + FileId, + StirlingFileStub, + StirlingFile, + createStirlingFile } from '../types/fileContext'; // Import modular components @@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions import { FileLifecycleManager } from './file/lifecycle'; import { FileStateContext, FileActionsContext } from './file/contexts'; import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext'; -import { FileId } from '../types/file'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -79,7 +81,7 @@ function FileContextInner({ } // File operations using unified addFiles helper with persistence - const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => { + const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => { const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager); // Auto-select the newly added files if requested @@ -98,15 +100,15 @@ function FileContextInner({ })); } - return addedFilesWithIds.map(({ file }) => file); + return addedFilesWithIds.map(({ file, id }) => createStirlingFile(file, id)); }, [indexedDB, enablePersistence]); - const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise => { + const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise => { const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager); - return result.map(({ file }) => file); + return result.map(({ file, id }) => createStirlingFile(file, id)); }, []); - const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise => { + const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise => { const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager); // Auto-select the newly added files if requested @@ -114,7 +116,7 @@ function FileContextInner({ selectFiles(result); } - return result.map(({ file }) => file); + return result.map(({ file, id }) => createStirlingFile(file, id)); }, []); // Action creators @@ -122,11 +124,11 @@ function FileContextInner({ // Helper functions for pinned files const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise => { - return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB); + return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB); }, [indexedDB]); - const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise => { - return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB); + const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise => { + return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, stateRef, filesRef, dispatch, indexedDB); }, [indexedDB]); // Helper to find FileId from File object @@ -140,24 +142,14 @@ function FileContextInner({ }); }, []); - // File-to-ID wrapper functions for pinning - const pinFileWrapper = useCallback((file: File) => { - const fileId = findFileId(file); - if (fileId) { - baseActions.pinFile(fileId); - } else { - console.warn('File not found for pinning:', file.name); - } - }, [baseActions, findFileId]); + // File pinning functions - use StirlingFile directly + const pinFileWrapper = useCallback((file: StirlingFile) => { + baseActions.pinFile(file.fileId); + }, [baseActions]); - const unpinFileWrapper = useCallback((file: File) => { - const fileId = findFileId(file); - if (fileId) { - baseActions.unpinFile(fileId); - } else { - console.warn('File not found for unpinning:', file.name); - } - }, [baseActions, findFileId]); + const unpinFileWrapper = useCallback((file: StirlingFile) => { + baseActions.unpinFile(file.fileId); + }, [baseActions]); // Complete actions object const actions = useMemo(() => ({ @@ -178,8 +170,8 @@ function FileContextInner({ } } }, - updateFileRecord: (fileId: FileId, updates: Partial) => - lifecycleManager.updateFileRecord(fileId, updates, stateRef), + updateStirlingFileStub: (fileId: FileId, updates: Partial) => + lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef), reorderFiles: (orderedFileIds: FileId[]) => { dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } }); }, @@ -303,7 +295,7 @@ export { useFileSelection, useFileManagement, useFileUI, - useFileRecord, + useStirlingFileStub, useAllFiles, useSelectedFiles, // Primary API hooks for tools diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts index 93c06c4d2..4c4196764 100644 --- a/frontend/src/contexts/file/FileReducer.ts +++ b/frontend/src/contexts/file/FileReducer.ts @@ -6,7 +6,7 @@ import { FileId } from '../../types/file'; import { FileContextState, FileContextAction, - FileRecord + StirlingFileStub } from '../../types/fileContext'; // Initial state @@ -29,7 +29,7 @@ export const initialFileContextState: FileContextState = { function processFileSwap( state: FileContextState, filesToRemove: FileId[], - filesToAdd: FileRecord[] + filesToAdd: StirlingFileStub[] ): FileContextState { // Only remove unpinned files const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id)); @@ -70,11 +70,11 @@ function processFileSwap( export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { switch (action.type) { case 'ADD_FILES': { - const { fileRecords } = action.payload; + const { stirlingFileStubs } = action.payload; const newIds: FileId[] = []; - const newById: Record = { ...state.files.byId }; + const newById: Record = { ...state.files.byId }; - fileRecords.forEach(record => { + stirlingFileStubs.forEach(record => { // Only add if not already present (dedupe by stable ID) if (!newById[record.id]) { newIds.push(record.id); @@ -233,13 +233,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA } case 'CONSUME_FILES': { - const { inputFileIds, outputFileRecords } = action.payload; - return processFileSwap(state, inputFileIds, outputFileRecords); + const { inputFileIds, outputStirlingFileStubs } = action.payload; + return processFileSwap(state, inputFileIds, outputStirlingFileStubs); } case 'UNDO_CONSUME_FILES': { - const { inputFileRecords, outputFileIds } = action.payload; - return processFileSwap(state, outputFileIds, inputFileRecords); + const { inputStirlingFileStubs, outputFileIds } = action.payload; + return processFileSwap(state, outputFileIds, inputStirlingFileStubs); } case 'RESET_CONTEXT': { diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index e55108553..3901fabee 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -3,10 +3,10 @@ */ import { - FileRecord, + StirlingFileStub, FileContextAction, FileContextState, - toFileRecord, + toStirlingFileStub, createFileId, createQuickKey } from '../../types/fileContext'; @@ -109,8 +109,8 @@ export async function addFiles( await addFilesMutex.lock(); try { - const fileRecords: FileRecord[] = []; - const addedFiles: AddedFile[] = []; + const stirlingFileStubs: StirlingFileStub[] = []; + const addedFiles: AddedFile[] = []; // Build quickKey lookup from existing files for deduplication const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId); @@ -163,7 +163,7 @@ export async function addFiles( } // Create record with immediate thumbnail and page metadata - const record = toFileRecord(file, fileId); + const record = toStirlingFileStub(file, fileId); if (thumbnail) { record.thumbnailUrl = thumbnail; // Track blob URLs for cleanup (images return blob URLs that need revocation) @@ -184,7 +184,7 @@ export async function addFiles( } existingQuickKeys.add(quickKey); - fileRecords.push(record); + stirlingFileStubs.push(record); addedFiles.push({ file, id: fileId, thumbnail }); } break; @@ -205,7 +205,7 @@ export async function addFiles( const fileId = createFileId(); filesRef.current.set(fileId, file); - const record = toFileRecord(file, fileId); + const record = toStirlingFileStub(file, fileId); if (thumbnail) { record.thumbnailUrl = thumbnail; // Track blob URLs for cleanup (images return blob URLs that need revocation) @@ -226,7 +226,7 @@ export async function addFiles( } existingQuickKeys.add(quickKey); - fileRecords.push(record); + stirlingFileStubs.push(record); addedFiles.push({ file, id: fileId, thumbnail }); } break; @@ -254,7 +254,7 @@ export async function addFiles( filesRef.current.set(fileId, file); - const record = toFileRecord(file, fileId); + const record = toStirlingFileStub(file, fileId); // Generate processedFile metadata for stored files let pageCount: number = 1; @@ -301,7 +301,7 @@ export async function addFiles( } existingQuickKeys.add(quickKey); - fileRecords.push(record); + stirlingFileStubs.push(record); addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail }); } @@ -310,9 +310,9 @@ export async function addFiles( } // Dispatch ADD_FILES action if we have new files - if (fileRecords.length > 0) { - dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); - if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`); + if (stirlingFileStubs.length > 0) { + dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } }); + if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`); } return addedFiles; @@ -328,7 +328,7 @@ export async function addFiles( async function processFilesIntoRecords( files: File[], filesRef: React.MutableRefObject> -): Promise> { +): Promise> { return Promise.all( files.map(async (file) => { const fileId = createFileId(); @@ -347,7 +347,7 @@ async function processFilesIntoRecords( if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error); } - const record = toFileRecord(file, fileId); + const record = toStirlingFileStub(file, fileId); if (thumbnail) { record.thumbnailUrl = thumbnail; } @@ -365,10 +365,10 @@ async function processFilesIntoRecords( * Helper function to persist files to IndexedDB */ async function persistFilesToIndexedDB( - fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>, + stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>, indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise } ): Promise { - await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => { + await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => { try { await indexedDB.saveFile(file, fileId, thumbnail); } catch (error) { @@ -383,7 +383,6 @@ async function persistFilesToIndexedDB( export async function consumeFiles( inputFileIds: FileId[], outputFiles: File[], - stateRef: React.MutableRefObject, filesRef: React.MutableRefObject>, dispatch: React.Dispatch, indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise } | null @@ -391,11 +390,11 @@ export async function consumeFiles( if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`); // Process output files with thumbnails and metadata - const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef); + const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef); // Persist output files to IndexedDB if available if (indexedDB) { - await persistFilesToIndexedDB(outputFileRecords, indexedDB); + await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB); } // Dispatch the consume action @@ -403,21 +402,21 @@ export async function consumeFiles( type: 'CONSUME_FILES', payload: { inputFileIds, - outputFileRecords: outputFileRecords.map(({ record }) => record) + outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record) } }); - if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`); - + if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`); + // Return the output file IDs for undo tracking - return outputFileRecords.map(({ fileId }) => fileId); + return outputStirlingFileStubs.map(({ fileId }) => fileId); } /** * Helper function to restore files to filesRef and manage IndexedDB cleanup */ async function restoreFilesAndCleanup( - filesToRestore: Array<{ file: File; record: FileRecord }>, + filesToRestore: Array<{ file: File; record: StirlingFileStub }>, fileIdsToRemove: FileId[], filesRef: React.MutableRefObject>, indexedDB?: { deleteFile: (fileId: FileId) => Promise } | null @@ -440,7 +439,7 @@ async function restoreFilesAndCleanup( if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`); return; } - + // Restore the file to filesRef if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`); filesRef.current.set(record.id, file); @@ -455,7 +454,7 @@ async function restoreFilesAndCleanup( throw error; // Re-throw to trigger rollback }) ); - + // Execute all IndexedDB operations await Promise.all(indexedDBPromises); } @@ -466,28 +465,28 @@ async function restoreFilesAndCleanup( */ export async function undoConsumeFiles( inputFiles: File[], - inputFileRecords: FileRecord[], + inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[], stateRef: React.MutableRefObject, filesRef: React.MutableRefObject>, dispatch: React.Dispatch, indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise; deleteFile: (fileId: FileId) => Promise } | null ): Promise { - if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`); + if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputStirlingFileStubs.length} input files, removing ${outputFileIds.length} output files`); // Validate inputs - if (inputFiles.length !== inputFileRecords.length) { - throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`); + if (inputFiles.length !== inputStirlingFileStubs.length) { + throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`); } // Create a backup of current filesRef state for rollback const backupFilesRef = new Map(filesRef.current); - + try { // Prepare files to restore const filesToRestore = inputFiles.map((file, index) => ({ file, - record: inputFileRecords[index] + record: inputStirlingFileStubs[index] })); // Restore input files and clean up output files @@ -502,13 +501,13 @@ export async function undoConsumeFiles( dispatch({ type: 'UNDO_CONSUME_FILES', payload: { - inputFileRecords, + inputStirlingFileStubs, outputFileIds } }); - if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`); - + if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`); + } catch (error) { // Rollback filesRef to previous state if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error); diff --git a/frontend/src/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts index e1b8e5cc4..1907c8fb8 100644 --- a/frontend/src/contexts/file/fileHooks.ts +++ b/frontend/src/contexts/file/fileHooks.ts @@ -9,7 +9,7 @@ import { FileContextStateValue, FileContextActionsValue } from './contexts'; -import { FileRecord } from '../../types/fileContext'; +import { StirlingFileStub, StirlingFile } from '../../types/fileContext'; import { FileId } from '../../types/file'; /** @@ -38,13 +38,13 @@ export function useFileActions(): FileContextActionsValue { /** * Hook for current/primary file (first in list) */ -export function useCurrentFile(): { file?: File; record?: FileRecord } { +export function useCurrentFile(): { file?: File; record?: StirlingFileStub } { const { state, selectors } = useFileState(); const primaryFileId = state.files.ids[0]; return useMemo(() => ({ file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, - record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined + record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined }), [primaryFileId, selectors]); } @@ -87,7 +87,7 @@ export function useFileManagement() { addFiles: actions.addFiles, removeFiles: actions.removeFiles, clearAllFiles: actions.clearAllFiles, - updateFileRecord: actions.updateFileRecord, + updateStirlingFileStub: actions.updateStirlingFileStub, reorderFiles: actions.reorderFiles }), [actions]); } @@ -111,24 +111,24 @@ export function useFileUI() { /** * Hook for specific file by ID (optimized for individual file access) */ -export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } { +export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub } { const { selectors } = useFileState(); return useMemo(() => ({ file: selectors.getFile(fileId), - record: selectors.getFileRecord(fileId) + record: selectors.getStirlingFileStub(fileId) }), [fileId, selectors]); } /** * Hook for all files (use sparingly - causes re-renders on file list changes) */ -export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { +export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } { const { state, selectors } = useFileState(); return useMemo(() => ({ files: selectors.getFiles(), - records: selectors.getFileRecords(), + records: selectors.getStirlingFileStubs(), fileIds: state.files.ids }), [state.files.ids, selectors]); } @@ -136,12 +136,12 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: /** * Hook for selected files (optimized for selection-based UI) */ -export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { +export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } { const { state, selectors } = useFileState(); return useMemo(() => ({ files: selectors.getSelectedFiles(), - records: selectors.getSelectedFileRecords(), + records: selectors.getSelectedStirlingFileStubs(), fileIds: state.ui.selectedFileIds }), [state.ui.selectedFileIds, selectors]); } diff --git a/frontend/src/contexts/file/fileSelectors.ts b/frontend/src/contexts/file/fileSelectors.ts index 2111693cf..a004831cc 100644 --- a/frontend/src/contexts/file/fileSelectors.ts +++ b/frontend/src/contexts/file/fileSelectors.ts @@ -4,9 +4,11 @@ import { FileId } from '../../types/file'; import { - FileRecord, + StirlingFileStub, FileContextState, - FileContextSelectors + FileContextSelectors, + StirlingFile, + createStirlingFile } from '../../types/fileContext'; /** @@ -17,16 +19,24 @@ export function createFileSelectors( filesRef: React.MutableRefObject> ): FileContextSelectors { return { - getFile: (id: FileId) => filesRef.current.get(id), + getFile: (id: FileId) => { + const file = filesRef.current.get(id); + return file ? createStirlingFile(file, id) : undefined; + }, getFiles: (ids?: FileId[]) => { const currentIds = ids || stateRef.current.files.ids; - return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[]; + return currentIds + .map(id => { + const file = filesRef.current.get(id); + return file ? createStirlingFile(file, id) : undefined; + }) + .filter(Boolean) as StirlingFile[]; }, - getFileRecord: (id: FileId) => stateRef.current.files.byId[id], + getStirlingFileStub: (id: FileId) => stateRef.current.files.byId[id], - getFileRecords: (ids?: FileId[]) => { + getStirlingFileStubs: (ids?: FileId[]) => { const currentIds = ids || stateRef.current.files.ids; return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean); }, @@ -35,11 +45,14 @@ export function createFileSelectors( getSelectedFiles: () => { return stateRef.current.ui.selectedFileIds - .map(id => filesRef.current.get(id)) - .filter(Boolean) as File[]; + .map(id => { + const file = filesRef.current.get(id); + return file ? createStirlingFile(file, id) : undefined; + }) + .filter(Boolean) as StirlingFile[]; }, - getSelectedFileRecords: () => { + getSelectedStirlingFileStubs: () => { return stateRef.current.ui.selectedFileIds .map(id => stateRef.current.files.byId[id]) .filter(Boolean); @@ -52,26 +65,21 @@ export function createFileSelectors( getPinnedFiles: () => { return Array.from(stateRef.current.pinnedFiles) - .map(id => filesRef.current.get(id)) - .filter(Boolean) as File[]; + .map(id => { + const file = filesRef.current.get(id); + return file ? createStirlingFile(file, id) : undefined; + }) + .filter(Boolean) as StirlingFile[]; }, - getPinnedFileRecords: () => { + getPinnedStirlingFileStubs: () => { return Array.from(stateRef.current.pinnedFiles) .map(id => stateRef.current.files.byId[id]) .filter(Boolean); }, - isFilePinned: (file: File) => { - // Find FileId by matching File object properties - const fileId = (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => { - const storedFile = filesRef.current.get(id); - return storedFile && - storedFile.name === file.name && - storedFile.size === file.size && - storedFile.lastModified === file.lastModified; - }); - return fileId ? stateRef.current.pinnedFiles.has(fileId) : false; + isFilePinned: (file: StirlingFile) => { + return stateRef.current.pinnedFiles.has(file.fileId); }, // Stable signature for effects - prevents unnecessary re-renders @@ -90,9 +98,9 @@ export function createFileSelectors( /** * Helper for building quickKey sets for deduplication */ -export function buildQuickKeySet(fileRecords: Record): Set { +export function buildQuickKeySet(stirlingFileStubs: Record): Set { const quickKeys = new Set(); - Object.values(fileRecords).forEach(record => { + Object.values(stirlingFileStubs).forEach(record => { if (record.quickKey) { quickKeys.add(record.quickKey); } @@ -119,7 +127,7 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz export function getPrimaryFile( stateRef: React.MutableRefObject, filesRef: React.MutableRefObject> -): { file?: File; record?: FileRecord } { +): { file?: File; record?: StirlingFileStub } { const primaryFileId = stateRef.current.files.ids[0]; if (!primaryFileId) return {}; diff --git a/frontend/src/contexts/file/lifecycle.ts b/frontend/src/contexts/file/lifecycle.ts index 9986fe585..901a16943 100644 --- a/frontend/src/contexts/file/lifecycle.ts +++ b/frontend/src/contexts/file/lifecycle.ts @@ -3,7 +3,7 @@ */ import { FileId } from '../../types/file'; -import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext'; +import { FileContextAction, StirlingFileStub, ProcessedFilePage } from '../../types/fileContext'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -166,7 +166,7 @@ export class FileLifecycleManager { /** * Update file record with race condition guards */ - updateFileRecord = (fileId: FileId, updates: Partial, stateRef?: React.MutableRefObject): void => { + updateStirlingFileStub = (fileId: FileId, updates: Partial, stateRef?: React.MutableRefObject): void => { // Guard against updating removed files (race condition protection) if (!this.filesRef.current.has(fileId)) { if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`); diff --git a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts index 32543ec91..798fdc5b4 100644 --- a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts +++ b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts @@ -347,9 +347,9 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => { const malformedFiles: Array<{name: string}> = [ { name: 'valid.pdf' }, - // @ts-ignore - Testing runtime resilience + // @ts-expect-error - Testing runtime resilience { name: null }, - // @ts-ignore + // @ts-expect-error - Testing runtime resilience { name: undefined } ]; diff --git a/frontend/src/hooks/tools/shared/useBaseTool.ts b/frontend/src/hooks/tools/shared/useBaseTool.ts index 64e9af59e..643e9bca5 100644 --- a/frontend/src/hooks/tools/shared/useBaseTool.ts +++ b/frontend/src/hooks/tools/shared/useBaseTool.ts @@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig'; import { BaseToolProps } from '../../../types/tool'; import { ToolOperationHook } from './useToolOperation'; import { BaseParametersHook } from './useBaseParameters'; +import { StirlingFile } from '../../../types/fileContext'; interface BaseToolReturn { // File management - selectedFiles: File[]; + selectedFiles: StirlingFile[]; // Tool-specific hooks params: BaseParametersHook; diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index d8d35176d..7bfcc3d32 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -6,10 +6,8 @@ import { useToolState, type ProcessingProgress } from './useToolState'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolResources } from './useToolResources'; import { extractErrorMessage } from '../../../utils/toolErrorHandler'; -import { createOperation } from '../../../utils/toolOperationTracker'; +import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext'; import { ResponseHandler } from '../../../utils/toolResponseProcessor'; -import { FileId } from '../../../types/file'; -import { FileRecord } from '../../../types/fileContext'; // Re-export for backwards compatibility export type { ProcessingProgress, ResponseHandler }; @@ -104,7 +102,7 @@ export interface ToolOperationHook { progress: ProcessingProgress | null; // Actions - executeOperation: (params: TParams, selectedFiles: File[]) => Promise; + executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise; resetResults: () => void; clearError: () => void; cancelOperation: () => void; @@ -130,7 +128,7 @@ export const useToolOperation = ( config: ToolOperationConfig ): ToolOperationHook => { const { t } = useTranslation(); - const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext(); + const { addFiles, consumeFiles, undoConsumeFiles, actions: fileActions, selectors } = useFileContext(); // Composed hooks const { state, actions } = useToolState(); @@ -140,13 +138,13 @@ export const useToolOperation = ( // Track last operation for undo functionality const lastOperationRef = useRef<{ inputFiles: File[]; - inputFileRecords: FileRecord[]; + inputStirlingFileStubs: StirlingFileStub[]; outputFileIds: FileId[]; } | null>(null); const executeOperation = useCallback(async ( params: TParams, - selectedFiles: File[] + selectedFiles: StirlingFile[] ): Promise => { // Validation if (selectedFiles.length === 0) { @@ -160,9 +158,6 @@ export const useToolOperation = ( return; } - // Setup operation tracking - const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles); - recordOperation(fileId, operation); // Reset state actions.setLoading(true); @@ -173,8 +168,11 @@ 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: + case ToolType.singleFile: { // Individual file processing - separate API call per file const apiCallsConfig: ApiCallsConfig = { endpoint: config.endpoint, @@ -184,17 +182,18 @@ export const useToolOperation = ( }; processedFiles = await processFiles( params, - validFiles, + validRegularFiles, apiCallsConfig, actions.setProgress, actions.setStatus ); break; + } - case ToolType.multiFile: + case ToolType.multiFile: { // Multi-file processing - single API call with all files actions.setStatus('Processing files...'); - const formData = config.buildFormData(params, 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' }); @@ -202,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')) { + (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 { @@ -219,10 +218,11 @@ export const useToolOperation = ( } } break; + } case ToolType.custom: actions.setStatus('Processing files...'); - processedFiles = await config.customProcessor(params, validFiles); + processedFiles = await config.customProcessor(params, validRegularFiles); break; } @@ -242,46 +242,40 @@ 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}`); } } - + 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 - 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(); @@ -310,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; } @@ -325,18 +319,19 @@ export const useToolOperation = ( try { // Undo the consume operation - await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds); + await undoConsumeFiles(inputFiles, inputStirlingFileStubs, 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 +340,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/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index f47430fd2..8df1e2754 100644 --- a/frontend/src/hooks/useFileManager.ts +++ b/frontend/src/hooks/useFileManager.ts @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'; import { useIndexedDB } from '../contexts/IndexedDBContext'; import { FileMetadata } from '../types/file'; import { generateThumbnailForFile } from '../utils/thumbnailUtils'; -import { FileId } from '../types/file'; +import { FileId } from '../types/fileContext'; export const useFileManager = () => { const [loading, setLoading] = useState(false); diff --git a/frontend/src/hooks/useFileWithUrl.ts b/frontend/src/hooks/useFileWithUrl.ts index fd2f2d604..5176c1225 100644 --- a/frontend/src/hooks/useFileWithUrl.ts +++ b/frontend/src/hooks/useFileWithUrl.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { isFileObject } from '../types/fileContext'; /** * Hook to convert a File object to { file: File; url: string } format @@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u return useMemo(() => { if (!file) return null; - // Validate that file is a proper File or Blob object - if (!(file instanceof File) && !(file instanceof Blob)) { + // Validate that file is a proper File, StirlingFile, or Blob object + if (!isFileObject(file) && !(file instanceof Blob)) { console.warn('useFileWithUrl: Expected File or Blob, got:', file); return null; } diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index 4f0d77c0e..a6251db3c 100644 --- a/frontend/src/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/hooks/useIndexedDBThumbnail.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { FileMetadata } from "../types/file"; import { useIndexedDB } from "../contexts/IndexedDBContext"; import { generateThumbnailForFile } from "../utils/thumbnailUtils"; +import { FileId } from "../types/fileContext"; /** * Calculate optimal scale for thumbnail generation @@ -53,7 +54,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { // Try to load file from IndexedDB using new context if (file.id && indexedDB) { - const loadedFile = await indexedDB.loadFile(file.id); + const loadedFile = await indexedDB.loadFile(file.id as FileId); if (!loadedFile) { throw new Error('File not found in IndexedDB'); } @@ -70,7 +71,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { // Save thumbnail to IndexedDB for persistence if (file.id && indexedDB && thumbnail) { try { - await indexedDB.updateThumbnail(file.id, thumbnail); + await indexedDB.updateThumbnail(file.id as FileId, thumbnail); } catch (error) { console.warn('Failed to save thumbnail to IndexedDB:', error); } diff --git a/frontend/src/hooks/usePDFProcessor.ts b/frontend/src/hooks/usePDFProcessor.ts index ab3b5e007..b88bfbe2b 100644 --- a/frontend/src/hooks/usePDFProcessor.ts +++ b/frontend/src/hooks/usePDFProcessor.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { PDFDocument, PDFPage } from '../types/pageEditor'; import { pdfWorkerManager } from '../services/pdfWorkerManager'; +import { createQuickKey } from '../types/fileContext'; export function usePDFProcessor() { const [loading, setLoading] = useState(false); @@ -75,7 +76,7 @@ export function usePDFProcessor() { // Create pages without thumbnails initially - load them lazily for (let i = 1; i <= totalPages; i++) { pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, originalPageNumber: i, thumbnail: null, // Will be loaded lazily diff --git a/frontend/src/hooks/usePdfSignatureDetection.ts b/frontend/src/hooks/usePdfSignatureDetection.ts index 17f90f2d9..b14c1a637 100644 --- a/frontend/src/hooks/usePdfSignatureDetection.ts +++ b/frontend/src/hooks/usePdfSignatureDetection.ts @@ -1,13 +1,14 @@ import { useState, useEffect } from 'react'; import * as pdfjsLib from 'pdfjs-dist'; import { pdfWorkerManager } from '../services/pdfWorkerManager'; +import { StirlingFile } from '../types/fileContext'; export interface PdfSignatureDetectionResult { hasDigitalSignatures: boolean; isChecking: boolean; } -export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => { +export const usePdfSignatureDetection = (files: StirlingFile[]): PdfSignatureDetectionResult => { const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false); const [isChecking, setIsChecking] = useState(false); diff --git a/frontend/src/hooks/useThumbnailGeneration.ts b/frontend/src/hooks/useThumbnailGeneration.ts index 8eba26214..310634045 100644 --- a/frontend/src/hooks/useThumbnailGeneration.ts +++ b/frontend/src/hooks/useThumbnailGeneration.ts @@ -1,5 +1,6 @@ import { useCallback, useRef } from 'react'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; +import { createQuickKey } from '../types/fileContext'; import { FileId } from '../types/file'; // Request queue to handle concurrent thumbnail requests @@ -71,8 +72,8 @@ async function processRequestQueue() { console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`); - // Use file name as fileId for PDF document caching - const fileId = file.name + '_' + file.size + '_' + file.lastModified as FileId; + // Use quickKey for PDF document caching (same metadata, consistent format) + const fileId = createQuickKey(file) as FileId; const results = await thumbnailGenerationService.generateThumbnails( fileId, diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 55fe7f046..38a0c1923 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -35,8 +35,11 @@ function updatePosthogConsent(){ return; } const optIn = (window.CookieConsent as any).acceptedCategory('analytics'); - optIn? - posthog.opt_in_capturing() : posthog.opt_out_capturing(); + if (optIn) { + posthog.opt_in_capturing(); + } else { + posthog.opt_out_capturing(); + } console.log("Updated analytics consent: ", optIn? "opted in" : "opted out"); } diff --git a/frontend/src/services/enhancedPDFProcessingService.ts b/frontend/src/services/enhancedPDFProcessingService.ts index f9f067c30..2b6b18c8c 100644 --- a/frontend/src/services/enhancedPDFProcessingService.ts +++ b/frontend/src/services/enhancedPDFProcessingService.ts @@ -5,6 +5,7 @@ import { FileHasher } from '../utils/fileHash'; import { FileAnalyzer } from './fileAnalyzer'; import { ProcessingErrorHandler } from './processingErrorHandler'; import { pdfWorkerManager } from './pdfWorkerManager'; +import { createQuickKey } from '../types/fileContext'; export class EnhancedPDFProcessingService { private static instance: EnhancedPDFProcessingService; @@ -182,7 +183,7 @@ export class EnhancedPDFProcessingService { ): Promise { const arrayBuffer = await file.arrayBuffer(); const pdf = await pdfWorkerManager.createDocument(arrayBuffer); - + try { const totalPages = pdf.numPages; @@ -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, @@ -459,11 +460,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; + } } } @@ -519,10 +521,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/fileAnalyzer.ts b/frontend/src/services/fileAnalyzer.ts index 537692600..1705bb54e 100644 --- a/frontend/src/services/fileAnalyzer.ts +++ b/frontend/src/services/fileAnalyzer.ts @@ -148,15 +148,17 @@ export class FileAnalyzer { case 'immediate_full': return pageCount * baseTime; - case 'priority_pages': + case 'priority_pages': { // Estimate time for priority pages (first 10) const priorityPages = Math.min(pageCount, 10); return priorityPages * baseTime; + } - case 'progressive_chunked': + case 'progressive_chunked': { // Estimate time for first chunk (20 pages) const firstChunk = Math.min(pageCount, 20); return firstChunk * baseTime; + } default: return pageCount * baseTime; diff --git a/frontend/src/services/indexedDBManager.ts b/frontend/src/services/indexedDBManager.ts index 2048c021f..9251998a3 100644 --- a/frontend/src/services/indexedDBManager.ts +++ b/frontend/src/services/indexedDBManager.ts @@ -73,7 +73,7 @@ class IndexedDBManager { request.onsuccess = () => { const db = request.result; console.log(`Successfully opened ${config.name}`); - + // Set up close handler to clean up our references db.onclose = () => { console.log(`Database ${config.name} closed`); @@ -87,13 +87,11 @@ class IndexedDBManager { request.onupgradeneeded = (event) => { const db = request.result; const oldVersion = event.oldVersion; - + console.log(`Upgrading ${config.name} from v${oldVersion} to v${config.version}`); // Create or update object stores config.stores.forEach(storeConfig => { - let store: IDBObjectStore; - if (db.objectStoreNames.contains(storeConfig.name)) { // Store exists - for now, just continue (could add migration logic here) console.log(`Object store '${storeConfig.name}' already exists`); @@ -109,7 +107,7 @@ class IndexedDBManager { options.autoIncrement = storeConfig.autoIncrement; } - store = db.createObjectStore(storeConfig.name, options); + const store = db.createObjectStore(storeConfig.name, options); console.log(`Created object store '${storeConfig.name}'`); // Create indexes @@ -168,7 +166,7 @@ class IndexedDBManager { return new Promise((resolve, reject) => { const deleteRequest = indexedDB.deleteDatabase(name); - + deleteRequest.onerror = () => reject(deleteRequest.error); deleteRequest.onsuccess = () => { console.log(`Deleted database: ${name}`); @@ -224,4 +222,4 @@ export const DATABASE_CONFIGS = { } as DatabaseConfig } as const; -export const indexedDBManager = IndexedDBManager.getInstance(); \ No newline at end of file +export const indexedDBManager = IndexedDBManager.getInstance(); diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index fe3e314e0..c0d6929fd 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -130,7 +130,7 @@ export class PDFExportService { newDoc.setModificationDate(new Date()); const pdfBytes = await newDoc.save(); - return new Blob([pdfBytes], { type: 'application/pdf' }); + return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }); } /** @@ -176,7 +176,7 @@ export class PDFExportService { newDoc.setModificationDate(new Date()); const pdfBytes = await newDoc.save(); - return new Blob([pdfBytes], { type: 'application/pdf' }); + return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }); } diff --git a/frontend/src/services/pdfProcessingService.ts b/frontend/src/services/pdfProcessingService.ts index 065f53210..7ede9334d 100644 --- a/frontend/src/services/pdfProcessingService.ts +++ b/frontend/src/services/pdfProcessingService.ts @@ -1,6 +1,7 @@ import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing'; import { ProcessingCache } from './processingCache'; import { pdfWorkerManager } from './pdfWorkerManager'; +import { createQuickKey } from '../types/fileContext'; export class PDFProcessingService { private static instance: PDFProcessingService; @@ -113,7 +114,7 @@ export class PDFProcessingService { const thumbnail = canvas.toDataURL(); pages.push({ - id: `${file.name}-page-${i}`, + id: `${createQuickKey(file)}-page-${i}`, pageNumber: i, thumbnail, rotation: 0, diff --git a/frontend/src/services/pdfWorkerManager.ts b/frontend/src/services/pdfWorkerManager.ts index 0999c5c29..57fc841e0 100644 --- a/frontend/src/services/pdfWorkerManager.ts +++ b/frontend/src/services/pdfWorkerManager.ts @@ -1,6 +1,6 @@ /** * PDF.js Worker Manager - Centralized worker lifecycle management - * + * * Prevents infinite worker creation by managing PDF.js workers globally * and ensuring proper cleanup when operations complete. */ @@ -86,7 +86,7 @@ class PDFWorkerManager { const pdf = await loadingTask.promise; this.activeDocuments.add(pdf); this.workerCount++; - + return pdf; } catch (error) { // If document creation fails, make sure to clean up the loading task @@ -94,6 +94,7 @@ class PDFWorkerManager { try { loadingTask.destroy(); } catch (destroyError) { + // Ignore errors } } throw error; @@ -125,7 +126,7 @@ class PDFWorkerManager { documentsToDestroy.forEach(pdf => { this.destroyDocument(pdf); }); - + this.activeDocuments.clear(); this.workerCount = 0; } @@ -166,9 +167,10 @@ class PDFWorkerManager { try { pdf.destroy(); } catch (error) { + // Ignore errors } }); - + this.activeDocuments.clear(); this.workerCount = 0; } @@ -182,4 +184,4 @@ class PDFWorkerManager { } // Export singleton instance -export const pdfWorkerManager = PDFWorkerManager.getInstance(); \ No newline at end of file +export const pdfWorkerManager = PDFWorkerManager.getInstance(); diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index 41a768838..4efb41d7e 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -18,6 +18,8 @@ import { FileContextProvider } from '../../contexts/FileContext'; import { I18nextProvider } from 'react-i18next'; import i18n from '../../i18n/config'; import axios from 'axios'; +import { createTestStirlingFile } from '../utils/testFileHelpers'; +import { StirlingFile } from '../../types/fileContext'; // Mock axios vi.mock('axios'); @@ -55,9 +57,9 @@ const createTestFile = (name: string, content: string, type: string): File => { return new File([content], name, { type }); }; -const createPDFFile = (): File => { +const createPDFFile = (): StirlingFile => { const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF'; - return createTestFile('test.pdf', pdfContent, 'application/pdf'); + return createTestStirlingFile('test.pdf', pdfContent, 'application/pdf'); }; // Test wrapper component @@ -162,7 +164,7 @@ describe('Convert Tool Integration Tests', () => { wrapper: TestWrapper }); - const testFile = createTestFile('invalid.txt', 'not a pdf', 'text/plain'); + const testFile = createTestStirlingFile('invalid.txt', 'not a pdf', 'text/plain'); const parameters: ConvertParameters = { fromExtension: 'pdf', toExtension: 'png', @@ -426,7 +428,7 @@ describe('Convert Tool Integration Tests', () => { }); const files = [ createPDFFile(), - createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf') + createTestStirlingFile('test2.pdf', '%PDF-1.4...', 'application/pdf') ] const parameters: ConvertParameters = { fromExtension: 'pdf', @@ -527,7 +529,7 @@ describe('Convert Tool Integration Tests', () => { wrapper: TestWrapper }); - const corruptedFile = createTestFile('corrupted.pdf', 'not-a-pdf', 'application/pdf'); + const corruptedFile = createTestStirlingFile('corrupted.pdf', 'not-a-pdf', 'application/pdf'); const parameters: ConvertParameters = { fromExtension: 'pdf', toExtension: 'png', diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx index 4e9fb7908..2904135e0 100644 --- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -14,6 +14,8 @@ import i18n from '../../i18n/config'; import axios from 'axios'; import { detectFileExtension } from '../../utils/fileUtils'; import { FIT_OPTIONS } from '../../constants/convertConstants'; +import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers'; +import { StirlingFile } from '../../types/fileContext'; // Mock axios vi.mock('axios'); @@ -81,7 +83,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mock DOCX file - const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); + const docxFile = createTestStirlingFile('document.docx', 'docx content', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); // Test auto-detection act(() => { @@ -117,7 +119,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mock unknown file - const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' }); + const unknownFile = createTestStirlingFile('document.xyz', 'unknown content', 'application/octet-stream'); // Test auto-detection act(() => { @@ -156,11 +158,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mock image files - const imageFiles = [ - new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }), - new File(['png content'], 'photo2.png', { type: 'image/png' }), - new File(['gif content'], 'photo3.gif', { type: 'image/gif' }) - ]; + const imageFiles = createTestFilesWithId([ + { name: 'photo1.jpg', content: 'jpg content', type: 'image/jpeg' }, + { name: 'photo2.png', content: 'png content', type: 'image/png' }, + { name: 'photo3.gif', content: 'gif content', type: 'image/gif' } + ]); // Test smart detection for all images act(() => { @@ -202,11 +204,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mixed file types - const mixedFiles = [ - new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }), - new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), - new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }) - ]; + const mixedFiles = createTestFilesWithId([ + { name: 'document.pdf', content: 'pdf content', type: 'application/pdf' }, + { name: 'spreadsheet.xlsx', content: 'docx content', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + { name: 'presentation.pptx', content: 'pptx content', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' } + ]); // Test smart detection for mixed types act(() => { @@ -243,10 +245,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Create mock web files - const webFiles = [ - new File(['content'], 'page1.html', { type: 'text/html' }), - new File(['zip content'], 'site.zip', { type: 'application/zip' }) - ]; + const webFiles = createTestFilesWithId([ + { name: 'page1.html', content: 'content', type: 'text/html' }, + { name: 'site.zip', content: 'zip content', type: 'application/zip' } + ]); // Test smart detection for web files act(() => { @@ -288,7 +290,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const htmlFile = new File(['content'], 'page.html', { type: 'text/html' }); + const htmlFile = createTestStirlingFile('page.html', 'content', 'text/html'); // Set up HTML conversion parameters act(() => { @@ -318,7 +320,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' }); + const emlFile = createTestStirlingFile('email.eml', 'email content', 'message/rfc822'); // Set up email conversion parameters act(() => { @@ -355,7 +357,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }); + const pdfFile = createTestStirlingFile('document.pdf', 'pdf content', 'application/pdf'); // Set up PDF/A conversion parameters act(() => { @@ -392,10 +394,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const imageFiles = [ - new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), - new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) - ]; + const imageFiles = createTestFilesWithId([ + { name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' }, + { name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' } + ]); // Set up image conversion parameters act(() => { @@ -432,10 +434,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { wrapper: TestWrapper }); - const imageFiles = [ - new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), - new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) - ]; + const imageFiles = createTestFilesWithId([ + { name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' }, + { name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' } + ]); // Set up for separate processing act(() => { @@ -477,10 +479,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }) .mockRejectedValueOnce(new Error('File 2 failed')); - const mixedFiles = [ - new File(['file1'], 'doc1.txt', { type: 'text/plain' }), - new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' }) - ]; + const mixedFiles = createTestFilesWithId([ + { name: 'doc1.txt', content: 'file1', type: 'text/plain' }, + { name: 'doc2.xyz', content: 'file2', type: 'application/octet-stream' } + ]); // Set up for separate processing (mixed smart detection) act(() => { diff --git a/frontend/src/tests/utils/testFileHelpers.ts b/frontend/src/tests/utils/testFileHelpers.ts new file mode 100644 index 000000000..80b3c74cf --- /dev/null +++ b/frontend/src/tests/utils/testFileHelpers.ts @@ -0,0 +1,28 @@ +/** + * Test utilities for creating StirlingFile objects in tests + */ + +import { StirlingFile, createStirlingFile } from '../../types/fileContext'; + +/** + * Create a StirlingFile object for testing purposes + */ +export function createTestStirlingFile( + name: string, + content: string = 'test content', + type: string = 'application/pdf' +): StirlingFile { + const file = new File([content], name, { type }); + return createStirlingFile(file); +} + +/** + * Create multiple StirlingFile objects for testing + */ +export function createTestFilesWithId( + files: Array<{ name: string; content?: string; type?: string }> +): StirlingFile[] { + return files.map(({ name, content = 'test content', type = 'application/pdf' }) => + createTestStirlingFile(name, content, type) + ); +} \ No newline at end of file diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 9210f9ce9..f5d4cef0a 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -5,6 +5,9 @@ import { PageOperation } from './pageEditor'; import { FileId, FileMetadata } from './file'; +// Re-export FileId for convenience +export type { FileId }; + export type ModeType = | 'viewer' | 'pageEditor' @@ -41,25 +44,32 @@ export interface ProcessedFileMetadata { [key: string]: any; } -export interface FileRecord { - id: FileId; - name: string; - size: number; - type: string; - lastModified: number; - quickKey?: string; // Fast deduplication key: name|size|lastModified - thumbnailUrl?: string; - blobUrl?: string; - createdAt?: number; - processedFile?: ProcessedFileMetadata; - insertAfterPageId?: string; // Page ID after which this file should be inserted - isPinned?: boolean; +/** + * StirlingFileStub - Metadata record for files in the active workbench session + * + * Contains UI display data and processing state. Actual File objects stored + * separately in refs for memory efficiency. Supports multi-tool workflows + * where files persist across tool operations. + */ +export interface StirlingFileStub { + id: FileId; // UUID primary key for collision-free operations + name: string; // Display name for UI + size: number; // File size for progress indicators + type: string; // MIME type for format validation + lastModified: number; // Original timestamp for deduplication + quickKey?: string; // Fast deduplication key: name|size|lastModified + thumbnailUrl?: string; // Generated thumbnail blob URL for visual display + blobUrl?: string; // File access blob URL for downloads/processing + createdAt?: number; // When added to workbench for sorting + processedFile?: ProcessedFileMetadata; // PDF page data and processing results + insertAfterPageId?: string; // Page ID after which this file should be inserted + isPinned?: boolean; // Protected from tool consumption (replace/remove) // Note: File object stored in provider ref, not in state } export interface FileContextNormalizedFiles { ids: FileId[]; - byId: Record; + byId: Record; } // Helper functions - UUID-based primary keys (zero collisions, synchronous) @@ -82,9 +92,68 @@ export function createQuickKey(file: File): string { return `${file.name}|${file.size}|${file.lastModified}`; } +// Stirling PDF file with embedded UUID - replaces loose File + FileId parameter passing +export interface StirlingFile extends File { + readonly fileId: FileId; + readonly quickKey: string; // Fast deduplication key: name|size|lastModified +} + +// Type guard to check if a File object has an embedded fileId +export function isStirlingFile(file: File): file is StirlingFile { + return 'fileId' in file && typeof (file as any).fileId === 'string' && + 'quickKey' in file && typeof (file as any).quickKey === 'string'; +} + +// Create a StirlingFile from a regular File object +export function createStirlingFile(file: File, id?: FileId): StirlingFile { + const fileId = id || createFileId(); + const quickKey = createQuickKey(file); + + // Use Object.defineProperty to add properties while preserving the original File object + // This maintains proper method binding and avoids "Illegal invocation" errors + Object.defineProperty(file, 'fileId', { + value: fileId, + writable: false, + enumerable: true, + configurable: false + }); + + Object.defineProperty(file, 'quickKey', { + value: quickKey, + writable: false, + enumerable: true, + configurable: false + }); + + return file as StirlingFile; +} + +// Extract FileIds from StirlingFile array +export function extractFileIds(files: StirlingFile[]): FileId[] { + return files.map(file => file.fileId); +} + +// Extract regular File objects from StirlingFile array +export function extractFiles(files: StirlingFile[]): File[] { + return files as File[]; +} + +// Check if an object is a File or StirlingFile (replaces instanceof File checks) +export function isFileObject(obj: any): obj is File | StirlingFile { + return obj && + typeof obj.name === 'string' && + typeof obj.size === 'number' && + typeof obj.type === 'string' && + typeof obj.lastModified === 'number' && + typeof obj.arrayBuffer === 'function'; +} -export function toFileRecord(file: File, id?: FileId): FileRecord { + +export function toStirlingFileStub( + file: File, + id?: FileId +): StirlingFileStub { const fileId = id || createFileId(); return { id: fileId, @@ -97,7 +166,7 @@ export function toFileRecord(file: File, id?: FileId): FileRecord { }; } -export function revokeFileResources(record: FileRecord): void { +export function revokeFileResources(record: StirlingFileStub): void { // Only revoke blob: URLs to prevent errors on other schemes if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) { try { @@ -171,7 +240,7 @@ export interface FileContextState { // Core file management - lightweight file IDs only files: { ids: FileId[]; - byId: Record; + byId: Record; }; // Pinned files - files that won't be consumed by tools @@ -190,16 +259,16 @@ export interface FileContextState { // Action types for reducer pattern export type FileContextAction = // File management actions - | { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } } + | { type: 'ADD_FILES'; payload: { stirlingFileStubs: StirlingFileStub[] } } | { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } } - | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial } } + | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial } } | { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } } // Pinned files actions | { type: 'PIN_FILE'; payload: { fileId: FileId } } | { type: 'UNPIN_FILE'; payload: { fileId: FileId } } - | { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } } - | { type: 'UNDO_CONSUME_FILES'; payload: { inputFileRecords: FileRecord[]; outputFileIds: FileId[] } } + | { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputStirlingFileStubs: StirlingFileStub[] } } + | { type: 'UNDO_CONSUME_FILES'; payload: { inputStirlingFileStubs: StirlingFileStub[]; outputFileIds: FileId[] } } // UI actions | { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } } @@ -215,22 +284,22 @@ export type FileContextAction = export interface FileContextActions { // File management - lightweight actions only - addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise; - addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; - addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise; + addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise; + addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; + addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise; - updateFileRecord: (id: FileId, updates: Partial) => void; + updateStirlingFileStub: (id: FileId, updates: Partial) => void; reorderFiles: (orderedFileIds: FileId[]) => void; clearAllFiles: () => Promise; clearAllData: () => Promise; - // File pinning - pinFile: (file: File) => void; - unpinFile: (file: File) => void; + // File pinning - accepts StirlingFile for safer type checking + pinFile: (file: StirlingFile) => void; + unpinFile: (file: StirlingFile) => void; // File consumption (replace unpinned files with outputs) consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise; - undoConsumeFiles: (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]) => Promise; + undoConsumeFiles: (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]) => Promise; // Selection management setSelectedFiles: (fileIds: FileId[]) => void; setSelectedPages: (pageNumbers: number[]) => void; @@ -253,26 +322,17 @@ export interface FileContextActions { // File selectors (separate from actions to avoid re-renders) export interface FileContextSelectors { - // File access - no state dependency, uses ref - getFile: (id: FileId) => File | undefined; - getFiles: (ids?: FileId[]) => File[]; - - // Record access - uses normalized state - getFileRecord: (id: FileId) => FileRecord | undefined; - getFileRecords: (ids?: FileId[]) => FileRecord[]; - - // Derived selectors + getFile: (id: FileId) => StirlingFile | undefined; + getFiles: (ids?: FileId[]) => StirlingFile[]; + getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined; + getStirlingFileStubs: (ids?: FileId[]) => StirlingFileStub[]; getAllFileIds: () => FileId[]; - getSelectedFiles: () => File[]; - getSelectedFileRecords: () => FileRecord[]; - - // Pinned files selectors + getSelectedFiles: () => StirlingFile[]; + getSelectedStirlingFileStubs: () => StirlingFileStub[]; getPinnedFileIds: () => FileId[]; - getPinnedFiles: () => File[]; - getPinnedFileRecords: () => FileRecord[]; - isFilePinned: (file: File) => boolean; - - // Stable signature for effect dependencies + getPinnedFiles: () => StirlingFile[]; + getPinnedStirlingFileStubs: () => StirlingFileStub[]; + isFilePinned: (file: StirlingFile) => boolean; getFilesSignature: () => string; } @@ -293,6 +353,3 @@ export interface FileContextActionsValue { actions: FileContextActions; dispatch: (action: FileContextAction) => void; } - -// TODO: URL parameter types will be redesigned for new routing system - diff --git a/frontend/src/types/fileIdSafety.d.ts b/frontend/src/types/fileIdSafety.d.ts new file mode 100644 index 000000000..08888d4c0 --- /dev/null +++ b/frontend/src/types/fileIdSafety.d.ts @@ -0,0 +1,49 @@ +/** + * Type safety declarations to prevent file.name/UUID confusion + */ + +import { FileId, StirlingFile, OperationType, FileOperation } from './fileContext'; + +declare global { + namespace FileIdSafety { + // Mark functions that should never accept file.name as parameters + type SafeFileIdFunction any> = T extends (...args: infer P) => infer R + ? P extends readonly [string, ...any[]] + ? never // Reject string parameters in first position for FileId functions + : T + : T; + + // Mark functions that should only accept StirlingFile, not regular File + type StirlingFileOnlyFunction any> = T extends (...args: infer P) => infer R + ? P extends readonly [File, ...any[]] + ? never // Reject File parameters in first position for StirlingFile functions + : T + : T; + + // Utility type to enforce StirlingFile usage + type RequireStirlingFile = T extends File ? StirlingFile : T; + } + + // Extend Window interface for debugging + interface Window { + __FILE_ID_DEBUG?: boolean; + } +} + +// Augment FileContext types to prevent bypassing StirlingFile +declare module '../contexts/FileContext' { + export interface StrictFileContextActions { + pinFile: (file: StirlingFile) => void; // Must be StirlingFile + unpinFile: (file: StirlingFile) => void; // Must be StirlingFile + addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise; // Returns StirlingFile + consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise; // Returns StirlingFile + } + + export interface StrictFileContextSelectors { + getFile: (id: FileId) => StirlingFile | undefined; // Returns StirlingFile + getFiles: (ids?: FileId[]) => StirlingFile[]; // Returns StirlingFile[] + isFilePinned: (file: StirlingFile) => boolean; // Must be StirlingFile + } +} + +export {}; \ No newline at end of file diff --git a/frontend/src/types/parameters.ts b/frontend/src/types/parameters.ts index 6f8856a8b..91355cb81 100644 --- a/frontend/src/types/parameters.ts +++ b/frontend/src/types/parameters.ts @@ -1,7 +1,6 @@ // Base parameter interfaces for reusable patterns -export interface BaseParameters { - // Base interface that all tool parameters should extend - // Provides a foundation for adding common properties across all tools - // Examples of future additions: userId, sessionId, commonFlags, etc. -} \ No newline at end of file +// Base interface that all tool parameters should extend +// Provides a foundation for adding common properties across all tools +// Examples of future additions: userId, sessionId, commonFlags, etc. +export type BaseParameters = object diff --git a/frontend/src/utils/fileIdSafety.ts b/frontend/src/utils/fileIdSafety.ts new file mode 100644 index 000000000..3fda8a4a5 --- /dev/null +++ b/frontend/src/utils/fileIdSafety.ts @@ -0,0 +1,14 @@ +/** + * Runtime validation utilities for FileId safety + */ + +import { FileId } from '../types/fileContext'; + +// Validate that a string is a proper FileId (has UUID format) +export function isValidFileId(id: string): id is FileId { + // Check UUID v4 format: 8-4-4-4-12 hex digits + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(id); +} + +