diff --git a/.gitignore b/.gitignore index 8f36e4547..dd1b45dde 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,14 @@ # production /dist +# Reusable-components build output. `npm run build-reusable-components` +# writes the `dv-tree-view` / `dv-uploader` JS+CSS here; the files are +# then copied into `dataverse/.../webapp/reusable-components/` for the +# WAR bake. Tracking the build folder makes manual post-build cleanup +# brittle: `git checkout .` to clean stray edits also reverts the +# generated build files (or vice-versa, source-file edits get swept up +# alongside build-artefact resets). Ignore it instead. +/dist-reusable-components # storybook /storybook-static diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a05b41c..832f7d11d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,29 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- DVWebloader V2: A standalone file uploader build that reuses React file upload components, supporting S3 direct uploads with configurable tagging. +- Shared file upload hooks (`useFileUploadState`, `useFileUploadOperations`) for better code reuse between the main SPA and standalone uploader. +- Lazy file tree view on the dataset Files tab (`#6691`). A new `FilesViewToggle` flips between the existing files table and a virtualised, lazily-loaded folder tree. The tree supports tri-state path-keyed selection, full WAI-ARIA tree keyboard navigation (`Up/Down/Left/Right/Home/End/Space/Enter`), and URL bookmarkability via `?view=tree&path=`. +- Client-side streaming-zip download of the tree's selection. Multi-file selections are zipped in the browser (single new dependency: [`client-zip`](https://github.com/Touffy/client-zip), ~3 KB gzip); a bottom-sheet tray surfaces inline **Retry / Skip / Skip & retry at end / Skip all** decisions on per-file failures. Single-file downloads bypass the zip wrap and trigger a direct browser download. No server-side ZIP endpoint is involved. +- Standalone reusable bundle for the lazy tree (`dv-tree-view.js`), built by `vite.config.reusable-components.ts` alongside `dv-uploader`. Mounted on JSF dataset pages via the new `dataverse.feature.react-tree-view` flag in `IQSS/dataverse`. +- Domain layer for the new tree (`src/files/domain/{models,repositories,useCases}/`) plus an SDK-backed `FileTreeJSDataverseRepository` (with a `JSFileTreeMapper`) and a previews-derived `FileTreeFromPreviewsRepository` fallback for older Dataverse instances. + ### Changed +- Bumped `@iqss/dataverse-client-javascript` to `2.2.0-pr403.3d6f638` (GitHub Packages prerelease) to consume the new `listDatasetTreeNode` and `iterateDatasetTreeNode` use cases. The pinned hash also carries the `x-amz-tagging` default fix so older Dataverse releases without the matching server PR keep getting `dv-state=temp` from the client. +- Streaming-zip download fetches now use `credentials: 'same-origin'` (previously `'include'`). Cookies still travel on the same-origin Dataverse hop, but are dropped on the cross-origin redirect to S3 — required for `Allow-Origin: *` buckets to accept the request. +- Standalone bundles (`dv-tree-view`, `dv-uploader`) now mount inside a Shadow DOM root via `mountInShadowRoot`, isolating both directions from the host page's CSS context. +- Component CSS that references `var(--bs-*)` Bootstrap-5 tokens carries hardcoded fallback values, so the bundle renders correctly on JSF hosts that don't define those custom properties. + ### Fixed -### Removed +- Successful Save in the SPA file uploader no longer re-engages the "Discard Uploaded Files?" leave modal. The post-save navigation effect was racing `useBlocker`'s predicate-update effect across a parent/child boundary; the fix colocates the navigate effect with `useBlocker` in the same component so React fires the predicate update before the navigation runs. The standalone uploader (which uses `beforeunload` instead of `useBlocker`) didn't reproduce the original symptom but uses the same colocated shape now. +- Tree download/upload paths select an S3-compatible storage driver via the typed `storageDriver.type === 's3'` capability and the `directDownload` / `directUpload` flags, instead of the previous `s3*` name-prefix heuristic. Operator-renamed drivers (e.g. `minio1`) are now recognised correctly without depending on the driver id. +- Tree DOM focus follows the keyboard's roving tabindex when the bundle is mounted inside a Shadow DOM (JSF embed): the focus-grab effect now resolves the active element via `el.getRootNode()` instead of `document.activeElement`, so the `:focus-visible` ring tracks Up/Down arrow keys across the host-page boundary. +- Tree view now re-mounts cleanly when a JSF partial update re-inserts the host `
`. Both standalone bundles install a `MutationObserver` that detects host-element identity changes and re-runs `init()` (the orphaned-Root regression that left the tree empty after toggling Table↔Tree several times). +- `useFileTree` no longer leaves the loading spinner forever when the host component unmounts mid-fetch; a `mountedRef` guard skips state updates after unmount, and the version-key-change reset path bypasses the stale-closure cache short-circuit so a refetch fires on schedule. +- Tree-view header now exposes a tristate select-all checkbox in its dedicated select column. +- Streaming-zip download tray's close button is sized for normal-pointer hit-targets when the bundle renders inside a JSF page (was rendering as a tiny `link` button due to host-page font cascade). --- diff --git a/cypress.config.ts b/cypress.config.ts index 6998d3198..31fe32f73 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -15,6 +15,16 @@ export default defineConfig({ supportFile: 'tests/support/e2e.ts', setupNodeEvents(on) { on('file:preprocessor', vitePreprocessor(path.resolve(__dirname, './vite.config.ts'))) + on('task', { + // Diagnostic printer: cy.task('diag', value) prints to the Cypress + // run's stdout, which GitHub Actions captures. cy.log / console.log + // from inside cy.then() run browser-side and never reach CI logs. + diag(value: unknown) { + // eslint-disable-next-line no-console + console.log('[diag]', JSON.stringify(value)) + return null + } + }) }, defaultCommandTimeout: 10_000 // https://docs.cypress.io/guides/references/configuration#Timeouts }, diff --git a/docs/reusable-components.md b/docs/reusable-components.md new file mode 100644 index 000000000..91737536e --- /dev/null +++ b/docs/reusable-components.md @@ -0,0 +1,331 @@ +# Reusable Components + +How to build, ship, and consume Dataverse frontend components that work in **both** the React SPA and the legacy JSF UI. + +This document is the **frontend** half of the contract. The matching backend half — JSF feature flags, JSF mount points, and operator setup — is documented in the Dataverse operator guide: [Reusable Frontend Components](https://guides.dataverse.org/en/latest/container/running/reusable-components.html). Read both before changing the contract. + +- [Why dual-mode](#why-dual-mode) +- [The contract](#the-contract) +- [Build pipeline](#build-pipeline) +- [Authentication](#authentication) +- [CSS isolation](#css-isolation) +- [Adding a new reusable component](#adding-a-new-reusable-component) +- [Making an existing SPA component reusable](#making-an-existing-spa-component-reusable) +- [Currently shipped components](#currently-shipped-components) +- [Testing reusable components](#testing-reusable-components) +- [Versioning and breaking changes](#versioning-and-breaking-changes) + +## Why dual-mode + +Dataverse is multi-year migrating from JSF to a React SPA. Some pages are SPA, many are still JSF, and a few are mixed. We don't want two implementations of the same feature; we want one React component that runs in both places. The pattern: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ One React component (built once) │ +│ │ +│ Mounts via window.Config in JSF (direct mount) │ +│ Mounts via React props in the SPA (no config object) │ +└─────────────────────────────────────────────────────────────┘ +``` + +The bundle is loaded as a regular ` + +``` + +Feature flag (server-side): `dataverse.feature.react-uploader`. + +### Tree view (`#6691`) + +Built on the same pattern. The SPA section lives at `src/sections/dataset/dataset-files/files-tree/`; the standalone wrapper is in `src/standalone-tree-view/` and is the second entry point in `vite.config.reusable-components.ts` (`dv-tree-view`). The bundle config interface is `window.dvTreeViewConfig` (see [`src/standalone-tree-view/config.ts`](../src/standalone-tree-view/config.ts)). + +Feature flag (server-side): `dataverse.feature.react-tree-view`. + +The tree view ships: + +- Lazy folder loading with an opaque keyset cursor. +- Path-keyed tri-state selection (folders without descendant enumeration; logical until download time). +- Visible-row virtualisation; no `react-virtual` / `react-window` dep. +- Full WAI-ARIA tree keyboard navigation (`ArrowUp/Down/Left/Right`, `Home/End`, `Space`, `Enter`). +- URL bookmarkability: `?view=tree&path=` round-trips and pre-fetches every ancestor on mount. +- **Client-side streaming-zip download.** Multi-file selections are zipped in the browser via [`client-zip`](https://github.com/Touffy/client-zip) (~3 KB gzip, the only new dep introduced by the tree). A bottom-sheet tray (`FilesTreeDownloadTray`) shows progress, the file currently being added, and surfaces an inline **Retry / Skip / Skip & retry at end / Skip all** decision row when a fetch fails. _Skip & retry at end_ converts the run into a two-pass flow mid-flight (failures accumulate as recoverable, then the tray prompts to retry them at the end). _Skip all_ switches to skip-with-manifest and writes a `manifest.txt` listing the failures into the root of the zip. Single-file downloads bypass the zip wrap and anchor-click `file.downloadUrl` directly. **No server contract changes.** Per-file fetches use `credentials: 'same-origin'` (not `'include'`) so the browser drops cookies on the cross-origin S3 hop after a `download-redirect=true` 302 — including credentials there would force `Access-Control-Allow-Credentials: true` on every S3 response and break against `Allow-Origin: *` rules. +- **Header select-all checkbox.** Tristate (none / partial / all). Selects every top-level item when nothing is selected, clears everything otherwise. + +## Testing reusable components + +- **SPA tests run as Cypress component tests** under `tests/component/...`, using `cy.customMount` so the React tree gets the same `Router`, `I18nextProvider`, `ThemeProvider`, and `ExternalToolsProvider` it would in production. +- **Standalone wrapper tests** mount the standalone component with a stubbed `window.`. Verify the inline error path (config missing) explicitly — JSF callers cannot see thrown exceptions. +- **Unit-test transformers and config parsers** in plain TypeScript files under `tests/component//...spec.ts`. No Cypress for pure-TS code. +- **Storybook** stories may be added for components that benefit from visual review. Not required. +- **Coverage threshold** is 95% on `src/sections/**/*.{ts,tsx}` (`.nycrc.json`). Reusable components count. + +When in doubt about a test, look at: + +- `tests/component/sections/shared/file-uploader/FileUploaderPanelCore.spec.tsx` +- `tests/component/sections/dataset/dataset-files/files-tree/FilesTree.spec.tsx` + +## Versioning and breaking changes + +The reusable bundle is consumed cross-repo: + +- The SPA and the standalone bundle move together (same git tag). +- Dataverse's `reusable-components` directory is **served from the SPA build output**. There is no separate package version on the JSF side beyond the file path it loads. +- A breaking change to a config interface is a coordinated change across `dataverse-frontend` and `dataverse` (the JSF page that sets `window.`). + +Rules of thumb: + +- **Add fields**, never remove. The host might be on an older JSF page. +- **Default to no-op** when a config field is unrecognised. Don't throw; log a `console.warn`. +- **Bump the file name (`dv-uploader.v2.js`) only on truly breaking changes** — a renamed config field, a removed mount path. Otherwise you fork the integration permanently. +- **Document the new shape in this file** and in the matching backend doc on the same PR. Reviewers should see both halves. diff --git a/package-lock.json b/package-lock.json index c5b242488..06c65879b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.1.0-alpha.4", + "@iqss/dataverse-client-javascript": "2.2.0-pr403.3d6f638", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -28,6 +28,7 @@ "async-mutex": "0.5.0", "bootstrap": "5.2.3", "classnames": "2.5.1", + "client-zip": "^2.5.0", "dompurify": "3.2.7", "html-react-parser": "3.0.16", "i18next": "25.6.0", @@ -148,9 +149,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -230,93 +231,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "regexpu-core": "^6.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", - "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/helper-define-polyfill-provider/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -326,21 +240,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", @@ -371,20 +270,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", @@ -394,59 +279,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -474,22 +306,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helpers": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", @@ -504,9 +320,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -518,109 +334,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -676,23 +389,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", @@ -714,958 +410,32 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-reserved-words": { + "node_modules/@babel/plugin-syntax-jsx": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1676,119 +446,92 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { "node": ">=6.9.0" @@ -1797,16 +540,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { "node": ">=6.9.0" @@ -1815,102 +556,30 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-env": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", - "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/compat-data": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.0", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.15", - "babel-plugin-polyfill-corejs3": "^0.14.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "core-js-compat": "^3.48.0", - "semver": "^6.3.1" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1919,20 +588,20 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/runtime": { @@ -3285,9 +1954,9 @@ } }, "node_modules/@iqss/dataverse-client-javascript": { - "version": "2.1.0-alpha.4", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.1.0-alpha.4/0a0dc68d4d99581d7ec017e58dbce3407f99f5d9", - "integrity": "sha512-UwHnFSYuvhxpc/JG2cFr5+bwERZXqGfNBTMSUQwtJaj6vfHO7anJAAxAx8g/AB8b4JtR9rljnYjAbsGJXicfBw==", + "version": "2.2.0-pr403.3d6f638", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.2.0-pr403.3d6f638/4f2b4704537956f2bc708093581bb6cd5579ee3a", + "integrity": "sha512-1R7EPEK8r+b2eHyldM9vwA7SWbLVvkrkLyZRV03Ntr32t6ublf3PzmMVpv4YauO7ed8/QJ3vKt+IJm2J1CZGBg==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", @@ -3298,9 +1967,9 @@ } }, "node_modules/@iqss/dataverse-client-javascript/node_modules/@types/node": { - "version": "18.19.127", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.127.tgz", - "integrity": "sha512-gSjxjrnKXML/yo0BO099uPixMqfpJU0TKYjpfLU7TrtA2WWDki412Np/RSTPRil1saKBhvVVKzVx/p/6p94nVA==", + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -4792,18 +3461,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -7019,6 +5676,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7032,6 +5690,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7045,6 +5704,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7058,6 +5718,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7071,6 +5732,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7084,6 +5746,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7097,6 +5760,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7110,6 +5774,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7123,6 +5788,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7136,6 +5802,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7149,6 +5816,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7162,6 +5830,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7175,6 +5844,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7188,6 +5858,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7201,6 +5872,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7214,6 +5886,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7227,6 +5900,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7240,6 +5914,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7253,6 +5928,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7266,6 +5942,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7279,6 +5956,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7292,6 +5970,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8794,6 +7473,7 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -9574,34 +8254,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/graceful-fs": { @@ -9913,9 +8570,9 @@ "optional": true }, "node_modules/@types/turndown": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz", - "integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", "license": "MIT" }, "node_modules/@types/unist": { @@ -10408,198 +9065,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true - }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -10680,7 +9145,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -10695,23 +9160,9 @@ "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", "dev": true, "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" } }, "node_modules/acorn-jsx": { @@ -10761,6 +9212,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", @@ -10818,20 +9270,6 @@ } } }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -10937,6 +9375,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, "license": "MIT", "dependencies": { "default-require-extensions": "^3.0.0" @@ -10977,6 +9416,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -11349,154 +9789,6 @@ "@babel/core": "^7.8.0" } }, - "node_modules/babel-loader": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", - "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "find-cache-dir": "^4.0.0", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0", - "webpack": ">=5" - } - }, - "node_modules/babel-loader/node_modules/find-cache-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", - "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "common-path-prefix": "^3.0.0", - "pkg-dir": "^7.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/babel-loader/node_modules/pkg-dir": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", - "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "find-up": "^6.3.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -11564,51 +9856,6 @@ "dev": true, "license": "MIT" }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", - "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.8", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", - "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", - "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/babel-plugin-styled-components": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.4.tgz", @@ -11719,9 +9966,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", - "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -12004,7 +10251,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/byte-size": { @@ -12112,6 +10359,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, "license": "MIT", "dependencies": { "hasha": "^5.0.0", @@ -12127,6 +10375,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^6.0.0" @@ -12142,6 +10391,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", @@ -12436,17 +10686,6 @@ } } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/ci-info": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", @@ -12479,6 +10718,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12553,6 +10793,12 @@ "node": ">= 12" } }, + "node_modules/client-zip": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/client-zip/-/client-zip-2.5.0.tgz", + "integrity": "sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -12815,14 +11061,6 @@ "dev": true, "license": "ISC" }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", @@ -12837,6 +11075,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, "license": "MIT" }, "node_modules/compare-func": { @@ -13065,21 +11304,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, - "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -13202,6 +11426,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -13593,6 +11818,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13726,6 +11952,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, "license": "MIT", "dependencies": { "strip-bom": "^4.0.0" @@ -14226,9 +12453,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.344", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", - "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", "license": "ISC" }, "node_modules/emittery": { @@ -14248,12 +12475,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14264,6 +12493,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14283,21 +12513,6 @@ "once": "^1.4.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -14471,14 +12686,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -14541,6 +12748,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, "license": "MIT" }, "node_modules/esbuild": { @@ -15226,17 +13434,6 @@ "dev": true, "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", @@ -15570,6 +13767,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, "license": "MIT", "dependencies": { "commondir": "^1.0.1", @@ -15587,6 +13785,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^6.0.0" @@ -15865,6 +14064,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true, "funding": [ { "type": "github", @@ -16047,6 +14247,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -16517,14 +14718,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -16832,6 +15025,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, "license": "MIT", "dependencies": { "is-stream": "^2.0.0", @@ -16848,6 +15042,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" @@ -16950,6 +15145,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, "license": "MIT" }, "node_modules/html-parse-stringify": { @@ -17259,6 +15455,7 @@ "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" @@ -17684,6 +15881,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17936,6 +16134,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -18010,6 +16209,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, "license": "MIT" }, "node_modules/is-unc-path": { @@ -18132,6 +16332,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/isstream": { @@ -18145,6 +16346,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -18154,6 +16356,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "append-transform": "^2.0.0" @@ -18206,6 +16409,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, "license": "ISC", "dependencies": { "archy": "^1.0.0", @@ -18223,6 +16427,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -18232,6 +16437,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" @@ -18244,6 +16450,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -18253,6 +16460,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", @@ -18267,6 +16475,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "debug": "^4.1.1", @@ -18281,6 +16490,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", @@ -21932,21 +20142,6 @@ "node": ">=8" } }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -21969,18 +20164,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.get": { @@ -22161,6 +20349,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -22176,6 +20365,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -23504,6 +21694,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -23819,6 +22010,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, "license": "MIT", "dependencies": { "process-on-spawn": "^1.0.0" @@ -24268,6 +22460,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/load-nyc-config": "^1.0.0", @@ -24309,6 +22502,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -24320,12 +22514,14 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, "license": "MIT" }, "node_modules/nyc/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -24339,6 +22535,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -24353,6 +22550,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -24373,6 +22571,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.7.5", @@ -24388,6 +22587,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -24400,6 +22600,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^6.0.0" @@ -24415,6 +22616,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -24430,6 +22632,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -24442,6 +22645,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" @@ -24454,6 +22658,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -24463,6 +22668,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -24477,12 +22683,14 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, "license": "ISC" }, "node_modules/nyc/node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^6.0.0", @@ -24505,6 +22713,7 @@ "version": "18.1.3", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, "license": "ISC", "dependencies": { "camelcase": "^5.0.0", @@ -24977,6 +23186,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, "license": "ISC", "dependencies": { "graceful-fs": "^4.1.15", @@ -25514,6 +23724,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -25633,6 +23844,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, "license": "MIT", "dependencies": { "find-up": "^4.0.0" @@ -25645,6 +23857,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -25658,6 +23871,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -25670,6 +23884,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -25685,6 +23900,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -25766,6 +23982,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -25987,6 +24204,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.1.0.tgz", "integrity": "sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==", + "dev": true, "license": "MIT", "dependencies": { "fromentries": "^1.2.0" @@ -26461,6 +24679,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -26562,6 +24781,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -27152,28 +25372,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -27207,51 +25405,11 @@ "url": "https://github.com/sponsors/mysticatea" } }, - "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/regjsparser": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", - "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "jsesc": "~3.1.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, "node_modules/release-zalgo": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, "license": "ISC", "dependencies": { "es6-error": "^4.0.1" @@ -27358,6 +25516,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -27376,6 +25535,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, "license": "ISC" }, "node_modules/requireindex": { @@ -27534,6 +25694,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -27550,6 +25711,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -27570,6 +25732,7 @@ "version": "4.52.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz", "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -27748,7 +25911,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sass": { @@ -27785,49 +25948,10 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/schema-utils/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "loose-envify": "^1.1.0" } }, "node_modules/semver": { @@ -27843,6 +25967,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, "license": "ISC" }, "node_modules/set-function-length": { @@ -27902,6 +26027,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -27914,6 +26040,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -28008,6 +26135,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/sigstore": { @@ -28157,6 +26285,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -28203,6 +26332,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^2.0.0", @@ -28220,6 +26350,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -28233,6 +26364,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -28242,6 +26374,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^6.0.0" @@ -28483,6 +26616,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -28600,6 +26734,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -28626,6 +26761,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -29381,21 +27517,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -29511,114 +27632,6 @@ "node": ">=4" } }, - "node_modules/terser": { - "version": "5.46.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", - "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", - "devOptional": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/terser-webpack-plugin/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "devOptional": true, - "license": "MIT", - "peer": true - }, - "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -30328,6 +28341,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, "license": "MIT", "dependencies": { "is-typedarray": "^1.0.0" @@ -30416,54 +28430,6 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/unified": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", @@ -31034,6 +29000,7 @@ "version": "5.4.20", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -31209,6 +29176,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31225,6 +29193,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31241,6 +29210,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31257,6 +29227,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31273,6 +29244,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31289,6 +29261,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31305,6 +29278,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31321,6 +29295,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31337,6 +29312,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31353,6 +29329,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31369,6 +29346,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31385,6 +29363,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31401,6 +29380,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31417,6 +29397,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31433,6 +29414,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31449,6 +29431,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31465,6 +29448,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31481,6 +29465,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31497,6 +29482,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31513,6 +29499,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31529,6 +29516,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31545,6 +29533,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31561,6 +29550,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31574,6 +29564,7 @@ "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -31788,21 +29779,6 @@ "loose-envify": "^1.0.0" } }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -31829,66 +29805,6 @@ "node": ">=12" } }, - "node_modules/webpack": { - "version": "5.106.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", - "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.1", - "mime-db": "^1.54.0", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.0.tgz", - "integrity": "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -31896,17 +29812,6 @@ "dev": true, "license": "MIT" }, - "node_modules/webpack/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", @@ -31961,6 +29866,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -32041,6 +29947,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, "license": "ISC" }, "node_modules/which-typed-array": { diff --git a/package.json b/package.json index 6d54dca10..a4bf7ad5d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.1.0-alpha.4", + "@iqss/dataverse-client-javascript": "2.2.0-pr403.3d6f638", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -33,6 +33,7 @@ "async-mutex": "0.5.0", "bootstrap": "5.2.3", "classnames": "2.5.1", + "client-zip": "^2.5.0", "dompurify": "3.2.7", "html-react-parser": "3.0.16", "i18next": "25.6.0", @@ -64,6 +65,7 @@ "scripts": { "start": "vite", "build": "tsc && vite build", + "build-reusable-components": "vite build --config vite.config.reusable-components.ts && cp -r public/locales dist-reusable-components/ && cp src/standalone-uploader/dvUploader.html dist-reusable-components/ && cp src/standalone-tree-view/dvTreeView.html dist-reusable-components/ && sass --no-source-map src/standalone-uploader/standalone-page.scss dist-reusable-components/standalone-page.css", "build-keycloak-theme": "npm run build && keycloakify build", "preview": "vite preview", "lint": "npm run typecheck && npm run lint:eslint && npm run lint:stylelint && npm run lint:prettier", diff --git a/packages/design-system/src/lib/components/button/Button.tsx b/packages/design-system/src/lib/components/button/Button.tsx index bbb0f94ad..e3db8e18d 100644 --- a/packages/design-system/src/lib/components/button/Button.tsx +++ b/packages/design-system/src/lib/components/button/Button.tsx @@ -12,6 +12,7 @@ interface ButtonProps extends ButtonHTMLAttributes { size?: ButtonSize variant?: ButtonVariant disabled?: boolean + active?: boolean onClick?: (event: MouseEvent) => void icon?: IconName | ReactNode withSpacing?: boolean @@ -23,6 +24,7 @@ export function Button({ size, variant = 'primary', disabled = false, + active, onClick, icon, withSpacing, @@ -35,6 +37,7 @@ export function Button({ size={size} className={withSpacing ? styles.spacing : ''} variant={variant} + active={active} onClick={disabled ? undefined : onClick} disabled={disabled} aria-disabled={disabled} diff --git a/packages/design-system/tsconfig.json b/packages/design-system/tsconfig.json index 9b257bf63..002865b24 100644 --- a/packages/design-system/tsconfig.json +++ b/packages/design-system/tsconfig.json @@ -15,7 +15,7 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx" }, "include": ["src", "tests", "vite.config.ts", "cypress.config.ts", ".storybook/test-runner.ts"], "exclude": ["tests/**/**.spec.ts", "tests/**/**.spec.tsx"] diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 447cccfbe..26f6236ad 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -1,6 +1,96 @@ { "files": "Files", "filesLoading": "Files loading spinner symbol", + "view": { + "toggle": { + "label": "Files view selector", + "changeView": "Change View", + "table": "Table", + "tree": "Tree" + } + }, + "tree": { + "label": "Dataset files", + "toolbar": { + "label": "Selection actions" + }, + "head": { + "name": "Name", + "size": "Size", + "count": "Files", + "actions": "Actions" + }, + "row": { + "expandFolder": "Expand {{name}}", + "collapseFolder": "Collapse {{name}}", + "selectFolder": "Select folder {{name}}", + "selectFile": "Select file {{name}}", + "downloadFolder": "Download folder {{name}}", + "downloadFile": "Download file {{name}}" + }, + "selection": { + "none": "No files selected", + "fileCount_one": "file", + "fileCount_other": "files", + "includesFolders": "folders included", + "clear": "Clear" + }, + "download": { + "button": "Download zip", + "enumerating": "Listing files…", + "preparing": "Preparing zip…", + "streaming": "Streaming…", + "tray": { + "label": "Zip download progress", + "preparing": "Preparing zip…", + "streaming": "Streaming files into zip…", + "paused": "Download paused — file failed", + "firstPass_one": "First pass complete — {{count}} file failed", + "firstPass_other": "First pass complete — {{count}} files failed", + "complete": "Download complete", + "completeWithSkipped_one": "Download complete — {{count}} skipped", + "completeWithSkipped_other": "Download complete — {{count}} skipped", + "error": "Download failed", + "cancelled": "Download cancelled", + "files": "files", + "pass2": "pass 2 of 2", + "now": { + "paused": "Paused", + "awaiting": "Awaiting retry decision", + "done": "Done", + "cancelled": "Cancelled" + }, + "failed": "Failed to fetch file", + "retry": "Retry this file", + "skip": "Skip", + "deferToEnd": "Skip & retry at end", + "skipAll": "Skip all remaining failures", + "notIncluded_one": "{{count}} file was not included in the zip", + "notIncluded_other": "{{count}} files were not included in the zip", + "twopassHint": "First pass finished. The zip will be finalised after a second-pass retry of the failures.", + "retryFailed_one": "Download {{count}} missing file", + "retryFailed_other": "Download {{count}} missing files", + "done": "Done", + "skipped_one": "{{count}} file skipped", + "skipped_other": "{{count}} files skipped", + "skippedManifest": "A manifest.txt listing skipped files has been added to the root of the zip.", + "hint": "Streamed locally into one zip — no server-side ZIP.", + "cancel": "Cancel", + "close": "Close" + } + }, + "state": { + "loading": "Loading file index…", + "loadingFolder": "Loading folder…", + "loadingMore": "Loading…", + "loadMore": "Load more", + "error": "Couldn't load file index", + "retry": "Retry", + "empty": "This dataset has no files", + "noMatches": "No files match \"{{query}}\"" + } + }, + "errorUnkownGetFilesFromDataset": "There was an error getting the files total download size", "errorUnkownGetFilesCountInfo": "There was an error getting the files count info", "errorUnkownGetFilesTotalDownloadSize": "There was an error getting the files total download size", diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index e00b67b0b..850c0a97a 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -218,6 +218,7 @@ "accordionTitle": "Upload with HTTP via your browser", "selectFileSingle": "Select file to add", "selectFileMultiple": "Select files to add", + "selectFolder": "Select folder to add", "dragDropSingle": "Drag and drop file here.", "dragDropMultiple": "Drag and drop files and/or directories here.", "uploadWidgetHelp": "Select files or drag and drop into the upload widget. Maximum of {{maxFilesPerUpload}} files per upload.", diff --git a/src/dataset/domain/models/Dataset.ts b/src/dataset/domain/models/Dataset.ts index 4fb18403a..ddaab5de9 100644 --- a/src/dataset/domain/models/Dataset.ts +++ b/src/dataset/domain/models/Dataset.ts @@ -27,6 +27,22 @@ export class DatasetLabel { ) {} } +/** + * The dataset's effective storage driver, surfaced to the SPA so feature + * gates can ask the right question — "is this driver capable of + * browser-direct upload to S3-compatible storage?" — instead of pattern- + * matching on the driver id (which is just an operator-chosen label). + * + * Mirrors the shape of `GET /api/datasets/{id}/storageDriver`. + */ +export interface DatasetStorageDriver { + name: string + type: string + label: string + directUpload: boolean + directDownload: boolean +} + // Only for testing purposes and checking the existence of the citation block in some parts of the code export enum MetadataBlockName { CITATION = 'citation', @@ -439,7 +455,8 @@ export class Dataset { public readonly nextMinorVersion?: string, public readonly requiresMajorVersionUpdate?: boolean, public readonly fileStore?: string, - public readonly guestbookId?: number + public readonly guestbookId?: number, + public readonly storageDriver?: DatasetStorageDriver ) {} public checkIsLockedFromPublishing(userPersistentId: string): boolean { @@ -535,7 +552,8 @@ export class Dataset { public readonly nextMinorVersionNumber?: string, public readonly requiresMajorVersionUpdate?: boolean, public readonly fileStore?: string, - public readonly guestbookId?: number + public readonly guestbookId?: number, + public readonly storageDriver?: DatasetStorageDriver ) { this.withAlerts() } @@ -608,7 +626,8 @@ export class Dataset { this.nextMinorVersionNumber, this.requiresMajorVersionUpdate, this.fileStore, - this.guestbookId + this.guestbookId, + this.storageDriver ) } } diff --git a/src/dataset/infrastructure/mappers/JSDatasetMapper.ts b/src/dataset/infrastructure/mappers/JSDatasetMapper.ts index 3358c7d3d..87a5a25b6 100644 --- a/src/dataset/infrastructure/mappers/JSDatasetMapper.ts +++ b/src/dataset/infrastructure/mappers/JSDatasetMapper.ts @@ -19,6 +19,7 @@ import { DatasetMetadataBlocks, DatasetMetadataFields, DatasetPermissions, + DatasetStorageDriver, DatasetVersion, MetadataBlockName, PrivateUrl @@ -49,7 +50,8 @@ export class JSDatasetMapper { latestPublishedVersionMajorNumber?: number, latestPublishedVersionMinorNumber?: number, datasetVersionDiff?: JSDatasetVersionDiff, - fileStore?: string + fileStore?: string, + storageDriver?: DatasetStorageDriver ): Dataset { const version = JSDatasetVersionMapper.toVersion( jsDataset.versionId, @@ -101,7 +103,8 @@ export class JSDatasetMapper { ), JSDatasetMapper.toRequiresMajorVersionUpdate(datasetVersionDiff), fileStore, - jsDataset.guestbookId as number + jsDataset.guestbookId as number, + storageDriver ).build() } diff --git a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts index a26660169..3e6ab1f09 100644 --- a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts +++ b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts @@ -3,6 +3,7 @@ import { Dataset, DatasetLock, DatasetNonNumericVersion, + DatasetStorageDriver, TermsOfAccess } from '../../domain/models/Dataset' import { DatasetVersionDiff } from '../../domain/models/DatasetVersionDiff' @@ -44,7 +45,8 @@ import { getDatasetLinkedCollections, updateTermsOfAccess, updateDatasetLicense, - getDatasetUploadLimits + getDatasetUploadLimits, + getDatasetStorageDriver } from '@iqss/dataverse-client-javascript' import { JSDatasetMapper } from '../mappers/JSDatasetMapper' import { DatasetPaginationInfo } from '../../domain/models/DatasetPaginationInfo' @@ -58,9 +60,7 @@ import { DatasetDownloadCount } from '@/dataset/domain/models/DatasetDownloadCou import { DatasetVersionPaginationInfo } from '@/dataset/domain/models/DatasetVersionPaginationInfo' import { FormattedCitation, CitationFormat } from '@/dataset/domain/models/DatasetCitation' import { DatasetLicenseUpdateRequest } from '../../domain/models/DatasetLicenseUpdateRequest' -import { axiosInstance } from '@/axiosInstance' import { requireAppConfig } from '../../../config' -import { AxiosResponse } from 'axios' import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' import { CollectionSummary } from '@/collection/domain/models/CollectionSummary' import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' @@ -79,6 +79,7 @@ interface IDatasetDetails { latestPublishedVersionMinorNumber?: number datasetVersionDiff?: JSDatasetVersionDiff fileStore?: string + storageDriver?: DatasetStorageDriver } export class DatasetJSDataverseRepository implements DatasetRepository { @@ -159,14 +160,14 @@ export class DatasetJSDataverseRepository implements DatasetRepository { getDatasetCitation.execute(jsDataset.id, version, includeDeaccessioned), getDatasetUserPermissions.execute(jsDataset.id), getDatasetLocks.execute(jsDataset.id), - this.getFileStore(jsDataset.id) + this.getStorageDriver(jsDataset.id) ]).then( - ([summaryFieldsNames, citation, jsDatasetPermissions, jsDatasetLocks, fileStore]: [ + ([summaryFieldsNames, citation, jsDatasetPermissions, jsDatasetLocks, storageDriver]: [ string[], string, JSDatasetPermissions, JSDatasetLock[], - string | undefined + DatasetStorageDriver | undefined ]) => { return { jsDataset, @@ -176,7 +177,8 @@ export class DatasetJSDataverseRepository implements DatasetRepository { jsDatasetLocks, jsDatasetFilesTotalOriginalDownloadSize: 0, jsDatasetFilesTotalArchivalDownloadSize: 0, - fileStore + fileStore: storageDriver?.name, + storageDriver } } ) @@ -269,7 +271,8 @@ export class DatasetJSDataverseRepository implements DatasetRepository { datasetDetails.latestPublishedVersionMajorNumber, datasetDetails.latestPublishedVersionMinorNumber, datasetDetails.datasetVersionDiff, - datasetDetails.fileStore + datasetDetails.fileStore, + datasetDetails.storageDriver ) }) .catch((error: ReadError) => { @@ -431,33 +434,8 @@ export class DatasetJSDataverseRepository implements DatasetRepository { return getDatasetLinkedCollections.execute(datasetId) } - /* - TODO: This is a temporary solution as this use case doesn't exist in js-dataverse yet and the API should also return the file store type rather than name only. - After https://github.com/IQSS/dataverse/issues/11695 is implemented, create a js-dataverse use case. - */ - private async getFileStore(datasetId: number): Promise { - return axiosInstance - .get( - `${DatasetJSDataverseRepository.DATAVERSE_BACKEND_URL}/api/datasets/${datasetId}/storageDriver` - ) - .then( - ( - res: AxiosResponse<{ - data: { - name: string - label: string - type: string - directDownload: boolean - directUpload: boolean - } - }> - ) => { - return res.data.data.name - } - ) - .catch(() => { - return undefined - }) + private async getStorageDriver(datasetId: number): Promise { + return getDatasetStorageDriver.execute(datasetId).catch(() => undefined) } updateDatasetLicense( diff --git a/src/files/domain/models/FileTreeItem.ts b/src/files/domain/models/FileTreeItem.ts new file mode 100644 index 000000000..ae19cdb41 --- /dev/null +++ b/src/files/domain/models/FileTreeItem.ts @@ -0,0 +1,57 @@ +import { FileAccess } from './FileAccess' + +export type FileAccessStatus = 'public' | 'restricted' | 'embargoed' + +export enum FileTreeItemType { + FOLDER = 'folder', + FILE = 'file' +} + +export interface FileTreeFolder { + type: FileTreeItemType.FOLDER + name: string + path: string + /** + * Recursive aggregates over the folder's subtree. `bytes`, `restricted`, + * and `embargoed` are individually optional so the SPA keeps rendering + * against an older SDK (or a server that hasn't yet rolled out a given + * aggregate) without a type-cast workaround. The previews-based + * fallback also leaves them off — its counts are best-effort. + */ + counts?: { + files: number + folders: number + bytes?: number + restricted?: number + embargoed?: number + } +} + +export interface FileTreeFile { + type: FileTreeItemType.FILE + id: number + name: string + path: string + size: number + contentType?: string + access?: FileAccess + /** + * Three-way access marker mirroring the per-file `access` string the + * SDK exposes on the tree response: `'public' | 'restricted' | + * 'embargoed'`. Kept separate from `access` (the boolean-ish + * `FileAccess` object used elsewhere in the SPA) because that shape + * collapses restricted and embargoed into the same `restricted: true` + * flag — fine for permission gating, lossy for tree display. + */ + accessStatus?: FileAccessStatus + checksum?: { type: string; value: string } + downloadUrl: string +} + +export type FileTreeItem = FileTreeFolder | FileTreeFile + +export const isFileTreeFolder = (item: FileTreeItem): item is FileTreeFolder => + item.type === FileTreeItemType.FOLDER + +export const isFileTreeFile = (item: FileTreeItem): item is FileTreeFile => + item.type === FileTreeItemType.FILE diff --git a/src/files/domain/models/FileTreePage.ts b/src/files/domain/models/FileTreePage.ts new file mode 100644 index 000000000..cbd89eb09 --- /dev/null +++ b/src/files/domain/models/FileTreePage.ts @@ -0,0 +1,22 @@ +import { FileTreeItem } from './FileTreeItem' + +export enum FileTreeInclude { + ALL = 'all', + FOLDERS = 'folders', + FILES = 'files' +} + +export enum FileTreeOrder { + NAME_AZ = 'NameAZ', + NAME_ZA = 'NameZA' +} + +export interface FileTreePage { + path: string + items: FileTreeItem[] + nextCursor: string | null + limit: number + order: FileTreeOrder + include: FileTreeInclude + approximateCount?: number +} diff --git a/src/files/domain/models/FixityAlgorithm.ts b/src/files/domain/models/FixityAlgorithm.ts index 3f062fcde..a676439ec 100644 --- a/src/files/domain/models/FixityAlgorithm.ts +++ b/src/files/domain/models/FixityAlgorithm.ts @@ -1,4 +1,5 @@ export enum FixityAlgorithm { + NONE = 'NONE', MD5 = 'MD5', SHA1 = 'SHA-1', SHA256 = 'SHA-256', diff --git a/src/files/domain/repositories/FileTreeRepository.ts b/src/files/domain/repositories/FileTreeRepository.ts new file mode 100644 index 000000000..d44db5477 --- /dev/null +++ b/src/files/domain/repositories/FileTreeRepository.ts @@ -0,0 +1,18 @@ +import { FileTreePage, FileTreeInclude, FileTreeOrder } from '../models/FileTreePage' +import { DatasetVersion } from '../../../dataset/domain/models/Dataset' + +export interface GetFileTreeNodeParams { + datasetPersistentId: string + datasetVersion: DatasetVersion + path?: string + limit?: number + cursor?: string + include?: FileTreeInclude + order?: FileTreeOrder + includeDeaccessioned?: boolean + originals?: boolean +} + +export interface FileTreeRepository { + getNode: (params: GetFileTreeNodeParams) => Promise +} diff --git a/src/files/domain/useCases/addUploadedFiles.ts b/src/files/domain/useCases/addUploadedFiles.ts index 552455b96..1a8970998 100644 --- a/src/files/domain/useCases/addUploadedFiles.ts +++ b/src/files/domain/useCases/addUploadedFiles.ts @@ -1,8 +1,14 @@ import { UploadedFileDTO } from '@iqss/dataverse-client-javascript' import { FileRepository } from '../repositories/FileRepository' +/** + * Minimal repository type for addUploadedFiles. + * Only requires the addUploadedFiles method. + */ +type AddUploadedFilesRepository = Pick + export function addUploadedFiles( - fileRepository: FileRepository, + fileRepository: AddUploadedFilesRepository, datasetId: number | string, files: UploadedFileDTO[] ): Promise { diff --git a/src/files/domain/useCases/enumerateFileTreeFiles.ts b/src/files/domain/useCases/enumerateFileTreeFiles.ts new file mode 100644 index 000000000..48a04369b --- /dev/null +++ b/src/files/domain/useCases/enumerateFileTreeFiles.ts @@ -0,0 +1,50 @@ +import { FileTreeFile, isFileTreeFile, isFileTreeFolder } from '../models/FileTreeItem' +import { FileTreeRepository } from '../repositories/FileTreeRepository' +import { DatasetVersion } from '../../../dataset/domain/models/Dataset' + +export interface EnumerateFileTreeFilesParams { + datasetPersistentId: string + datasetVersion: DatasetVersion + paths: string[] + limit?: number + signal?: AbortSignal +} + +export async function enumerateFileTreeFiles( + repository: FileTreeRepository, + params: EnumerateFileTreeFilesParams +): Promise { + const { datasetPersistentId, datasetVersion, paths, limit = 500, signal } = params + const collected: FileTreeFile[] = [] + const seen = new Set() + const queue = [...paths] + + while (queue.length > 0) { + if (signal?.aborted) { + throw new Error('Enumeration aborted') + } + const path = queue.shift() as string + let cursor: string | undefined + do { + const page = await repository.getNode({ + datasetPersistentId, + datasetVersion, + path, + limit, + cursor + }) + for (const item of page.items) { + if (isFileTreeFile(item)) { + if (!seen.has(item.id)) { + seen.add(item.id) + collected.push(item) + } + } else if (isFileTreeFolder(item)) { + queue.push(item.path) + } + } + cursor = page.nextCursor ?? undefined + } while (cursor) + } + return collected +} diff --git a/src/files/domain/useCases/getFileTreeNode.ts b/src/files/domain/useCases/getFileTreeNode.ts new file mode 100644 index 000000000..9ac4a2cba --- /dev/null +++ b/src/files/domain/useCases/getFileTreeNode.ts @@ -0,0 +1,11 @@ +import { FileTreePage } from '../models/FileTreePage' +import { FileTreeRepository, GetFileTreeNodeParams } from '../repositories/FileTreeRepository' + +export function getFileTreeNode( + repository: FileTreeRepository, + params: GetFileTreeNodeParams +): Promise { + return repository.getNode(params).catch(() => { + throw new Error('There was an error getting the file tree node') + }) +} diff --git a/src/files/domain/useCases/replaceFile.ts b/src/files/domain/useCases/replaceFile.ts index 9240d84d8..29aa537dd 100644 --- a/src/files/domain/useCases/replaceFile.ts +++ b/src/files/domain/useCases/replaceFile.ts @@ -1,8 +1,14 @@ import { UploadedFileDTO } from '@iqss/dataverse-client-javascript' import { FileRepository } from '../repositories/FileRepository' +/** + * Minimal repository type for replaceFile. + * Only requires the replace method. + */ +type ReplaceFileRepository = Pick + export function replaceFile( - fileRepository: FileRepository, + fileRepository: ReplaceFileRepository, fileId: number | string, newFile: UploadedFileDTO ): Promise { diff --git a/src/files/domain/useCases/uploadFile.ts b/src/files/domain/useCases/uploadFile.ts index 0b4febeac..f90919fc8 100644 --- a/src/files/domain/useCases/uploadFile.ts +++ b/src/files/domain/useCases/uploadFile.ts @@ -1,7 +1,13 @@ import { FileRepository } from '../repositories/FileRepository' +/** + * Minimal repository type for uploadFile. + * Only requires the uploadFile method. + */ +type UploadFileRepository = Pick + export function uploadFile( - fileRepository: FileRepository, + fileRepository: UploadFileRepository, datasetId: number | string, file: File, done: () => void, diff --git a/src/files/infrastructure/mappers/JSFileTreeMapper.ts b/src/files/infrastructure/mappers/JSFileTreeMapper.ts new file mode 100644 index 000000000..921761097 --- /dev/null +++ b/src/files/infrastructure/mappers/JSFileTreeMapper.ts @@ -0,0 +1,148 @@ +import { + FileTreeFileNode as SDKFileTreeFileNode, + FileTreeFolderNode as SDKFileTreeFolderNode, + FileTreeInclude as SDKFileTreeInclude, + FileTreeNode as SDKFileTreeNode, + FileTreeOrder as SDKFileTreeOrder, + FileTreePage as SDKFileTreePage, + ReadError, + isFileTreeFileNode, + isFileTreeFolderNode +} from '@iqss/dataverse-client-javascript' +import { + FileTreeFile, + FileTreeFolder, + FileTreeItem, + FileTreeItemType +} from '../../domain/models/FileTreeItem' +import { FileTreeInclude, FileTreeOrder, FileTreePage } from '../../domain/models/FileTreePage' + +/** + * Translates the wire shape returned by the SDK helper + * `listDatasetTreeNode` into the SPA's domain models, and (in the + * other direction) maps the domain enums back onto the SDK enums for + * outbound requests. + * + * Kept as a static-method class to match the prevailing pattern under + * `infrastructure/mappers/` (see `JSFileMapper`, `JSFileMetadataMapper`). + */ +export class JSFileTreeMapper { + static toFileTreePage( + page: SDKFileTreePage, + fallbackOrder?: FileTreeOrder, + fallbackInclude?: FileTreeInclude + ): FileTreePage { + return { + path: page.path, + items: page.items.map((item) => JSFileTreeMapper.toFileTreeItem(item)), + nextCursor: page.nextCursor, + limit: page.limit, + order: JSFileTreeMapper.toFileTreeOrder(page.order, fallbackOrder), + include: JSFileTreeMapper.toFileTreeInclude(page.include, fallbackInclude), + approximateCount: page.approximateCount + } + } + + static toFileTreeItem(item: SDKFileTreeNode): FileTreeItem { + if (isFileTreeFolderNode(item)) { + return JSFileTreeMapper.toFileTreeFolder(item) + } + if (isFileTreeFileNode(item)) { + return JSFileTreeMapper.toFileTreeFile(item) + } + // The SDK's union is exhaustive; this is a defensive fallthrough + // so a future server-side type addition doesn't crash the SPA. + /* istanbul ignore next */ + throw new Error(`Unknown file tree node type: ${(item as { type: string }).type}`) + } + + static toFileTreeFolder(item: SDKFileTreeFolderNode): FileTreeFolder { + return { + type: FileTreeItemType.FOLDER, + name: item.name, + path: item.path, + counts: item.counts + } + } + + static toFileTreeFile(item: SDKFileTreeFileNode): FileTreeFile { + return { + type: FileTreeItemType.FILE, + id: item.id, + name: item.name, + path: item.path, + size: item.size, + contentType: item.contentType, + access: item.access + ? { + restricted: item.access !== 'public', + latestVersionRestricted: item.access !== 'public', + canBeRequested: item.access === 'restricted', + requested: false + } + : undefined, + // Preserve the three-way distinction (public / restricted / + // embargoed) lost by the FileAccess flat shape above — needed for + // the tree row's access column. + accessStatus: item.access, + checksum: item.checksum, + downloadUrl: item.downloadUrl + } + } + + static toSDKFileTreeOrder(value?: FileTreeOrder): SDKFileTreeOrder | undefined { + if (value === undefined) return undefined + return value === FileTreeOrder.NAME_ZA ? SDKFileTreeOrder.NAME_ZA : SDKFileTreeOrder.NAME_AZ + } + + static toSDKFileTreeInclude(value?: FileTreeInclude): SDKFileTreeInclude | undefined { + if (value === undefined) return undefined + switch (value) { + case FileTreeInclude.FOLDERS: + return SDKFileTreeInclude.FOLDERS + case FileTreeInclude.FILES: + return SDKFileTreeInclude.FILES + case FileTreeInclude.ALL: + default: + return SDKFileTreeInclude.ALL + } + } + + static toFileTreeOrder(value: SDKFileTreeOrder, fallback?: FileTreeOrder): FileTreeOrder { + return value === SDKFileTreeOrder.NAME_ZA + ? FileTreeOrder.NAME_ZA + : (value as unknown as FileTreeOrder) ?? fallback ?? FileTreeOrder.NAME_AZ + } + + static toFileTreeInclude(value: SDKFileTreeInclude, fallback?: FileTreeInclude): FileTreeInclude { + switch (value) { + case SDKFileTreeInclude.FOLDERS: + return FileTreeInclude.FOLDERS + case SDKFileTreeInclude.FILES: + return FileTreeInclude.FILES + case SDKFileTreeInclude.ALL: + return FileTreeInclude.ALL + default: + return fallback ?? FileTreeInclude.ALL + } + } + + /** + * True when the response indicates the tree endpoint isn't available + * on the target instance — older versions, alternate deployments — + * so the host can transparently fall back to the previews-derived + * tree implementation. + */ + static isEndpointMissing(error: unknown): boolean { + if (error instanceof ReadError) { + return /\[(404|405|501)\]/.test(error.message) + } + // Defensive: pre-SDK-wrapped axios errors used by the previous + // implementation. Kept so a transitional state (older browsers / + // cached SDK build) doesn't regress. + /* istanbul ignore next */ + const status = (error as { response?: { status?: number } })?.response?.status + /* istanbul ignore next */ + return status === 404 || status === 405 || status === 501 + } +} diff --git a/src/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts b/src/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts new file mode 100644 index 000000000..b77c6fc61 --- /dev/null +++ b/src/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts @@ -0,0 +1,229 @@ +import { + FileTreeFile, + FileTreeFolder, + FileTreeItem, + FileTreeItemType +} from '../../domain/models/FileTreeItem' +import { FileTreeInclude, FileTreeOrder, FileTreePage } from '../../domain/models/FileTreePage' +import { + FileTreeRepository, + GetFileTreeNodeParams +} from '../../domain/repositories/FileTreeRepository' +import { FileRepository } from '../../domain/repositories/FileRepository' +import { FilePreview } from '../../domain/models/FilePreview' +import { FilePaginationInfo } from '../../domain/models/FilePaginationInfo' +import { FileCriteria } from '../../domain/models/FileCriteria' +import { DatasetVersion } from '../../../dataset/domain/models/Dataset' + +const PAGE_SIZE = 1000 + +/** + * Tree-shaped view of an existing dataset file listing. + * + * This adapter is used while the dedicated paginated tree endpoint + * (`GET /api/datasets/{id}/versions/{versionId}/tree`) is not deployed yet + * on the target Dataverse instance. It pulls the dataset file previews via + * the existing `FileRepository` (paginating internally), groups them by + * `directoryLabel`, and exposes the same `FileTreeRepository` contract that + * the new endpoint will satisfy. + * + * Once the endpoint and SDK helper land, the SPA can swap this for a thin + * `FileTreeJSDataverseRepository` that calls the SDK directly. The UI does + * not need to change. + */ +export class FileTreeFromPreviewsRepository implements FileTreeRepository { + private cache = new Map() + + constructor( + private readonly fileRepository: FileRepository, + private readonly accessApiBase: string = '/api/access/datafile' + ) {} + + async getNode(params: GetFileTreeNodeParams): Promise { + const path = normalizePath(params.path) + const order = params.order ?? FileTreeOrder.NAME_AZ + const include = params.include ?? FileTreeInclude.ALL + const limit = clampLimit(params.limit) + const previews = await this.loadAllPreviews(params.datasetPersistentId, params.datasetVersion) + const items = collectImmediateChildren(previews, path, order, include, this.accessApiBase) + const offset = parseCursor(params.cursor) + const slice = items.slice(offset, offset + limit) + const nextCursor = offset + limit < items.length ? encodeCursor(offset + limit) : null + return { + path, + items: slice, + nextCursor, + limit, + order, + include, + approximateCount: items.length + } + } + + private async loadAllPreviews( + persistentId: string, + datasetVersion: DatasetVersion + ): Promise { + const key = `${persistentId}::${datasetVersion.number.toString()}` + const cached = this.cache.get(key) + if (cached) { + return cached + } + const all: FilePreview[] = [] + let page = 1 + let total = Number.POSITIVE_INFINITY + const criteria = new FileCriteria() + while (all.length < total) { + const pageInfo = new FilePaginationInfo(page, PAGE_SIZE, total === Infinity ? 0 : total) + const result = await this.fileRepository.getAllByDatasetPersistentIdWithCount( + persistentId, + datasetVersion, + pageInfo, + criteria + ) + total = result.totalFilesCount + all.push(...result.files) + if (result.files.length < PAGE_SIZE) { + break + } + page += 1 + } + this.cache.set(key, all) + return all + } +} + +const CURSOR_PREFIX = 'mem:' + +function clampLimit(limit?: number): number { + if (!limit || limit <= 0) { + return 100 + } + if (limit > 1000) { + return 1000 + } + return Math.floor(limit) +} + +function parseCursor(cursor?: string): number { + if (!cursor) { + return 0 + } + if (!cursor.startsWith(CURSOR_PREFIX)) { + throw new Error('Invalid cursor') + } + const offset = Number.parseInt(cursor.slice(CURSOR_PREFIX.length), 10) + if (!Number.isFinite(offset) || offset < 0) { + throw new Error('Invalid cursor') + } + return offset +} + +function encodeCursor(offset: number): string { + return `${CURSOR_PREFIX}${offset}` +} + +export function normalizePath(input?: string): string { + if (!input) { + return '' + } + return input.replace(/\/+/g, '/').replace(/^\/+/, '').replace(/\/+$/, '') +} + +interface FolderAccumulator { + name: string + path: string + fileCount: number + bytes: number + subfolderNames: Set +} + +function collectImmediateChildren( + previews: FilePreview[], + path: string, + order: FileTreeOrder, + include: FileTreeInclude, + accessApiBase: string +): FileTreeItem[] { + const folders = new Map() + const files: FileTreeFile[] = [] + const prefix = path === '' ? '' : `${path}/` + for (const preview of previews) { + const directory = (preview.metadata.directory ?? '').replace(/^\/+|\/+$/g, '') + if (path !== '' && directory !== path && !directory.startsWith(prefix)) { + continue + } + if (directory === path) { + files.push(buildFile(preview, path, accessApiBase)) + continue + } + const remainder = directory.slice(prefix.length) + const segment = remainder.split('/')[0] + if (!segment) { + continue + } + const folderPath = prefix + segment + let entry = folders.get(folderPath) + if (!entry) { + entry = { + name: segment, + path: folderPath, + fileCount: 0, + bytes: 0, + subfolderNames: new Set() + } + folders.set(folderPath, entry) + } + entry.fileCount += 1 + entry.bytes += preview.metadata.size?.toBytes() ?? 0 + if (directory !== folderPath) { + // Track distinct subfolder names (the next path segment after this folder). + const sub = directory.slice(folderPath.length + 1).split('/')[0] + if (sub) { + entry.subfolderNames.add(sub) + } + } + } + + const folderItems: FileTreeFolder[] = Array.from(folders.values()).map((f) => ({ + type: FileTreeItemType.FOLDER, + name: f.name, + path: f.path, + counts: { files: f.fileCount, folders: f.subfolderNames.size, bytes: f.bytes } + })) + + sortByName(folderItems, order) + sortByName(files, order) + + if (include === FileTreeInclude.FOLDERS) { + return folderItems + } + if (include === FileTreeInclude.FILES) { + return files + } + return [...folderItems, ...files] +} + +function buildFile(preview: FilePreview, parentPath: string, accessApiBase: string): FileTreeFile { + return { + type: FileTreeItemType.FILE, + id: preview.id, + name: preview.name, + path: parentPath === '' ? preview.name : `${parentPath}/${preview.name}`, + size: preview.metadata.size.toBytes(), + contentType: preview.metadata.type.value, + access: preview.access, + checksum: preview.metadata.checksum + ? { + type: preview.metadata.checksum.algorithm, + value: preview.metadata.checksum.value + } + : undefined, + downloadUrl: `${accessApiBase}/${preview.id}` + } +} + +function sortByName(items: T[], order: FileTreeOrder): void { + const dir = order === FileTreeOrder.NAME_ZA ? -1 : 1 + items.sort((a, b) => dir * a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) +} diff --git a/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts b/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts new file mode 100644 index 000000000..2c88f5eb3 --- /dev/null +++ b/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts @@ -0,0 +1,54 @@ +import { + FileTreeRepository, + GetFileTreeNodeParams +} from '../../domain/repositories/FileTreeRepository' +import { FileTreePage } from '../../domain/models/FileTreePage' +import { listDatasetTreeNode } from '@iqss/dataverse-client-javascript' +import { FileTreeFromPreviewsRepository } from './FileTreeFromPreviewsRepository' +import { FileRepository } from '../../domain/repositories/FileRepository' +import { JSFileTreeMapper } from '../mappers/JSFileTreeMapper' + +/** + * Calls the dedicated tree endpoint via the SDK helper + * `listDatasetTreeNode`, which wraps + * `GET /api/datasets/{id}/versions/{versionId}/tree`. + * + * When the endpoint is not available on the target instance the + * repository falls back to the in-memory `FileTreeFromPreviewsRepository` + * so the SPA stays usable in mixed-version deployments. + */ +export class FileTreeJSDataverseRepository implements FileTreeRepository { + private fallback?: FileTreeFromPreviewsRepository + private endpointUnavailable = false + + constructor(private readonly fileRepository?: FileRepository) {} + + async getNode(params: GetFileTreeNodeParams): Promise { + if (this.endpointUnavailable && this.fallback) { + return this.fallback.getNode(params) + } + try { + const sdkPage = await listDatasetTreeNode.execute({ + datasetId: params.datasetPersistentId, + datasetVersionId: params.datasetVersion.number.toString(), + path: params.path, + limit: params.limit, + cursor: params.cursor, + include: JSFileTreeMapper.toSDKFileTreeInclude(params.include), + order: JSFileTreeMapper.toSDKFileTreeOrder(params.order), + includeDeaccessioned: params.includeDeaccessioned, + originals: params.originals + }) + return JSFileTreeMapper.toFileTreePage(sdkPage, params.order, params.include) + } catch (error) { + if (this.fileRepository && JSFileTreeMapper.isEndpointMissing(error)) { + this.endpointUnavailable = true + if (!this.fallback) { + this.fallback = new FileTreeFromPreviewsRepository(this.fileRepository) + } + return this.fallback.getNode(params) + } + throw error + } + } +} diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index 133e043af..2bd796852 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -1,5 +1,5 @@ -import { ReplaceFileReferrer } from './replace-file/ReplaceFile' -import { EditFileMetadataReferrer } from '@/sections/edit-file-metadata/EditFileMetadata' +import { ReplaceFileReferrer } from './replace-file/ReplaceFileReferrer' +import { EditFileMetadataReferrer } from '@/sections/edit-file-metadata/EditFileMetadataReferrer' export enum Route { HOME = '/', diff --git a/src/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.tsx b/src/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.tsx index be3488173..5644d5d01 100644 --- a/src/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.tsx +++ b/src/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.tsx @@ -86,8 +86,9 @@ export function EditDatasetMenu({ dataset, datasetRepository }: EditDatasetMenuP asButtonGroup variant="secondary" disabled={dataset.checkIsLockedFromEdits(user.persistentId)}> - {/* TODO: remove this when we can handle non-S3 files */} - {dataset?.fileStore?.startsWith('s3') && ( + {/* SPA upload flow needs an S3-compatible direct-upload driver; + decide via the typed driver capabilities, not the driver id name. */} + {dataset?.storageDriver?.type === 's3' && dataset.storageDriver.directUpload && ( { + setSearchParams(nextSearchParamsForView(searchParams, next), { replace: true }) + } + const setTreePath = (next: string) => { + setSearchParams(nextSearchParamsForTreePath(searchParams, next), { replace: true }) + } + + const treeRepository = useMemo( + () => fileTreeRepository ?? new FileTreeJSDataverseRepository(filesRepository), + [fileTreeRepository, filesRepository] + ) + + return view === 'tree' ? ( + + ) : ( + + ) +} + +interface DatasetFilesTableViewProps { + filesRepository: FileRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + datasetRepository: DatasetRepository + onChangeView: (view: FilesViewMode) => void + view: FilesViewMode +} + +function DatasetFilesTableView({ + filesRepository, + datasetPersistentId, + datasetVersion, + datasetRepository, + onChangeView, + view +}: DatasetFilesTableViewProps) { const [paginationInfo, setPaginationInfo] = useState(new FilePaginationInfo()) const [criteria, setCriteria] = useState(new FileCriteria()) const { files, isLoading, filesCountInfo, filesTotalDownloadSize } = useFiles( @@ -35,6 +105,9 @@ export function DatasetFiles({ return ( <> +
+ +
) } + +interface DatasetFilesTreeViewProps { + treeRepository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + onChangeView: (view: FilesViewMode) => void + view: FilesViewMode + initialPath: string + onCurrentPathChange: (path: string) => void +} + +function DatasetFilesTreeView({ + treeRepository, + datasetPersistentId, + datasetVersion, + onChangeView, + view, + initialPath, + onCurrentPathChange +}: DatasetFilesTreeViewProps) { + const { dataset } = useDataset() + const downloadsDisabled = treeDownloadsRequireTermsGate(dataset) + return ( + <> +
+ +
+ + + ) +} diff --git a/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss b/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss index f6fb9013c..249a51b67 100644 --- a/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss +++ b/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss @@ -22,3 +22,9 @@ margin-bottom: 0; } } + +.view-toggle-row { + display: flex; + justify-content: flex-start; + margin-bottom: 0.75rem; +} diff --git a/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx b/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx index 4b65605d0..261202b9a 100644 --- a/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx +++ b/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx @@ -1,5 +1,6 @@ import cn from 'classnames' import { useMemo, useRef, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import useInfiniteScroll, { UseInfiniteScrollHookRefCallback } from 'react-infinite-scroll-hook' import { Alert } from '@iqss/dataverse-design-system' import { FileRepository } from '../../../files/domain/repositories/FileRepository' @@ -14,6 +15,18 @@ import { FilesTableScrollable } from './files-table/FilesTableScrollable' import { FileCriteriaForm } from './file-criteria-form/FileCriteriaForm' import { FilesContext } from '@/sections/file/FilesContext' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { FilesTree } from './files-tree/FilesTree' +import { FilesViewToggle, FilesViewMode } from './files-view-toggle/FilesViewToggle' +import { FileTreeRepository } from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeJSDataverseRepository } from '@/files/infrastructure/repositories/FileTreeJSDataverseRepository' +import { useDataset } from '../DatasetContext' +import { treeDownloadsRequireTermsGate } from './treeDownloadsRequireTermsGate' +import { + PATH_PARAM, + VIEW_PARAM, + nextSearchParamsForTreePath, + nextSearchParamsForView +} from './filesViewSearchParams' import styles from './DatasetFilesScrollable.module.scss' interface DatasetFilesScrollableProps { @@ -22,6 +35,7 @@ interface DatasetFilesScrollableProps { datasetVersion: DatasetVersion datasetRepository: DatasetRepository canUpdateDataset?: boolean + fileTreeRepository?: FileTreeRepository } export type SentryRef = UseInfiniteScrollHookRefCallback @@ -31,8 +45,111 @@ export function DatasetFilesScrollable({ datasetPersistentId, datasetVersion, canUpdateDataset, - datasetRepository + datasetRepository, + fileTreeRepository }: DatasetFilesScrollableProps) { + const [searchParams, setSearchParams] = useSearchParams() + const view: FilesViewMode = searchParams.get(VIEW_PARAM) === 'tree' ? 'tree' : 'table' + const treePath = searchParams.get(PATH_PARAM) ?? '' + const setView = (next: FilesViewMode) => { + setSearchParams(nextSearchParamsForView(searchParams, next), { replace: true }) + } + const setTreePath = (next: string) => { + setSearchParams(nextSearchParamsForTreePath(searchParams, next), { replace: true }) + } + + const treeRepository = useMemo( + () => fileTreeRepository ?? new FileTreeJSDataverseRepository(filesRepository), + [fileTreeRepository, filesRepository] + ) + + // Branch on view BEFORE invoking either subview's hooks. The previous + // single-component layout fired the table-only data hooks + // (`useGetFilesCountInfo`, `useGetFilesTotalDownloadSize`, + // `useGetAccumulatedFiles`) on every render — including tree mode — + // and a transient error in any of them aborted the render with a + // top-level , replacing a perfectly healthy tree response with + // an unrelated error banner. + return view === 'tree' ? ( + + ) : ( + + ) +} + +interface DatasetFilesScrollableTreeViewProps { + treeRepository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + view: FilesViewMode + onChangeView: (view: FilesViewMode) => void + initialPath: string + onCurrentPathChange: (path: string) => void +} + +function DatasetFilesScrollableTreeView({ + treeRepository, + datasetPersistentId, + datasetVersion, + view, + onChangeView, + initialPath, + onCurrentPathChange +}: DatasetFilesScrollableTreeViewProps) { + const { dataset } = useDataset() + const downloadsDisabled = treeDownloadsRequireTermsGate(dataset) + return ( +
+
+ +
+ +
+ ) +} + +interface DatasetFilesScrollableTableViewProps { + filesRepository: FileRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + datasetRepository: DatasetRepository + canUpdateDataset?: boolean + view: FilesViewMode + onChangeView: (view: FilesViewMode) => void +} + +function DatasetFilesScrollableTableView({ + filesRepository, + datasetPersistentId, + datasetVersion, + canUpdateDataset, + datasetRepository, + view, + onChangeView +}: DatasetFilesScrollableTableViewProps) { const scrollableContainerRef = useRef(null) const criteriaContainerRef = useRef(null) const criteriaContainerSize = useObserveElementSize(criteriaContainerRef) @@ -154,6 +271,7 @@ export function DatasetFilesScrollable({ ) } + return (
+
+ +
} - // TODO: remove this when we can handle non-S3 files - if (!dataset?.fileStore?.startsWith('s3')) { + // The SPA upload flow requires browser-direct upload to S3-compatible + // storage. Check the driver's typed capabilities, not its operator- + // chosen id (the previous `fileStore?.startsWith('s3')` heuristic + // missed any S3-compatible driver registered under a different id — + // e.g., `minio1`, `wasabi`, `cloudflare-r2`). + const driver = dataset.storageDriver + if (!driver || driver.type !== 's3' || !driver.directUpload) { return <> } diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx index 96336e6c4..d4a04394e 100644 --- a/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx +++ b/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx @@ -92,8 +92,12 @@ export function DownloadFilesButton({ files, fileSelection }: DownloadFilesButto return <> } - // TODO: remove this when we can handle non-S3 files - if (!dataset?.fileStore?.startsWith('s3')) { + // The download menu's signed-URL flow targets S3-compatible storage. + // Decide via the typed driver capabilities, not the operator-chosen + // driver id (so any driver with type=s3 + directDownload works, + // regardless of whether it's named `s3`, `minio1`, etc.). + const driver = dataset?.storageDriver + if (!driver || driver.type !== 's3' || !driver.directDownload) { return <> } diff --git a/src/sections/dataset/dataset-files/files-table/useFilesTableScrollable.tsx b/src/sections/dataset/dataset-files/files-table/useFilesTableScrollable.tsx index 7eefb8dac..b57fb0223 100644 --- a/src/sections/dataset/dataset-files/files-table/useFilesTableScrollable.tsx +++ b/src/sections/dataset/dataset-files/files-table/useFilesTableScrollable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useDeepCompareMemo } from 'use-deep-compare' import { FilePreview } from '../../../../files/domain/models/FilePreview' import { getCoreRowModel, Row, useReactTable } from '@tanstack/react-table' @@ -40,15 +40,26 @@ export function useFilesTableScrollable( return result }, [selectedRowsModels, rowSelection]) + // Recreating the column definitions on every render gives every cell a new + // component identity, so any incidental re-render (e.g. opening a per-row + // dropdown, which itself triggers a couple of re-renders) unmounts and + // remounts the row cells — closing the just-opened menu before it can show. + // Memoize so cell identities stay stable across those re-renders. + const columns = useMemo( + () => + createColumnsDefinition( + paginationInfo, + fileSelection, + fileRepository, + datasetRepository, + accumulatedFilesCount + ), + [paginationInfo, fileSelection, fileRepository, datasetRepository, accumulatedFilesCount] + ) + const table = useReactTable({ data: files, - columns: createColumnsDefinition( - paginationInfo, - fileSelection, - fileRepository, - datasetRepository, - accumulatedFilesCount - ), + columns, state: { rowSelection: rowSelection }, diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss b/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss new file mode 100644 index 000000000..8463f761e --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss @@ -0,0 +1,429 @@ +$row-height-comfortable: 32px; +$row-height-compact: 26px; +$indent-step: 18px; + +.tree-wrap { + margin-top: 12px; + border: 1px solid var(--bs-border-color); + background-color: var(--bs-white); + border-radius: 4px; + overflow: hidden; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--bs-border-color); + background-color: var(--bs-white); + flex-wrap: wrap; +} + +.toolbar-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + flex-wrap: wrap; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.selection-summary { + font-family: inherit; + font-size: 12px; + color: var(--bs-secondary-color); + white-space: nowrap; +} + +.selection-summary-strong { + color: var(--bs-body-color); + font-weight: 500; +} + +.selection-summary-sep { + margin: 0 6px; + color: var(--bs-tertiary-color); +} + +.tree-head { + display: grid; + grid-template-columns: 28px 1fr 110px 130px 80px 40px; + gap: 8px; + padding: 8px 14px; + border-bottom: 1px solid var(--bs-border-color); + background-color: var(--bs-light, #f8f9fa); + font-family: inherit; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} + +.tree-head-size, +.tree-head-count { + text-align: right; +} + +.tree-head-access { + text-align: left; +} + +.tree-viewport { + position: relative; + overflow: auto; + height: 540px; + background: var(--bs-white); +} + +.tree-viewport::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.tree-viewport::-webkit-scrollbar-thumb { + background: var(--bs-border-color); + border-radius: 999px; + border: 2px solid var(--bs-white); +} + +.tree-spacer { + position: relative; +} + +.row { + position: absolute; + left: 0; + right: 0; + display: grid; + grid-template-columns: 28px 1fr 110px 130px 80px 40px; + gap: 8px; + align-items: center; + height: $row-height-comfortable; + padding: 0 14px; + font-size: 13px; + border-bottom: 1px solid transparent; + user-select: none; + cursor: default; + background-color: var(--bs-white); +} + +.row-select { + display: flex; + align-items: center; + justify-content: center; + + // The dedicated column gives the checkbox visual weight and signals + // that selection is a first-class action. The narrow width keeps the + // name column long; padding-right balances the gap with the twisty. +} + +.row:hover { + background-color: var(--bs-light, #f8f9fa); +} + +.row:focus-visible { + outline: none; + + // Inset ring works around the absolute-positioned row clipping a + // standard outline against neighbouring rows. + box-shadow: inset 0 0 0 2px var(--bs-primary, #0d6efd); + background-color: rgb(13 110 253 / 4%); + z-index: 1; +} + +.row-selected, +.row-selected:hover { + background-color: rgb(13 110 253 / 8%); +} + +.row-selected:focus-visible { + background-color: rgb(13 110 253 / 12%); +} + +.row-name { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.row-twisty { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--bs-secondary-color); + border: none; + background: transparent; + cursor: pointer; + border-radius: 2px; + flex: 0 0 auto; + padding: 0; +} + +.row-twisty:hover { + background-color: rgb(0 0 0 / 6%); + color: var(--bs-body-color); +} + +.row-twisty:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 1px; +} + +.row-twisty-empty { + visibility: hidden; +} + +.row-twisty svg { + transition: transform 120ms ease; +} + +.row-twisty-open svg { + transform: rotate(90deg); +} + +.row-icon { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--bs-secondary-color); + flex: 0 0 auto; +} + +.row-icon-folder { + color: var(--bs-primary); +} + +.row-label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: inherit; + font-size: 12.5px; + color: var(--bs-body-color); +} + +.row-label-folder { + font-weight: 500; +} + +.row-label a { + color: var(--bs-primary); + text-decoration: none; +} + +.row-label a:hover { + color: var(--bs-link-hover-color); + text-decoration: underline; +} + +.row-size, +.row-count { + font-family: inherit; + font-size: 11.5px; + color: var(--bs-secondary-color); + text-align: right; +} + +.row-access { + font-family: inherit; + font-size: 11.5px; + color: var(--bs-secondary-color); + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row-access-restricted { + color: var(--bs-warning-text-emphasis, #997404); +} + +.row-access-embargoed { + color: var(--bs-info-text-emphasis, #087990); +} + +.row-actions { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.icon-btn { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--bs-secondary-color); + cursor: pointer; + border-radius: 2px; + padding: 0; +} + +.download-btn-icon { + margin-right: 0.3rem; + margin-bottom: 0.1rem; +} + +.icon-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.icon-btn:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 1px; +} + +.icon-btn:hover:not(:disabled) { + background-color: rgb(0 0 0 / 6%); + color: var(--bs-primary); +} + +.checkbox { + width: 16px; + height: 16px; + + // Hardcoded fallbacks because the standalone bundle is rendered into + // a Shadow DOM hosted by JSF pages that don't always define the full + // Bootstrap-5 --bs-* custom property set. Without fallbacks the + // border resolves to `1px solid` (invalid → invisible) and the + // checked-state background falls back to transparent — the user sees + // a white-on-white checkbox with no border. + border: 1px solid var(--bs-border-color, #c4c8cc); + border-radius: 2px; + background-color: var(--bs-white, #fff); + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + cursor: pointer; + position: relative; + transition: background-color 80ms ease, border-color 80ms ease; +} + +.checkbox:hover { + border-color: var(--bs-primary, #0d6efd); +} + +.checkbox:focus-visible { + outline: 2px solid var(--bs-primary, #0d6efd); + outline-offset: 1px; +} + +.checkbox-checked, +.checkbox-indeterminate { + background-color: var(--bs-primary, #0d6efd); + border-color: var(--bs-primary, #0d6efd); +} + +.checkbox-checked::after { + content: ''; + width: 4px; + height: 8px; + border: solid #fff; + border-width: 0 1.5px 1.5px 0; + transform: rotate(45deg) translate(-0.5px, -1px); +} + +.checkbox-indeterminate::after { + content: ''; + width: 8px; + height: 0; + border-top: 1.5px solid #fff; +} + +.tree-state { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--bs-secondary-color); + font-family: inherit; + font-size: 13px; + flex-direction: column; + gap: 10px; + padding: 40px; + text-align: center; +} + +.tree-state-glyph { + width: 40px; + height: 40px; + border: 1px dashed var(--bs-border-color); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--bs-secondary-color); +} + +.tree-state-detail { + color: var(--bs-tertiary-color); + font-size: 11.5px; +} + +.row-loading, +.row-error { + position: absolute; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0 14px; + height: $row-height-comfortable; + font-family: inherit; + font-size: 11.5px; + color: var(--bs-secondary-color); +} + +.row-error { + color: var(--bs-danger); +} + +.row-load-more { + position: absolute; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0 14px; + height: $row-height-comfortable; +} + +.btn-link-inline { + background: transparent; + border: none; + color: var(--bs-primary); + font-size: 12px; + padding: 0; + cursor: pointer; + font-family: inherit; +} + +.btn-link-inline:hover { + text-decoration: underline; +} + +.btn-link-inline:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx new file mode 100644 index 000000000..1c01899c7 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx @@ -0,0 +1,766 @@ +import { + CSSProperties, + KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { Button } from '@iqss/dataverse-design-system' +import { useTranslation } from 'react-i18next' +import { toast } from 'react-toastify' +import { FileTreeRepository } from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeInclude, FileTreeOrder } from '@/files/domain/models/FileTreePage' +import { + FileTreeFile, + FileTreeFolder, + isFileTreeFile, + isFileTreeFolder +} from '@/files/domain/models/FileTreeItem' +import { DatasetVersion } from '@/dataset/domain/models/Dataset' +import { useFileTree } from './useFileTree' +import { SelectionState, useFileTreeSelection } from './useFileTreeSelection' +import { useFileTreeFlatten } from './useFileTreeFlatten' +import { useFileTreeDownload } from './useFileTreeDownload' +import { useStreamingZipDownload } from './useStreamingZipDownload' +import { FilesTreeRow } from './FilesTreeRow' +import { FilesTreeHeader } from './FilesTreeHeader' +import { FilesTreeDownloadTray } from './FilesTreeDownloadTray' +import { DownloadIcon, EmptyIcon, SpinnerIcon, WarnIcon } from './icons/FilesTreeIcons' +import { formatBytes } from './format' +import styles from './FilesTree.module.scss' + +interface FilesTreeProps { + treeRepository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + pageSize?: number + query?: string + order?: FileTreeOrder + rowHeight?: number + fallbackHeight?: number + /** + * Folder path to expand on mount (e.g. read from a `?path=` URL query + * param). All ancestors along the path are expanded. + */ + initialPath?: string + /** + * Called with the deepest currently-expanded folder path whenever the + * expansion changes. The host can sync this to the URL for deep + * linking. Empty string means only the root is expanded. + */ + onCurrentPathChange?: (path: string) => void + /** + * Optional URL builder for the filename → file metadata link. + * Forwarded to FilesTreeRow. When provided, the row uses a plain + * anchor (suitable for JSF embeds without React Router); when + * omitted, the row falls back to the SPA ``. + */ + buildFileMetadataUrl?: (file: FileTreeFile) => string + /** + * When `true`, every download action in the tree (per-row download + * icon, toolbar download button) is disabled. The host should set + * this on datasets where a guestbook, custom terms, or non-default + * license requires user acknowledgement before downloading — the + * acknowledgement modal lives on the table-view side and is not + * yet wired into the tree. See the SPA caller in `DatasetFiles.tsx`. + * Standalone JSF mounts default to `false`; the JSF page is + * responsible for its own gating in that flow. + */ + downloadsDisabled?: boolean +} + +const DEFAULT_ROW_HEIGHT = 32 +const DEFAULT_FALLBACK_HEIGHT = 540 +const OVERSCAN = 6 + +export function FilesTree({ + treeRepository, + datasetPersistentId, + datasetVersion, + pageSize = 200, + query, + order = FileTreeOrder.NAME_AZ, + rowHeight = DEFAULT_ROW_HEIGHT, + fallbackHeight = DEFAULT_FALLBACK_HEIGHT, + initialPath = '', + onCurrentPathChange, + buildFileMetadataUrl, + downloadsDisabled = false +}: FilesTreeProps) { + const { t } = useTranslation('files') + const tree = useFileTree({ + repository: treeRepository, + datasetPersistentId, + datasetVersion, + pageSize, + order, + include: FileTreeInclude.ALL, + initialPath + }) + + const lastPathRef = useRef(initialPath) + useEffect(() => { + if (!onCurrentPathChange) return + if (tree.currentPath !== lastPathRef.current) { + lastPathRef.current = tree.currentPath + onCurrentPathChange(tree.currentPath) + } + }, [tree.currentPath, onCurrentPathChange]) + const selection = useFileTreeSelection() + const streamingZip = useStreamingZipDownload() + const [trayOpen, setTrayOpen] = useState(false) + + // Auto-close the tray when the engine returns to idle (after the user + // dismisses a finished/cancelled run via the close button). + useEffect(() => { + if (streamingZip.state.status === 'idle') { + setTrayOpen(false) + } + }, [streamingZip.state.status]) + + const onDownloadFiles = useCallback( + (files: FileTreeFile[]) => { + /* istanbul ignore if */ + if (files.length === 0) { + return + } + // Single source of truth: every download — one file or many — is + // assembled into a zip via the chunked streaming engine. Means a + // single big file gets the per-part Range / retry resilience, the + // tray gives consistent progress UX, and the button label + // ("Download zip") never lies about what the user is going to + // get. The historical "anchor-click for one file" bypass was + // removed because it lost the per-part resume on large files and + // forked the UX. + const zipName = `${datasetPersistentId.replace(/[^a-zA-Z0-9._-]+/g, '_')}-files.zip` + streamingZip.start({ files, zipName }) + setTrayOpen(true) + }, + [datasetPersistentId, streamingZip] + ) + + const download = useFileTreeDownload({ + treeRepository, + datasetPersistentId, + datasetVersion, + selection, + onDownloadFiles, + onError: () => toast.error(t('actions.optionsMenu.guestbookCollectModal.downloadError')) + }) + + const visibleRows = useFileTreeFlatten({ + nodes: tree.nodes, + expanded: tree.expanded, + query + }) + + const containerRef = useRef(null) + const [scrollTop, setScrollTop] = useState(0) + const [viewportH, setViewportH] = useState(fallbackHeight) + + useLayoutEffect(() => { + /* istanbul ignore if */ + if (typeof window === 'undefined') { + return + } + const el = containerRef.current + /* istanbul ignore if */ + if (!el) { + return + } + const update = () => { + const measured = el.clientHeight + /* istanbul ignore else */ + if (measured > 0) { + setViewportH(measured) + } + } + update() + /* istanbul ignore if */ + if (typeof ResizeObserver === 'undefined') { + return + } + const ro = new ResizeObserver(update) + ro.observe(el) + return () => ro.disconnect() + }, []) + + useEffect(() => { + for (const row of visibleRows) { + if (row.kind === 'item' && isFileTreeFile(row.node)) { + selection.registerFile(row.node) + } + } + }, [selection, visibleRows]) + + const totalRows = visibleRows.length + const startIdx = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN) + const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportH) / rowHeight) + OVERSCAN) + const slice = useMemo(() => visibleRows.slice(startIdx, endIdx), [endIdx, startIdx, visibleRows]) + + const onScroll = (event: React.UIEvent) => { + setScrollTop(event.currentTarget.scrollTop) + } + + // Auto-load: whenever a "load-more" row enters the rendered slice and the + // corresponding folder is not already loading, trigger loadMore. The + // explicit button stays as a fallback (and a focus target) but the + // common case is now infinite-scroll-style. + useEffect(() => { + for (const row of slice) { + if (row.kind === 'load-more') { + const node = tree.nodes.get(row.path) + if (node?.nextCursor && !node.loading) { + void tree.loadMore(row.path) + } + } + } + // tree.nodes / tree.loadMore are stable callbacks; depending on `slice` + // is enough to re-evaluate when the visible range changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slice]) + + const handleDownloadOne = useCallback( + (item: FileTreeFile | FileTreeFolder) => { + void download.downloadNode(item) + }, + [download] + ) + + const handleToggleExpansion = useCallback( + async (folder: FileTreeFolder) => { + await tree.toggleExpanded(folder.path) + }, + [tree] + ) + + const handleToggleSelectionFolder = useCallback( + (folder: FileTreeFolder) => { + const known = tree.visibleKnownChildren(folder.path) + selection.toggleFolder(folder, known) + }, + [selection, tree] + ) + + const handleToggleSelectionFile = useCallback( + (file: FileTreeFile) => { + selection.toggleFile(file) + }, + [selection] + ) + + // ---------- Keyboard navigation (WAI-ARIA tree pattern) ---------- + const itemRowIndices = useMemo(() => { + const out: number[] = [] + for (let i = 0; i < visibleRows.length; i++) { + if (visibleRows[i].kind === 'item') out.push(i) + } + return out + }, [visibleRows]) + + const [focusedRowIndex, setFocusedRowIndex] = useState(-1) + + // Reset / clamp focus when the visible rows change so we never point at a + // removed row. Default focus is the first item row. + useEffect(() => { + if (itemRowIndices.length === 0) { + setFocusedRowIndex(-1) + return + } + if (focusedRowIndex === -1 || !itemRowIndices.includes(focusedRowIndex)) { + setFocusedRowIndex(itemRowIndices[0]) + } + }, [itemRowIndices, focusedRowIndex]) + + // Auto-scroll the focused row into view after focus changes. + useEffect(() => { + if (focusedRowIndex < 0) return + const el = containerRef.current + /* istanbul ignore if */ + if (!el) return + const top = focusedRowIndex * rowHeight + const bottom = top + rowHeight + /* istanbul ignore next */ + if (top < el.scrollTop) { + el.scrollTop = top + } else if (bottom > el.scrollTop + el.clientHeight) { + el.scrollTop = bottom - el.clientHeight + } + }, [focusedRowIndex, rowHeight]) + + // Move DOM focus to follow the roving tabindex. The state-only update + // above shifts which row carries `tabIndex=0`, but the browser's focus + // does not move on its own — without this, the user sees the + // `:focus-visible` ring stay on whichever row Tab originally landed + // on, even after pressing ArrowDown / ArrowUp. Only follow focus + // when the tree already owns it; otherwise we'd grab focus on + // initial mount and steal it from the rest of the page. + // + // We resolve the active element via `getRootNode()` rather than + // `document.activeElement` so the check works inside a Shadow DOM + // mount (the JSF standalone bundle): `document.activeElement` from + // outside the shadow returns the shadow host, not the focused + // descendant inside it, so a host-page-level check would always + // report the tree as not-owning-focus and the ring would never + // follow arrow keys in JSF. + useEffect(() => { + if (focusedRowIndex < 0) return + const el = containerRef.current + /* istanbul ignore if */ + if (!el) return + const root = el.getRootNode() as Document | ShadowRoot + if (!el.contains(root.activeElement)) return + const target = el.querySelector('[role="treeitem"][tabindex="0"]') + target?.focus() + }, [focusedRowIndex]) + + const moveFocus = useCallback( + (delta: number | 'first' | 'last') => { + /* istanbul ignore if */ + if (itemRowIndices.length === 0) return + if (delta === 'first') { + setFocusedRowIndex(itemRowIndices[0]) + return + } + if (delta === 'last') { + setFocusedRowIndex(itemRowIndices[itemRowIndices.length - 1]) + return + } + const currentRank = itemRowIndices.indexOf(focusedRowIndex) + const nextRank = Math.max( + 0, + Math.min(itemRowIndices.length - 1, (currentRank === -1 ? 0 : currentRank) + delta) + ) + setFocusedRowIndex(itemRowIndices[nextRank]) + }, + [focusedRowIndex, itemRowIndices] + ) + + const moveToParent = useCallback(() => { + /* istanbul ignore if */ + if (focusedRowIndex < 0) return + const focusedRow = visibleRows[focusedRowIndex] + /* istanbul ignore if */ + if (focusedRow.kind !== 'item') return + const parentDepth = focusedRow.depth - 1 + if (parentDepth < 0) return + for (let i = focusedRowIndex - 1; i >= 0; i--) { + const r = visibleRows[i] + if (r.kind === 'item' && r.depth === parentDepth) { + setFocusedRowIndex(i) + return + } + } + }, [focusedRowIndex, visibleRows]) + + const onRowKeyDown = useCallback( + (event: KeyboardEvent) => { + /* istanbul ignore if */ + if (focusedRowIndex < 0) return + const focusedRow = visibleRows[focusedRowIndex] + /* istanbul ignore if */ + if (focusedRow.kind !== 'item') return + const item = focusedRow.node + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + moveFocus(1) + return + case 'ArrowUp': + event.preventDefault() + moveFocus(-1) + return + case 'Home': + event.preventDefault() + moveFocus('first') + return + case 'End': + event.preventDefault() + moveFocus('last') + return + case 'ArrowRight': + if (isFileTreeFolder(item)) { + event.preventDefault() + if (!tree.expanded.has(item.path)) { + void handleToggleExpansion(item) + } else { + moveFocus(1) + } + } + return + case 'ArrowLeft': + event.preventDefault() + if (isFileTreeFolder(item) && tree.expanded.has(item.path)) { + tree.collapse(item.path) + } else { + moveToParent() + } + return + case ' ': + case 'Spacebar': + event.preventDefault() + if (isFileTreeFile(item)) { + handleToggleSelectionFile(item) + } else { + handleToggleSelectionFolder(item) + } + return + case 'Enter': + if (isFileTreeFolder(item)) { + event.preventDefault() + void handleToggleExpansion(item) + } + // For files, let the default Enter behavior activate the + // filename anchor inside the row (browser handles it). + return + default: + return + } + }, + [ + focusedRowIndex, + handleToggleExpansion, + handleToggleSelectionFile, + handleToggleSelectionFolder, + moveFocus, + moveToParent, + tree, + visibleRows + ] + ) + + // Header select-all state + handler. Must live above the early + // returns so the hook count stays stable when the component swaps + // between loading / error / empty / loaded states. The state value + // is recomputed each render (cheap), so we stash it on a ref the + // JSX below reads — that way we don't add a useMemo + churn. + const headerSelectAllStateRef = useRef('none') + { + const totalCount = selection.totals.count + const hasFolders = selection.totals.hasLogicalFolders + const topLevel = tree.rootNode.items + if (totalCount === 0 && !hasFolders) { + headerSelectAllStateRef.current = 'none' + } else if (topLevel.length === 0) { + headerSelectAllStateRef.current = 'partial' + } else { + const everyTopSelected = topLevel.every((item) => + isFileTreeFile(item) + ? selection.fileState(item) === 'all' + : selection.folderState(item, tree.visibleKnownChildren(item.path)) === 'all' + ) + headerSelectAllStateRef.current = everyTopSelected ? 'all' : 'partial' + } + } + const onToggleSelectAll = useCallback(() => { + selection.toggleAll(tree.rootNode.items) + }, [selection, tree.rootNode.items]) + + const isInitialLoad = !tree.rootNode.loaded && tree.rootNode.loading + + if (isInitialLoad) { + return ( +
+ +
+
+
+ +
+
{t('tree.state.loading')}
+
+
+
+ ) + } + + if (tree.rootNode.error && tree.rootNode.items.length === 0) { + return ( +
+ +
+
+
+ +
+
{t('tree.state.error')}
+
{tree.rootNode.error}
+ +
+
+
+ ) + } + + if (totalRows === 0) { + return ( +
+ + +
+
+
+ +
+
{query ? t('tree.state.noMatches', { query }) : t('tree.state.empty')}
+
+
+
+ ) + } + + const streamingZipActive = !['idle', 'done', 'error', 'cancelled'].includes( + streamingZip.state.status + ) + + return ( +
+ + +
+
+ {slice.map((row, sliceIndex) => { + const absoluteIndex = startIdx + sliceIndex + const top = absoluteIndex * rowHeight + if (row.kind === 'loading') { + return ( + + {t('tree.state.loadingFolder')} + + ) + } + if (row.kind === 'error') { + return ( + + {row.error}{' '} + + + ) + } + if (row.kind === 'load-more') { + const node = tree.nodes.get(row.path) + return ( + + + + ) + } + const item = row.node + const knownChildren = isFileTreeFolder(item) ? tree.visibleKnownChildren(item.path) : [] + const selectionState = isFileTreeFile(item) + ? selection.fileState(item) + : selection.folderState(item, knownChildren) + const expanded = isFileTreeFolder(item) ? tree.expanded.has(item.path) : undefined + return ( + void handleToggleExpansion(item) : undefined + } + onToggleSelection={() => { + if (isFileTreeFile(item)) { + handleToggleSelectionFile(item) + } else { + handleToggleSelectionFolder(item) + } + }} + onDownload={downloadsDisabled ? undefined : () => handleDownloadOne(item)} + datasetVersionNumber={datasetVersion.number} + buildFileMetadataUrl={buildFileMetadataUrl} + focused={absoluteIndex === focusedRowIndex} + onFocus={() => setFocusedRowIndex(absoluteIndex)} + onRowKeyDown={onRowKeyDown} + /> + ) + })} +
+
+ { + setTrayOpen(false) + streamingZip.close() + }} + /> +
+ ) +} + +interface FilesTreeToolbarProps { + selection: ReturnType + download: ReturnType + disableDownload?: boolean + streamingZipActive?: boolean +} + +function FilesTreeToolbar({ + selection, + download, + disableDownload, + streamingZipActive +}: FilesTreeToolbarProps) { + const { t } = useTranslation('files') + const { count, bytes, hasLogicalFolders } = selection.totals + const downloadable = !disableDownload && (count > 0 || hasLogicalFolders) + const enumerating = download.progress.status === 'enumerating' + const requesting = download.progress.status === 'requesting' + + return ( +
+
+ + {count === 0 && !hasLogicalFolders ? ( + {t('tree.selection.none')} + ) : ( + <> + {count.toLocaleString()}{' '} + {t('tree.selection.fileCount', { count })} + {hasLogicalFolders && ( + <> + · + {t('tree.selection.includesFolders')} + + )} + {bytes > 0 && ( + <> + · + {formatBytes(bytes)} + + )} + + )} + +
+
+ + +
+
+ ) +} + +interface RowMessageProps { + top: number + height: number + depth: number + className: string + children: React.ReactNode +} + +function RowMessage({ top, height, depth, className, children }: RowMessageProps) { + // 14 row padding + 28 select column + 8 column gap + depth indent + + // 22 (twisty 18 + 4 gap) so the message text aligns with where a + // row's label would start. + const indent: CSSProperties = { + paddingLeft: 14 + 28 + 8 + depth * 18 + 22, + top, + height + } + return ( +
+ {children} +
+ ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx new file mode 100644 index 000000000..2bdc410d8 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx @@ -0,0 +1,45 @@ +import { KeyboardEvent, MouseEvent } from 'react' +import cn from 'classnames' +import styles from './FilesTree.module.scss' +import { SelectionState } from './useFileTreeSelection' + +interface FilesTreeCheckboxProps { + state: SelectionState + onToggle: () => void + label: string + testId?: string +} + +export function FilesTreeCheckbox({ state, onToggle, label, testId }: FilesTreeCheckboxProps) { + const handleClick = (event: MouseEvent) => { + event.stopPropagation() + onToggle() + } + const handleKey = (event: KeyboardEvent) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + onToggle() + } + } + // Inside a tree row we follow the WAI-ARIA tree pattern: only the + // focused row participates in the page tab order (roving tabindex on + // the row itself), and Space on that row toggles selection. The + // checkbox handles mouse / keyboard activation when focused but is + // skipped on Tab so the user doesn't have to tab through every row. + return ( + + ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.module.scss b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.module.scss new file mode 100644 index 000000000..a24d8ac15 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.module.scss @@ -0,0 +1,196 @@ +.overlay { + position: fixed; + inset: 0; + background: rgb(20 18 12 / 18%); + z-index: 1080; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; +} + +.overlay-open { + opacity: 1; + pointer-events: auto; +} + +.tray { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%) translateY(140%); + width: min(640px, calc(100vw - 32px)); + background: var(--bs-white, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 6px; + box-shadow: 0 6px 24px rgb(20 18 12 / 18%); + z-index: 1090; + transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1); + overflow: hidden; +} + +.tray-open { + transform: translateX(-50%) translateY(0); +} + +.tray-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--bs-border-color, #dee2e6); +} + +.tray-title { + font-size: 14px; + font-weight: 500; + color: var(--bs-body-color, #212529); +} + +.tray-meta { + font-family: inherit; + font-size: 11.5px; + color: var(--bs-secondary-color, #6c757d); + margin-top: 2px; +} + +// Dedicated close button. Sized larger than Bootstrap's default +// `Button size="sm"` so the × is comfortably clickable in the +// JSF-embedded tray (where the host page's font-size cascade does +// not reach into the shadow DOM and the link-variant button would +// otherwise read as a tiny x). Square hit-target with a clear hover +// state. +.close-btn { + flex: 0 0 auto; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + border-radius: 4px; + color: var(--bs-secondary-color, #6c757d); + font-size: 22px; + line-height: 1; + cursor: pointer; + padding: 0; +} + +.close-btn:hover { + background-color: rgb(0 0 0 / 6%); + color: var(--bs-body-color, #212529); +} + +.close-btn:focus-visible { + outline: 2px solid var(--bs-primary, #0d6efd); + outline-offset: 1px; +} + +.tray-body { + padding: 16px; +} + +.progress { + width: 100%; + height: 6px; + background: var(--bs-light, #f8f9fa); + border-radius: 999px; + overflow: hidden; + position: relative; +} + +.progress-bar { + height: 100%; + background: var(--bs-primary, #0d6efd); + width: 0%; + transition: width 200ms ease; +} + +.progress-error .progress-bar { + background: var(--bs-danger, #dc3545); +} + +.progress-done .progress-bar { + background: var(--bs-success, #198754); +} + +.tray-status { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; + font-family: inherit; + font-size: 11.5px; +} + +.tray-status-now { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + margin-right: 12px; + color: var(--bs-secondary-color, #6c757d); +} + +.tray-status-pct { + font-variant-numeric: tabular-nums; + color: var(--bs-body-color, #212529); +} + +.tray-fail { + margin-top: 14px; + padding: 12px; + border: 1px solid var(--bs-border-color, #dee2e6); + border-left: 2px solid var(--bs-warning, #ffc107); + background: rgb(255 193 7 / 6%); + border-radius: 4px; + font-size: 13px; +} + +.tray-fail-heading { + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; + color: var(--bs-body-color, #212529); + margin-bottom: 4px; +} + +.tray-fail-file { + font-family: inherit; + font-size: 12px; + color: var(--bs-secondary-color, #6c757d); + word-break: break-all; +} + +.tray-fail-err { + font-family: inherit; + font-size: 11px; + color: var(--bs-tertiary-color, #adb5bd); + margin-top: 2px; +} + +.tray-fail-actions { + margin-top: 10px; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.tray-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid var(--bs-border-color, #dee2e6); + background: var(--bs-light, #f8f9fa); +} + +.tray-hint { + font-family: inherit; + font-size: 11px; + color: var(--bs-tertiary-color, #adb5bd); +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx new file mode 100644 index 000000000..5ec9d773e --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx @@ -0,0 +1,233 @@ +import { useTranslation } from 'react-i18next' +import { Button } from '@iqss/dataverse-design-system' +import cn from 'classnames' +import { StreamingZipApi } from './useStreamingZipDownload' +import { WarnIcon } from './icons/FilesTreeIcons' +import { formatBytes } from './format' +import styles from './FilesTreeDownloadTray.module.scss' + +interface FilesTreeDownloadTrayProps { + api: StreamingZipApi + open: boolean + onClose: () => void +} + +/** + * Bottom-sheet status panel for the streaming-zip download. Shows + * overall progress, the file currently being added, and surfaces the + * pause-on-fail / two-pass decisions as inline buttons. Stays out of + * the user's way while they continue browsing. + */ +export function FilesTreeDownloadTray({ api, open, onClose }: FilesTreeDownloadTrayProps) { + const { t } = useTranslation('files') + const { state } = api + + const isPaused = state.status === 'paused' + const isAwaitingRetry = state.status === 'awaiting-retry' + const isDone = state.status === 'done' + const isCancelled = state.status === 'cancelled' + const isError = state.status === 'error' + const lastRecoverableFailure = [...state.failedSoFar].reverse().find((f) => f.recoverable) + + const pct = state.totalBytes > 0 ? Math.min(100, (state.bytesDone / state.totalBytes) * 100) : 0 + + let title: string = t('tree.download.tray.preparing', 'Preparing zip…') + if (isPaused) title = t('tree.download.tray.paused', 'Download paused — file failed') + else if (isAwaitingRetry) + title = t('tree.download.tray.firstPass', { + defaultValue: 'First pass complete — {{count}} file(s) failed', + count: state.failedSoFar.length + }) + else if (isDone && state.failedSoFar.length === 0 && state.verificationFailures.length === 0) + title = t('tree.download.tray.complete', 'Download complete') + else if (isDone && state.failedSoFar.length > 0 && state.verificationFailures.length === 0) + title = t('tree.download.tray.completeWithSkipped', { + defaultValue: 'Download complete — {{count}} skipped', + count: state.failedSoFar.length + }) + // The verification-failure title takes precedence over the + // skipped-files variant when both happen in the same run — the + // verification miss is the surprising, actionable signal (user + // should re-download the bad file), the skips already had their + // own dialog earlier. Both still appear in `manifest.txt`. + else if (isDone && state.verificationFailures.length > 0) + title = t('tree.download.tray.completeWithVerificationFailures', { + defaultValue: 'Download complete — {{count}} file(s) failed checksum verification', + count: state.verificationFailures.length + }) + // The 'error' status is reached only from the IIFE catch in the + // engine — itself a defensive path covered by /* istanbul ignore */ + // — so the matching branch here is unreachable from the spec. + /* istanbul ignore next */ else if (isError) + title = t('tree.download.tray.error', 'Download failed') + else if (isCancelled) title = t('tree.download.tray.cancelled', 'Download cancelled') + else if (state.filesDone > 0 || state.bytesDone > 0) + title = t('tree.download.tray.streaming', 'Streaming files into zip…') + + const nowText = isPaused + ? t('tree.download.tray.now.paused', 'Paused') + : isAwaitingRetry + ? t('tree.download.tray.now.awaiting', 'Awaiting retry decision') + : isDone + ? t('tree.download.tray.now.done', 'Done') + : isCancelled + ? t('tree.download.tray.now.cancelled', 'Cancelled') + : state.current + ? `▸ ${state.current.path}` + : '…' + + return ( + <> +
{ + if (isDone || isCancelled || isError) onClose() + }} + aria-hidden + /> +
+
+
+
{title}
+
+ {state.filesDone} / {state.totalFiles} {t('tree.download.tray.files', 'files')} ·{' '} + {formatBytes(state.bytesDone)} / {formatBytes(state.totalBytes)} + {state.pass === 2 && <> · {t('tree.download.tray.pass2', 'pass 2 of 2')}} +
+
+ +
+ +
+
+
+
+
+
{nowText}
+
{pct.toFixed(1)}%
+
+ + {isPaused && lastRecoverableFailure && ( +
+
+ {t('tree.download.tray.failed', 'Failed to fetch file')} +
+
{lastRecoverableFailure.path}
+
{lastRecoverableFailure.error}
+
+ + + + +
+
+ )} + + {isAwaitingRetry && ( +
+
+ {' '} + {t('tree.download.tray.notIncluded', { + defaultValue: '{{count}} file(s) were not included in the zip', + count: state.failedSoFar.length + })} +
+
+ {t( + 'tree.download.tray.twopassHint', + 'First pass finished. The zip will be finalised after a second-pass retry of the failures.' + )} +
+
+ + +
+
+ )} + + {isDone && state.failedSoFar.length > 0 && !isAwaitingRetry && ( +
+
+ {' '} + {t('tree.download.tray.skipped', { + defaultValue: '{{count}} file(s) skipped', + count: state.failedSoFar.length + })} +
+
+ {t( + 'tree.download.tray.skippedManifest', + 'A manifest.txt listing skipped files has been added to the root of the zip.' + )} +
+
+ )} + + {/* istanbul ignore next */} + {isError && state.message && ( +
+
+ {t('tree.download.tray.error', 'Download failed')} +
+
{state.message}
+
+ )} +
+ +
+
+ {t('tree.download.tray.hint', 'Streamed locally into one zip — no server-side ZIP.')} +
+ {!isDone && !isAwaitingRetry && !isError && !isCancelled && ( + + )} + {(isDone || isCancelled || isError) && ( + + )} +
+
+ + ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeHeader.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeHeader.tsx new file mode 100644 index 000000000..20d0e44d3 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeHeader.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next' +import { FilesTreeCheckbox } from './FilesTreeCheckbox' +import { SelectionState } from './useFileTreeSelection' +import styles from './FilesTree.module.scss' + +interface FilesTreeHeaderProps { + /** Aggregate selection state for the visible tree, controls the + * header's select-all checkbox visual. Omit to hide the checkbox. */ + selectAllState?: SelectionState + onToggleSelectAll?: () => void +} + +/** + * Visual sticky-labels strip above the tree viewport. The accessible + * tree pattern lives on the rows themselves (`role="tree"` / + * `role="treeitem"` further down). The header itself is decorative + * (`aria-hidden`); the only interactive element is the optional + * select-all checkbox in the dedicated select column. + */ +export function FilesTreeHeader({ selectAllState, onToggleSelectAll }: FilesTreeHeaderProps = {}) { + const { t } = useTranslation('files') + return ( +
+
+ {selectAllState && onToggleSelectAll ? ( + + ) : null} +
+
{t('tree.head.name', 'Name')}
+
+ {t('tree.head.size', 'Size')} +
+
+ {t('tree.head.access', 'Access')} +
+
+ {t('tree.head.count', 'Files')} +
+
+
+ ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx new file mode 100644 index 000000000..50d7f7dfb --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx @@ -0,0 +1,214 @@ +import { CSSProperties, KeyboardEvent, MouseEvent, RefObject } from 'react' +import cn from 'classnames' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { FileTreeFile, FileTreeFolder, isFileTreeFile } from '@/files/domain/models/FileTreeItem' +import { DatasetVersionNumber } from '@/dataset/domain/models/Dataset' +import { QueryParamKey, Route } from '@/sections/Route.enum' +import { FilesTreeCheckbox } from './FilesTreeCheckbox' +import { + ChevronIcon, + DownloadIcon, + FileIcon, + FolderIcon, + FolderOpenIcon +} from './icons/FilesTreeIcons' +import styles from './FilesTree.module.scss' +import { SelectionState } from './useFileTreeSelection' +import { formatBytes, formatCount, formatFileAccess, formatFolderAccess } from './format' + +interface FilesTreeRowProps { + depth: number + top: number + height: number + item: FileTreeFile | FileTreeFolder + selectionState: SelectionState + expanded?: boolean + onToggleSelection: () => void + onToggleExpansion?: () => void + /** + * Per-row download trigger. When omitted, the download icon is + * hidden — used by hosts that need to gate downloads behind a + * terms-of-use modal that the row itself cannot open. + */ + onDownload?: () => void + datasetVersionNumber: DatasetVersionNumber + /** + * Optional URL builder for the filename → file metadata link. When + * provided, the row renders a plain `` (works in JSF + * standalone embeds where there is no React Router). When omitted, + * the row falls back to a SPA `` pointing at the SPA file + * page. + */ + buildFileMetadataUrl?: (file: FileTreeFile) => string + /** + * Whether this row is the focused row in the roving-tabindex + * keyboard model. Only one row at a time has tabIndex=0; the rest + * are tabIndex=-1. + */ + focused?: boolean + /** Called when this row receives focus (e.g. via Tab into the tree). */ + onFocus?: () => void + /** Forwarded to the row's underlying div so the parent can scroll it into view. */ + rowRef?: RefObject + /** Keyboard handler shared by all rows; lives on the parent for navigation logic. */ + onRowKeyDown?: (event: KeyboardEvent) => void +} + +const INDENT_BASE = 14 +const INDENT_PER_LEVEL = 18 + +export function FilesTreeRow({ + depth, + top, + height, + item, + selectionState, + expanded, + onToggleSelection, + onToggleExpansion, + onDownload, + datasetVersionNumber, + buildFileMetadataUrl, + focused, + onFocus, + rowRef, + onRowKeyDown +}: FilesTreeRowProps) { + const { t } = useTranslation('files') + const isFile = isFileTreeFile(item) + const indent: CSSProperties = { + paddingLeft: INDENT_BASE + depth * INDENT_PER_LEVEL + } + const rowStyle: CSSProperties = { top, height } + const Icon = isFile ? FileIcon : expanded ? FolderOpenIcon : FolderIcon + + const handleRowClick = (event: MouseEvent) => { + const target = event.target as HTMLElement + if (target.closest('a, button, [role="checkbox"]')) { + return + } + onToggleSelection() + } + + const handleTwistyClick = (event: MouseEvent) => { + event.stopPropagation() + onToggleExpansion?.() + } + + return ( +
+
+ +
+
+
+ {isFile ? formatBytes(item.size) : formatBytes(item.counts?.bytes)} +
+
0, + [styles['row-access-embargoed']]: isFile + ? item.accessStatus === 'embargoed' + : (item.counts?.restricted ?? 0) === 0 && (item.counts?.embargoed ?? 0) > 0 + })} + data-testid={`files-tree-row-access-${item.path}`}> + {isFile ? formatFileAccess(item.accessStatus) : formatFolderAccess(item.counts)} +
+
+ {!isFile && item.counts ? formatCount(item.counts.files) : ''} +
+
+ {onDownload ? ( + + ) : null} +
+
+ ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/format.ts b/src/sections/dataset/dataset-files/files-tree/format.ts new file mode 100644 index 000000000..5077dee05 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/format.ts @@ -0,0 +1,58 @@ +export function formatBytes(input: number | undefined): string { + if (input === undefined || input === null || Number.isNaN(input)) { + return '' + } + if (input < 1024) { + return `${input} B` + } + if (input < 1024 * 1024) { + return `${(input / 1024).toFixed(1)} KB` + } + if (input < 1024 * 1024 * 1024) { + return `${(input / (1024 * 1024)).toFixed(1)} MB` + } + return `${(input / (1024 * 1024 * 1024)).toFixed(2)} GB` +} + +export function formatCount(input: number | undefined): string { + if (input === undefined || input === null || Number.isNaN(input)) { + return '' + } + if (input < 1000) { + return input.toString() + } + return `${(input / 1000).toFixed(1)}k` +} + +export type FileAccessStatus = 'public' | 'restricted' | 'embargoed' + +/** + * Per-file access label. Empty string when access is unknown (e.g. + * older server / SDK that didn't surface the field). Capitalised so the + * tree row can render it as a self-contained word; the SCSS layer adds + * the colour cue for restricted / embargoed. + */ +export function formatFileAccess(access: FileAccessStatus | undefined): string { + if (!access) return '' + return access[0].toUpperCase() + access.slice(1) +} + +/** + * Folder-row access cell. Mirrors the per-file resolution recursively + * over the subtree: a folder with any restricted file gets a "restricted" + * count, a folder with any non-restricted-but-actively-embargoed file + * gets an "embargoed" count, both can co-exist. All-public folders + * intentionally render as empty string so the column reads as + * "anything here that the user should know about?" — public is the + * default, no extra noise needed. + */ +export function formatFolderAccess( + counts: { restricted?: number; embargoed?: number } | undefined +): string { + const r = counts?.restricted ?? 0 + const e = counts?.embargoed ?? 0 + if (r > 0 && e > 0) return `${r} restricted · ${e} embargoed` + if (r > 0) return `${r} restricted` + if (e > 0) return `${e} embargoed` + return '' +} diff --git a/src/sections/dataset/dataset-files/files-tree/icons/FilesTreeIcons.tsx b/src/sections/dataset/dataset-files/files-tree/icons/FilesTreeIcons.tsx new file mode 100644 index 000000000..11d9940af --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/icons/FilesTreeIcons.tsx @@ -0,0 +1,122 @@ +/** + * Inline SVG glyphs used by the tree view rows and toolbar. + * + * Kept inline (rather than pulling another icon set) because the row icons + * are rendered in volume during virtualization and we want to avoid a font + * round-trip or a heavier icon component for each row. + */ +export function FolderIcon() { + return ( + + + + ) +} + +export function FolderOpenIcon() { + return ( + + + + + ) +} + +export function FileIcon() { + return ( + + + + + ) +} + +export function ChevronIcon() { + return ( + + + + ) +} + +export function DownloadIcon() { + return ( + + + + ) +} + +export function WarnIcon() { + return ( + + + + + ) +} + +export function EmptyIcon() { + return ( + + + + ) +} + +export function SpinnerIcon() { + return ( + + + + + + ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTree.ts b/src/sections/dataset/dataset-files/files-tree/useFileTree.ts new file mode 100644 index 000000000..a82b20a7d --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTree.ts @@ -0,0 +1,349 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + FileTreeRepository, + GetFileTreeNodeParams +} from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeFile, FileTreeItem, isFileTreeFile } from '@/files/domain/models/FileTreeItem' +import { FileTreeInclude, FileTreeOrder } from '@/files/domain/models/FileTreePage' +import { DatasetVersion } from '@/dataset/domain/models/Dataset' + +export interface FolderNode { + path: string + items: FileTreeItem[] + nextCursor: string | null + loading: boolean + error?: string + loaded: boolean +} + +export interface UseFileTreeArgs { + repository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + pageSize?: number + order?: FileTreeOrder + include?: FileTreeInclude + /** + * Path to expand on mount — typically read from a `?path=` URL query + * param so a deep link opens the tree at the bookmarked folder. The + * hook expands every ancestor along the way (e.g. `data/raw/2024` + * causes `data`, `data/raw`, and `data/raw/2024` all to be expanded). + */ + initialPath?: string +} + +export interface UseFileTreeApi { + rootNode: FolderNode + nodes: ReadonlyMap + expanded: ReadonlySet + /** + * The deepest folder currently in the expanded set. Empty string means + * only the root is expanded. Useful for surfacing a single canonical + * path to a URL bookmark. + */ + currentPath: string + toggleExpanded: (path: string) => Promise + expand: (path: string) => Promise + collapse: (path: string) => void + loadMore: (path: string) => Promise + refresh: (path?: string) => Promise + registerKnownFile: (file: FileTreeFile) => void + knownFiles: ReadonlyMap + visibleKnownChildren: (path: string) => FileTreeItem[] +} + +const ROOT = '' + +/** + * Returns the chain of ancestor paths for a folder, including the folder + * itself but excluding the empty root. For `data/raw/2024` → + * `['data', 'data/raw', 'data/raw/2024']`. + */ +function ancestorChain(path: string): string[] { + if (!path) { + return [] + } + const parts = path.split('/').filter((p) => p.length > 0) + const out: string[] = [] + let acc = '' + for (const part of parts) { + acc = acc ? `${acc}/${part}` : part + out.push(acc) + } + return out +} + +/** + * Picks the deepest folder from a set of expanded paths — used to derive + * `currentPath` for URL bookmarking. Returns `''` if no non-root folder + * is expanded. + */ +function deepestExpanded(set: ReadonlySet): string { + let deepest = '' + let depth = 0 + for (const path of set) { + if (!path) continue + const d = path.split('/').length + if (d > depth) { + deepest = path + depth = d + } + } + return deepest +} + +export function useFileTree({ + repository, + datasetPersistentId, + datasetVersion, + pageSize = 200, + order = FileTreeOrder.NAME_AZ, + include = FileTreeInclude.ALL, + initialPath = '' +}: UseFileTreeArgs): UseFileTreeApi { + const [nodes, setNodes] = useState>(() => new Map()) + const initialExpanded = (() => { + const set = new Set([ROOT]) + for (const ancestor of ancestorChain(initialPath)) { + set.add(ancestor) + } + return set + })() + const [expanded, setExpanded] = useState>(() => initialExpanded) + const knownFilesRef = useRef>(new Map()) + const inFlight = useRef>>(new Map()) + const versionKey = `${datasetPersistentId}::${datasetVersion.number.toString()}::${order}::${include}` + const previousKey = useRef(versionKey) + // True only while the hook's host component is mounted. Set to false + // by the cleanup effect below so any fetch promise that resolves AFTER + // unmount becomes a no-op instead of pushing state into a defunct + // hook instance. Without this guard, switching rapidly between the + // tree view and the table view occasionally left the next mount + // showing the "loading" spinner forever, because a slow getNode that + // started under the previous mount completed against the wrong + // setState closure. + const mountedRef = useRef(true) + + const setNode = useCallback((path: string, updater: (prev: FolderNode) => FolderNode) => { + if (!mountedRef.current) return + setNodes((prev) => { + const next = new Map(prev) + const current = prev.get(path) ?? { + path, + items: [], + nextCursor: null, + loading: false, + loaded: false + } + next.set(path, updater(current)) + return next + }) + }, []) + + const fetchPage = useCallback( + async (path: string, cursor?: string) => { + const params: GetFileTreeNodeParams = { + datasetPersistentId, + datasetVersion, + path, + limit: pageSize, + cursor, + order, + include + } + setNode(path, (prev) => ({ ...prev, loading: true, error: undefined })) + try { + const page = await repository.getNode(params) + for (const item of page.items) { + if (isFileTreeFile(item)) { + knownFilesRef.current.set(item.path, item) + } + } + setNode(path, (prev) => ({ + ...prev, + items: cursor ? [...prev.items, ...page.items] : page.items, + nextCursor: page.nextCursor, + loading: false, + loaded: true, + error: undefined + })) + } catch (error) { + setNode(path, (prev) => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : String(error) + })) + } + }, + [datasetPersistentId, datasetVersion, include, order, pageSize, repository, setNode] + ) + + const ensureLoaded = useCallback( + (path: string): Promise => { + const existing = nodes.get(path) + if (existing && existing.loaded && !existing.error) { + return Promise.resolve() + } + const pending = inFlight.current.get(path) + if (pending) { + return pending + } + const promise = fetchPage(path).finally(() => { + inFlight.current.delete(path) + }) + inFlight.current.set(path, promise) + return promise + }, + [fetchPage, nodes] + ) + + useEffect(() => { + if (previousKey.current !== versionKey) { + previousKey.current = versionKey + setNodes(new Map()) + const reset = new Set([ROOT]) + for (const ancestor of ancestorChain(initialPath)) { + reset.add(ancestor) + } + setExpanded(reset) + knownFilesRef.current = new Map() + inFlight.current.clear() + // Reset path: bypass ensureLoaded's cache check (which closes + // over the pre-reset `nodes` map and would short-circuit because + // the old root was `loaded: true`). fetchPage runs unconditionally + // and uses the latest fetchPage closure (which is keyed off the + // new versionKey via its useCallback deps). + void fetchPage(ROOT) + for (const ancestor of ancestorChain(initialPath)) { + void fetchPage(ancestor) + } + return + } + void ensureLoaded(ROOT) + // Pre-fetch every initial-path ancestor so the tree opens to the + // bookmarked depth on mount without the user clicking through. + for (const ancestor of ancestorChain(initialPath)) { + void ensureLoaded(ancestor) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [versionKey]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const expand = useCallback( + async (path: string) => { + setExpanded((prev) => { + if (prev.has(path)) { + return prev + } + const next = new Set(prev) + next.add(path) + return next + }) + await ensureLoaded(path) + }, + [ensureLoaded] + ) + + const collapse = useCallback((path: string) => { + setExpanded((prev) => { + if (!prev.has(path)) { + return prev + } + const next = new Set(prev) + next.delete(path) + // Also drop every descendant from the expanded set. Without this, + // collapsing `data` after the user opened `data/sub` leaves + // `data/sub` in the set; `currentPath` (deepest expanded) still + // reports `data/sub`, so the URL bookmark and a subsequent reload + // re-open the very branch the user just collapsed. + const prefix = path === '' ? '' : `${path}/` + for (const p of Array.from(next)) { + if (p !== path && (path === '' ? p !== '' : p.startsWith(prefix))) { + next.delete(p) + } + } + return next + }) + }, []) + + const toggleExpanded = useCallback( + async (path: string) => { + if (expanded.has(path)) { + collapse(path) + } else { + await expand(path) + } + }, + [collapse, expand, expanded] + ) + + const loadMore = useCallback( + async (path: string) => { + const existing = nodes.get(path) + if (!existing || !existing.nextCursor || existing.loading) { + return + } + await fetchPage(path, existing.nextCursor) + }, + [fetchPage, nodes] + ) + + const refresh = useCallback( + async (path?: string) => { + const target = path ?? ROOT + setNodes((prev) => { + const next = new Map(prev) + next.delete(target) + return next + }) + await fetchPage(target) + }, + [fetchPage] + ) + + const registerKnownFile = useCallback((file: FileTreeFile) => { + knownFilesRef.current.set(file.path, file) + }, []) + + const visibleKnownChildren = useCallback( + (path: string): FileTreeItem[] => { + const out: FileTreeItem[] = [] + for (const node of nodes.values()) { + if (path === '' || node.path === path || node.path.startsWith(`${path}/`)) { + out.push(...node.items) + } + } + return out + }, + [nodes] + ) + + const rootNode: FolderNode = nodes.get(ROOT) ?? { + path: ROOT, + items: [], + nextCursor: null, + loading: true, + loaded: false + } + + return { + rootNode, + nodes, + expanded, + currentPath: deepestExpanded(expanded), + toggleExpanded, + expand, + collapse, + loadMore, + refresh, + registerKnownFile, + knownFiles: knownFilesRef.current, + visibleKnownChildren + } +} diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts b/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts new file mode 100644 index 000000000..daaac95b3 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts @@ -0,0 +1,193 @@ +import { useCallback, useState } from 'react' +import { FileTreeRepository } from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeFile, FileTreeFolder, isFileTreeFile } from '@/files/domain/models/FileTreeItem' +import { enumerateFileTreeFiles } from '@/files/domain/useCases/enumerateFileTreeFiles' +import { DatasetVersion } from '@/dataset/domain/models/Dataset' +import { FileTreeSelection } from './useFileTreeSelection' + +export interface DownloadProgress { + status: 'idle' | 'enumerating' | 'requesting' | 'success' | 'error' + enumeratedCount: number + message?: string +} + +export interface UseFileTreeDownloadArgs { + treeRepository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + selection: FileTreeSelection + onError?: (error: unknown) => void + /** + * Caller decides how to actually trigger the download for a list of + * files (e.g. direct anchor click for one file, streaming zip for + * many). The hook only owns the enumeration step. + */ + onDownloadFiles: (files: FileTreeFile[]) => Promise | void +} + +export interface UseFileTreeDownloadApi { + progress: DownloadProgress + downloadSelection: () => Promise + downloadNode: (node: FileTreeFile | FileTreeFolder) => Promise + reset: () => void +} + +export function useFileTreeDownload({ + treeRepository, + datasetPersistentId, + datasetVersion, + selection, + onError, + onDownloadFiles +}: UseFileTreeDownloadArgs): UseFileTreeDownloadApi { + const [progress, setProgress] = useState({ + status: 'idle', + enumeratedCount: 0 + }) + + const reset = useCallback(() => { + setProgress({ status: 'idle', enumeratedCount: 0 }) + }, []) + + const dispatchFiles = useCallback( + async (files: FileTreeFile[]) => { + if (files.length === 0) { + // No files to dispatch (empty folder, or every selected file + // got filtered out by the deselect overrides). Reset progress + // back to idle so the toolbar doesn't get stuck showing + // `enumerating` / `requesting` indefinitely. + setProgress({ status: 'idle', enumeratedCount: 0 }) + return + } + setProgress({ status: 'requesting', enumeratedCount: files.length }) + try { + await onDownloadFiles(files) + setProgress({ status: 'success', enumeratedCount: files.length }) + } catch (error) { + setProgress({ + status: 'error', + enumeratedCount: files.length, + message: error instanceof Error ? error.message : String(error) + }) + onError?.(error) + } + }, + [onDownloadFiles, onError] + ) + + const collectExplicitFiles = useCallback((): FileTreeFile[] => { + const out: FileTreeFile[] = [] + for (const path of selection.selectedFilePaths) { + const file = lookupFile(selection.filesById, path) + if (file) { + out.push(file) + } + } + return out + }, [selection]) + + const downloadSelection = useCallback(async () => { + const explicit = collectExplicitFiles() + const folderPaths = Array.from(selection.selectedFolderPaths) + if (explicit.length === 0 && folderPaths.length === 0) { + return + } + + let enumerated: FileTreeFile[] = [] + if (folderPaths.length > 0) { + setProgress({ status: 'enumerating', enumeratedCount: 0 }) + try { + enumerated = await enumerateFileTreeFiles(treeRepository, { + datasetPersistentId, + datasetVersion, + paths: folderPaths + }) + } catch (error) { + setProgress({ + status: 'error', + enumeratedCount: 0, + message: error instanceof Error ? error.message : String(error) + }) + onError?.(error) + return + } + } + + const merged = mergeFiles(explicit, enumerated, selection.deselectedFilePaths) + await dispatchFiles(merged) + }, [ + collectExplicitFiles, + datasetPersistentId, + datasetVersion, + dispatchFiles, + onError, + selection.deselectedFilePaths, + selection.selectedFolderPaths, + treeRepository + ]) + + const downloadNode = useCallback( + async (node: FileTreeFile | FileTreeFolder) => { + if (isFileTreeFile(node)) { + await dispatchFiles([node]) + return + } + setProgress({ status: 'enumerating', enumeratedCount: 0 }) + try { + const files = await enumerateFileTreeFiles(treeRepository, { + datasetPersistentId, + datasetVersion, + paths: [node.path] + }) + await dispatchFiles(files) + } catch (error) { + setProgress({ + status: 'error', + enumeratedCount: 0, + message: error instanceof Error ? error.message : String(error) + }) + onError?.(error) + } + }, + [datasetPersistentId, datasetVersion, dispatchFiles, onError, treeRepository] + ) + + return { progress, downloadSelection, downloadNode, reset } +} + +function lookupFile(filesById: Map, path: string): FileTreeFile | undefined { + for (const file of filesById.values()) { + if (file.path === path) { + return file + } + } + return undefined +} + +function mergeFiles( + explicit: FileTreeFile[], + enumerated: FileTreeFile[], + deselected: ReadonlySet +): FileTreeFile[] { + const seen = new Set() + const out: FileTreeFile[] = [] + for (const file of explicit) { + if (deselected.has(file.path)) { + continue + } + if (!seen.has(file.id)) { + seen.add(file.id) + out.push(file) + } + } + for (const file of enumerated) { + if (deselected.has(file.path)) { + continue + } + if (!seen.has(file.id)) { + seen.add(file.id) + out.push(file) + } + } + return out +} diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.ts b/src/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.ts new file mode 100644 index 000000000..cae493c0b --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.ts @@ -0,0 +1,95 @@ +import { useMemo } from 'react' +import { FolderNode } from './useFileTree' +import { FileTreeItem, isFileTreeFile, isFileTreeFolder } from '@/files/domain/models/FileTreeItem' + +export type VisibleRow = + | { kind: 'item'; depth: number; node: FileTreeItem } + | { kind: 'loading'; depth: number; path: string } + | { kind: 'error'; depth: number; path: string; error: string } + | { kind: 'load-more'; depth: number; path: string } + +const ROOT = '' + +export interface UseFileTreeFlattenArgs { + nodes: ReadonlyMap + expanded: ReadonlySet + query?: string +} + +export function useFileTreeFlatten({ + nodes, + expanded, + query +}: UseFileTreeFlattenArgs): VisibleRow[] { + return useMemo(() => buildVisibleRows(nodes, expanded, query), [nodes, expanded, query]) +} + +export function buildVisibleRows( + nodes: ReadonlyMap, + expanded: ReadonlySet, + query?: string +): VisibleRow[] { + const rows: VisibleRow[] = [] + const root = nodes.get(ROOT) + if (!root) { + return rows + } + walk(root, 0) + return rows + + function walk(folder: FolderNode, depth: number): void { + if (folder.loading && folder.items.length === 0) { + rows.push({ kind: 'loading', depth, path: folder.path }) + return + } + if (folder.error && folder.items.length === 0) { + rows.push({ kind: 'error', depth, path: folder.path, error: folder.error }) + return + } + for (const item of folder.items) { + if (query && !matches(item, query, nodes)) { + continue + } + rows.push({ kind: 'item', depth, node: item }) + if (isFileTreeFolder(item)) { + const isOpen = expanded.has(item.path) || Boolean(query) + if (isOpen) { + const sub = nodes.get(item.path) + if (sub) { + walk(sub, depth + 1) + } else { + rows.push({ kind: 'loading', depth: depth + 1, path: item.path }) + } + } + } + } + if (folder.nextCursor) { + rows.push({ kind: 'load-more', depth, path: folder.path }) + } + if (folder.error && folder.items.length > 0) { + rows.push({ kind: 'error', depth, path: folder.path, error: folder.error }) + } + } +} + +function matches( + item: FileTreeItem, + query: string, + nodes: ReadonlyMap +): boolean { + const lowered = query.trim().toLowerCase() + if (!lowered) { + return true + } + if (isFileTreeFile(item)) { + return item.name.toLowerCase().includes(lowered) + } + if (item.name.toLowerCase().includes(lowered)) { + return true + } + const sub = nodes.get(item.path) + if (!sub) { + return false + } + return sub.items.some((child) => matches(child, query, nodes)) +} diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTreeSelection.ts b/src/sections/dataset/dataset-files/files-tree/useFileTreeSelection.ts new file mode 100644 index 000000000..65ae3b92a --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTreeSelection.ts @@ -0,0 +1,340 @@ +import { useCallback, useMemo, useState } from 'react' +import { + FileTreeFile, + FileTreeFolder, + FileTreeItem, + isFileTreeFile +} from '@/files/domain/models/FileTreeItem' + +export type SelectionState = 'all' | 'partial' | 'none' + +export interface FileTreeSelectionTotals { + count: number + bytes: number + hasLogicalFolders: boolean +} + +/** + * Selection state for the lazy file tree. + * + * Three sets cooperate: + * + * - `selectedFolderPaths` — folders the user explicitly checked. Implies + * "all descendants are logically selected" without enumerating them. + * - `selectedFilePaths` — individual files checked when no ancestor folder + * is selected. + * - `deselectedFilePaths` — individual files unchecked within a folder that + * is in `selectedFolderPaths` (or under a selected ancestor). + * + * The component never enumerates an unvisited subtree; the download flow + * walks the tree API to expand selected folders into concrete file IDs. + */ +export interface FileTreeSelection { + selectedFilePaths: ReadonlySet + selectedFolderPaths: ReadonlySet + deselectedFilePaths: ReadonlySet + totals: FileTreeSelectionTotals + fileState: (file: FileTreeFile) => SelectionState + folderState: (folder: FileTreeFolder, knownChildren: FileTreeItem[]) => SelectionState + toggleFile: (file: FileTreeFile) => void + toggleFolder: (folder: FileTreeFolder, knownChildren: FileTreeItem[]) => void + clear: () => void + /** + * Header "select-all" action: if anything is currently selected, + * clears the selection; otherwise marks every supplied top-level + * item as selected (files go into selectedFilePaths, folders into + * selectedFolderPaths). Tree depth below the supplied items is + * implicitly covered by ancestor-selected logic, matching the + * row-checkbox semantics. + */ + toggleAll: (topLevelItems: FileTreeItem[]) => void + filesById: Map + registerFile: (file: FileTreeFile) => void +} + +const isStrictlyUnder = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}/`) + +const hasSelectedAncestor = (path: string, selectedFolders: ReadonlySet): boolean => { + for (const folder of selectedFolders) { + if (isStrictlyUnder(path, folder)) { + return true + } + } + return false +} + +export function useFileTreeSelection(): FileTreeSelection { + const [selectedFilePaths, setSelectedFilePaths] = useState>(() => new Set()) + const [selectedFolderPaths, setSelectedFolderPaths] = useState>(() => new Set()) + const [deselectedFilePaths, setDeselectedFilePaths] = useState>(() => new Set()) + const [filesById] = useState>(() => new Map()) + + const registerFile = useCallback( + (file: FileTreeFile) => { + filesById.set(file.id, file) + }, + [filesById] + ) + + const isFileLogicallySelected = useCallback( + (path: string): boolean => { + if (deselectedFilePaths.has(path)) { + return false + } + if (selectedFilePaths.has(path)) { + return true + } + return hasSelectedAncestor(path, selectedFolderPaths) + }, + [deselectedFilePaths, selectedFilePaths, selectedFolderPaths] + ) + + const fileState = useCallback( + (file: FileTreeFile): SelectionState => (isFileLogicallySelected(file.path) ? 'all' : 'none'), + [isFileLogicallySelected] + ) + + const folderState = useCallback( + (folder: FileTreeFolder, knownChildren: FileTreeItem[]): SelectionState => { + const explicitlySelected = selectedFolderPaths.has(folder.path) + const ancestorSelected = hasSelectedAncestor(folder.path, selectedFolderPaths) + const logicallySelected = explicitlySelected || ancestorSelected + + const knownFilesUnder = knownChildren.filter( + (child): child is FileTreeFile => + isFileTreeFile(child) && + (child.path === `${folder.path}/${child.name}` || + isStrictlyUnder(child.path, folder.path)) + ) + + if (logicallySelected) { + const someDeselected = knownFilesUnder.some((file) => deselectedFilePaths.has(file.path)) + return someDeselected ? 'partial' : 'all' + } + + if (knownChildren.length === 0) { + return 'none' + } + + const nestedFolderSelected = Array.from(selectedFolderPaths).some((other) => + isStrictlyUnder(other, folder.path) + ) + const someFileSelected = knownFilesUnder.some((file) => isFileLogicallySelected(file.path)) + + if (!nestedFolderSelected && !someFileSelected) { + return 'none' + } + + const allFilesSelected = + knownFilesUnder.length > 0 && + knownFilesUnder.every((file) => isFileLogicallySelected(file.path)) + + // If we know about subfolders but none are logically selected and + // not every visited file is selected, the folder is partial. + if (allFilesSelected && !nestedFolderSelected) { + return 'all' + } + if (allFilesSelected && nestedFolderSelected) { + // descendant folder selection covers some unvisited paths; + // we cannot honestly call this 'all' so partial is correct. + return 'partial' + } + return 'partial' + }, + [deselectedFilePaths, isFileLogicallySelected, selectedFolderPaths] + ) + + const toggleFile = useCallback( + (file: FileTreeFile) => { + filesById.set(file.id, file) + const ancestorSelected = hasSelectedAncestor(file.path, selectedFolderPaths) + if (ancestorSelected) { + const next = new Set(deselectedFilePaths) + if (next.has(file.path)) { + next.delete(file.path) + } else { + next.add(file.path) + } + setDeselectedFilePaths(next) + return + } + const next = new Set(selectedFilePaths) + if (next.has(file.path)) { + next.delete(file.path) + } else { + next.add(file.path) + } + setSelectedFilePaths(next) + }, + [deselectedFilePaths, filesById, selectedFilePaths, selectedFolderPaths] + ) + + const toggleFolder = useCallback( + (folder: FileTreeFolder, knownChildren: FileTreeItem[]) => { + const explicitlySelected = selectedFolderPaths.has(folder.path) + const ancestorSelected = hasSelectedAncestor(folder.path, selectedFolderPaths) + const state = folderState(folder, knownChildren) + + if (state === 'all' && explicitlySelected) { + // Deselect this folder and any nested artifacts under it. + const nextFolders = new Set(selectedFolderPaths) + const nextFiles = new Set(selectedFilePaths) + const nextDeselected = new Set(deselectedFilePaths) + nextFolders.delete(folder.path) + for (const other of Array.from(nextFolders)) { + if (isStrictlyUnder(other, folder.path)) { + nextFolders.delete(other) + } + } + for (const path of Array.from(nextFiles)) { + if (path === folder.path || isStrictlyUnder(path, folder.path)) { + nextFiles.delete(path) + } + } + for (const path of Array.from(nextDeselected)) { + if (isStrictlyUnder(path, folder.path)) { + nextDeselected.delete(path) + } + } + setSelectedFolderPaths(nextFolders) + setSelectedFilePaths(nextFiles) + setDeselectedFilePaths(nextDeselected) + return + } + + if (ancestorSelected) { + // We're inside an already-logically-selected branch; flip the + // deselect overrides for every known descendant file under this + // folder. + const nextDeselected = new Set(deselectedFilePaths) + const knownFiles = collectKnownFilesUnder(folder, knownChildren) + const allDeselected = + knownFiles.length > 0 && knownFiles.every((f) => nextDeselected.has(f.path)) + for (const file of knownFiles) { + if (allDeselected) { + nextDeselected.delete(file.path) + } else { + nextDeselected.add(file.path) + } + } + setDeselectedFilePaths(nextDeselected) + return + } + + // 'partial' or 'none' on a folder without selected ancestors -> select-all logically. + const nextFolders = new Set(selectedFolderPaths) + nextFolders.add(folder.path) + // Folding nested explicitly-selected folders into the parent. + for (const other of Array.from(nextFolders)) { + if (other !== folder.path && isStrictlyUnder(other, folder.path)) { + nextFolders.delete(other) + } + } + const nextFiles = new Set(selectedFilePaths) + const nextDeselected = new Set(deselectedFilePaths) + for (const path of Array.from(nextFiles)) { + if (path === folder.path || isStrictlyUnder(path, folder.path)) { + nextFiles.delete(path) + } + } + for (const path of Array.from(nextDeselected)) { + if (isStrictlyUnder(path, folder.path)) { + nextDeselected.delete(path) + } + } + setSelectedFolderPaths(nextFolders) + setSelectedFilePaths(nextFiles) + setDeselectedFilePaths(nextDeselected) + }, + [deselectedFilePaths, folderState, selectedFilePaths, selectedFolderPaths] + ) + + const clear = useCallback(() => { + setSelectedFilePaths(new Set()) + setSelectedFolderPaths(new Set()) + setDeselectedFilePaths(new Set()) + }, []) + + const toggleAll = useCallback( + (topLevelItems: FileTreeItem[]) => { + const anySelected = selectedFilePaths.size > 0 || selectedFolderPaths.size > 0 + if (anySelected) { + setSelectedFilePaths(new Set()) + setSelectedFolderPaths(new Set()) + setDeselectedFilePaths(new Set()) + return + } + const nextFiles = new Set() + const nextFolders = new Set() + for (const item of topLevelItems) { + if (isFileTreeFile(item)) { + filesById.set(item.id, item) + nextFiles.add(item.path) + } else { + nextFolders.add(item.path) + } + } + setSelectedFilePaths(nextFiles) + setSelectedFolderPaths(nextFolders) + setDeselectedFilePaths(new Set()) + }, + [filesById, selectedFilePaths, selectedFolderPaths] + ) + + const totals = useMemo(() => { + let count = 0 + let bytes = 0 + for (const path of selectedFilePaths) { + const file = findFileByPath(filesById, path) + count += 1 + if (file) { + bytes += file.size + } + } + return { + count, + bytes, + hasLogicalFolders: selectedFolderPaths.size > 0 + } + }, [filesById, selectedFilePaths, selectedFolderPaths.size]) + + return { + selectedFilePaths, + selectedFolderPaths, + deselectedFilePaths, + totals, + fileState, + folderState, + toggleFile, + toggleFolder, + clear, + toggleAll, + filesById, + registerFile + } +} + +function collectKnownFilesUnder( + folder: FileTreeFolder, + knownChildren: FileTreeItem[] +): FileTreeFile[] { + const out: FileTreeFile[] = [] + for (const child of knownChildren) { + if (isFileTreeFile(child) && isStrictlyUnder(child.path, folder.path)) { + out.push(child) + } + } + return out +} + +function findFileByPath( + filesById: Map, + path: string +): FileTreeFile | undefined { + for (const file of filesById.values()) { + if (file.path === path) { + return file + } + } + return undefined +} diff --git a/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts b/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts new file mode 100644 index 000000000..08ebcd099 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts @@ -0,0 +1,862 @@ +import { useCallback, useRef, useState } from 'react' +import { downloadZip } from 'client-zip' +import { md5 } from 'js-md5' +import { FileTreeFile } from '@/files/domain/models/FileTreeItem' + +/** + * Strategy for handling per-file fetch failures during a streaming-zip + * download. + * + * - `pause`: stop the engine on the first failure and surface a + * retry / skip / skip-all decision to the caller. Default — matches + * the behaviour the design bundle prescribes. + * - `skip`: best-effort. Failed files are dropped, listed in a + * `manifest.txt` entry inside the resulting zip, and the engine + * keeps going. + * - `twopass`: failed files are deferred. After the first pass the + * engine waits for the caller to call `retryFailed()`; at that + * point it re-queues all recoverable failures as a second pass. + */ +export type StreamingZipStrategy = 'pause' | 'skip' | 'twopass' + +export interface StreamingZipFailure { + path: string + name: string + size: number + error: string + recoverable: boolean +} + +/** + * Per-file checksum-verification miss. The file's bytes ARE in the zip + * (you can't unwrite a stream after client-zip has consumed it), but + * its computed digest doesn't match what the tree response advertised. + * Surfaced in the tray so users know which files to re-download and + * appended to `manifest.txt` for the `skip` strategy. + */ +export interface StreamingZipVerificationFailure { + path: string + name: string + size: number + algorithm: string + expected: string + actual: string +} + +export interface StreamingZipState { + status: + | 'idle' + | 'preparing' + | 'running' + | 'paused' + | 'awaiting-retry' + | 'done' + | 'error' + | 'cancelled' + totalFiles: number + filesDone: number + totalBytes: number + bytesDone: number + current?: { name: string; path: string; size: number } + failedSoFar: StreamingZipFailure[] + /** + * Files whose bytes flowed into the zip successfully but whose + * computed digest didn't match the tree response's advertised + * `checksum`. Empty when no verification was attempted (older server) + * or all files verified clean. + */ + verificationFailures: StreamingZipVerificationFailure[] + pass: 1 | 2 + message?: string +} + +export interface StartStreamingZipArgs { + files: FileTreeFile[] + zipName?: string + strategy?: StreamingZipStrategy + /** Allows a custom URL builder (e.g. JSF integration); defaults to `file.downloadUrl`. */ + buildFetchUrl?: (file: FileTreeFile) => string + /** + * Extra options forwarded to `fetch()`. Defaults to + * `credentials: 'same-origin'` because the file-download URL typically + * 302s to S3 (or an S3-compatible store) which returns + * `Access-Control-Allow-Origin: *`, and browsers refuse `*` + credentials. + * `same-origin` carries the Dataverse session cookie on the Dataverse + * hop and drops it on the cross-origin S3 hop, which is what we want. + */ + fetchInit?: RequestInit + /** + * Files larger than this are fetched as a sequence of HTTP `Range` + * requests instead of one full GET. Each part is retried on its own + * (`partRetries`), so a transient TCP drop mid-file no longer aborts + * the whole download. Default `10 * 1024 * 1024` (10 MiB). + */ + partSize?: number + /** Per-part retry budget for chunked fetches. Default `3`. */ + partRetries?: number + /** Backoff between part retries, in ms. Default `500`. */ + partRetryDelayMs?: number +} + +const DEFAULT_PART_SIZE_BYTES = 10 * 1024 * 1024 +const DEFAULT_PART_RETRIES = 3 +const DEFAULT_PART_RETRY_DELAY_MS = 500 + +export interface StreamingZipApi { + state: StreamingZipState + start: (args: StartStreamingZipArgs) => void + retryCurrent: () => void + skipCurrent: () => void + skipAllFailures: () => void + /** + * Convert the current pause-on-fail dialog into a two-pass run: + * the failure stays in `failedSoFar` as recoverable, the strategy + * switches to `twopass`, and the engine keeps going without pausing + * on subsequent failures. After the first pass finishes the engine + * pauses with `status: 'awaiting-retry'` so the host can call + * `retryFailed`. + */ + deferCurrentToEnd: () => void + retryFailed: () => void + cancel: () => void + close: () => void +} + +const initialState: StreamingZipState = { + status: 'idle', + totalFiles: 0, + filesDone: 0, + totalBytes: 0, + bytesDone: 0, + failedSoFar: [], + verificationFailures: [], + pass: 1 +} + +interface ResolveBag { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): ResolveBag { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +/** + * Streaming-zip download driver. + * + * Builds a zip in the browser by piping per-file response bodies + * through `client-zip`. The result is a single `Response` whose body is + * a `ReadableStream`; we materialise it to a `Blob` and trigger a + * save via an anchor click. For very large datasets this still buffers + * the zip in memory; future work can swap the blob save for the + * File System Access API or a Service Worker stream-saver. + * + * Per-file progress is tracked inside the custom `ReadableStream` that + * `buildChunkedStream` returns: it tallies bytes as `client-zip` pulls + * them, and lazily fetches additional Range parts when the file is + * larger than `partSize`. + */ +export function useStreamingZipDownload(): StreamingZipApi { + const [state, setState] = useState(initialState) + const stateRef = useRef(initialState) + stateRef.current = state + + // Engine control: a single "decision" promise that the iterator + // awaits when paused. The UI calls retryCurrent/skipCurrent/etc. + // which resolve this promise with the requested action. + type Decision = 'retry' | 'skip' | 'skip-all' | 'defer-to-end' | 'retry-failed' | 'cancel' + const decisionRef = useRef | null>(null) + const cancelledRef = useRef(false) + + const update = useCallback((fn: (prev: StreamingZipState) => StreamingZipState) => { + setState((prev) => { + const next = fn(prev) + stateRef.current = next + return next + }) + }, []) + + const close = useCallback(() => { + cancelledRef.current = false + decisionRef.current = null + setState(initialState) + stateRef.current = initialState + }, []) + + const cancel = useCallback(() => { + cancelledRef.current = true + decisionRef.current?.resolve('cancel') + decisionRef.current = null + update((prev) => ({ ...prev, status: 'cancelled' })) + }, [update]) + + const sendDecision = useCallback((decision: Decision) => { + const bag = decisionRef.current + /* istanbul ignore if */ + if (!bag) return + decisionRef.current = null + bag.resolve(decision) + }, []) + + const retryCurrent = useCallback(() => sendDecision('retry'), [sendDecision]) + const skipCurrent = useCallback(() => sendDecision('skip'), [sendDecision]) + const skipAllFailures = useCallback(() => sendDecision('skip-all'), [sendDecision]) + const deferCurrentToEnd = useCallback(() => sendDecision('defer-to-end'), [sendDecision]) + const retryFailed = useCallback(() => sendDecision('retry-failed'), [sendDecision]) + + const start = useCallback( + (args: StartStreamingZipArgs) => { + const { + files, + zipName = 'dataset.zip', + strategy: initialStrategy = 'pause', + buildFetchUrl = (f) => f.downloadUrl, + fetchInit, + partSize = DEFAULT_PART_SIZE_BYTES, + partRetries = DEFAULT_PART_RETRIES, + partRetryDelayMs = DEFAULT_PART_RETRY_DELAY_MS + } = args + /* istanbul ignore if */ + if (files.length === 0) return + + cancelledRef.current = false + decisionRef.current = null + const totalBytes = files.reduce((s, f) => s + f.size, 0) + update(() => ({ + ...initialState, + status: 'preparing', + totalFiles: files.length, + totalBytes, + pass: 1 + })) + + const queue: FileTreeFile[] = [...files] + const skippedManifest: StreamingZipFailure[] = [] + let strategy = initialStrategy + + async function* iterableForZip() { + // ----- helper: process a queue ---------------------------------- + const processQueue = async function* () { + while (queue.length > 0) { + // Loop-top cancel guard. The pause-decision path also + // resolves with 'cancel' and aborts, which the spec covers; + // this is the additional check for cancellation that lands + // *between* files in a fast-scrolling run. + /* istanbul ignore next */ + if (cancelledRef.current) return + const file = queue.shift() as FileTreeFile + update((prev) => ({ + ...prev, + status: 'running', + current: { name: file.name, path: file.path, size: file.size } + })) + + const url = buildFetchUrl(file) + // Files larger than `partSize` are fetched as a sequence of + // Range requests so a transient drop mid-file only invalidates + // the active part instead of the whole file. The first part + // still goes through the existing retry/skip/defer dialog on + // hard failure (the body hasn't started streaming into the + // zip yet); subsequent parts can only retry inline because + // client-zip is already consuming the stream. + const useRange = file.size > partSize + const firstPartRange = useRange + ? `bytes=0-${Math.min(partSize, file.size) - 1}` + : undefined + + let response: Response + try { + response = await fetchWithRetries({ + url, + rangeHeader: firstPartRange, + fetchInit, + retries: partRetries, + delayMs: partRetryDelayMs + }) + } catch (err) { + const failure: StreamingZipFailure = { + path: file.path, + name: file.name, + size: file.size, + /* istanbul ignore next */ + error: err instanceof Error ? err.message : String(err), + recoverable: strategy !== 'skip' + } + update((prev) => ({ + ...prev, + failedSoFar: [...prev.failedSoFar, failure] + })) + if (strategy === 'pause') { + update((prev) => ({ ...prev, status: 'paused' })) + const decision = await waitForDecision() + if (decision === 'cancel') return + if (decision === 'retry') { + // pop the failure record we just added and re-queue this file + update((prev) => ({ + ...prev, + failedSoFar: prev.failedSoFar.slice(0, -1), + status: 'running' + })) + queue.unshift(file) + continue + } + if (decision === 'defer-to-end') { + // Switch to twopass: the failure stays recoverable in + // failedSoFar, the engine stops pausing, and a second + // pass will pick up all recoverable failures at the end. + strategy = 'twopass' + update((prev) => ({ ...prev, status: 'running' })) + continue + } + if (decision === 'skip' || decision === 'skip-all') { + // Both skip variants demote the just-added failure to + // non-recoverable. Without this, a later 'defer-to-end' + // decision on a different file would re-queue the + // already-skipped file in its second pass (see the + // recoverable filter in the awaiting-retry path). + if (decision === 'skip-all') { + strategy = 'skip' + } + update((prev) => { + const last = prev.failedSoFar[prev.failedSoFar.length - 1] + /* istanbul ignore if */ + if (!last) return { ...prev, status: 'running' } + return { + ...prev, + failedSoFar: [ + ...prev.failedSoFar.slice(0, -1), + { ...last, recoverable: false } + ], + status: 'running' + } + }) + } + // 'skip' or 'skip-all' fall through + skippedManifest.push({ ...failure, recoverable: false }) + continue + } + if (strategy === 'skip') { + skippedManifest.push({ ...failure, recoverable: false }) + continue + } + // 'twopass': defer; second pass will pick recoverable ones up + continue + } + + // `response.body` is null only for `Response.error()` and a + // handful of legacy fetch implementations; modern browsers + // always populate it on a successful HTTP response. Kept as + // a safety net; not exercised by the test harness. + /* istanbul ignore next */ + if (!response.body) { + update((prev) => ({ + ...prev, + filesDone: prev.filesDone + 1, + bytesDone: prev.bytesDone + file.size + })) + continue + } + const stream = buildChunkedStream({ + file, + initialResponse: response, + originalUrl: url, + partSize, + partRetries, + partRetryDelayMs, + fetchInit, + onProgress: (delta) => + update((prev) => ({ ...prev, bytesDone: prev.bytesDone + delta })), + onVerificationFailure: (failure) => + update((prev) => ({ + ...prev, + verificationFailures: [...prev.verificationFailures, failure] + })), + cancelled: () => cancelledRef.current + }) + yield { + name: file.path, + input: stream, + lastModified: new Date() + } + update((prev) => ({ ...prev, filesDone: prev.filesDone + 1 })) + } + } + + // ----- first pass -------------------------------------------------- + yield* processQueue() + // Cancel-after-first-pass guard. processQueue's own cancel + // checks short-circuit before reaching here in the cancel path. + /* istanbul ignore next */ + if (cancelledRef.current) return + + // ----- two-pass: pause for user decision then re-queue failures ---- + if (strategy === 'twopass' && stateRef.current.failedSoFar.length > 0) { + update((prev) => ({ ...prev, status: 'awaiting-retry' })) + const decision = await waitForDecision() + /* istanbul ignore if */ + if (decision === 'cancel') return + if (decision === 'retry-failed') { + const recoverable = stateRef.current.failedSoFar.filter((f) => f.recoverable) + // Reconstruct the file objects from the tail of `files`: + const fileByPath = new Map(files.map((f) => [f.path, f])) + for (const f of recoverable) { + const file = fileByPath.get(f.path) + if (file) queue.push(file) + } + update((prev) => ({ + ...prev, + failedSoFar: prev.failedSoFar.filter((f) => !f.recoverable), + pass: 2, + status: 'running' + })) + yield* processQueue() + } + } + + // ----- manifest.txt: list any skipped or verification-failed files ---- + // Both populate the manifest; the zip is closing and this is the + // last yield, so we collect from `stateRef` (which has any + // mid-stream verification failures the per-file pull pushed + // through the update callback) plus `skippedManifest` (the + // fetch-level failures from the per-file loop). + const verifyFails = stateRef.current.verificationFailures + if (skippedManifest.length > 0 || verifyFails.length > 0) { + const lines: string[] = [] + if (skippedManifest.length > 0) { + lines.push('The following files were skipped during this zip download:') + lines.push('') + for (const f of skippedManifest) lines.push(`${f.path} — ${f.error}`) + } + if (verifyFails.length > 0) { + if (lines.length > 0) lines.push('') + lines.push('The following files were downloaded but failed checksum verification.') + lines.push('Their bytes are in this zip; re-download to confirm integrity:') + lines.push('') + for (const v of verifyFails) { + lines.push(`${v.path} — ${v.algorithm}: expected ${v.expected}, got ${v.actual}`) + } + } + yield { + name: 'manifest.txt', + input: new Blob([lines.join('\n')], { type: 'text/plain' }), + lastModified: new Date() + } + } + } + + async function waitForDecision(): Promise { + const bag = deferred() + decisionRef.current = bag + return bag.promise + } + + void (async () => { + try { + const response = downloadZip(iterableForZip()) + const blob = await response.blob() + if (cancelledRef.current) return + triggerDownload(blob, zipName) + update((prev) => ({ ...prev, status: 'done', current: undefined })) + } catch (err) { + // Defensive catch for unexpected failures inside `client-zip` + // or the anchor-click. The per-file fetch failures are + // handled inline above; reaching here means something + // happened that the per-file flow can't classify. + /* istanbul ignore next */ + if (cancelledRef.current) return + /* istanbul ignore next */ + update((prev) => ({ + ...prev, + status: 'error', + message: err instanceof Error ? err.message : String(err) + })) + } + })() + }, + [update] + ) + + return { + state, + start, + retryCurrent, + skipCurrent, + skipAllFailures, + deferCurrentToEnd, + retryFailed, + cancel, + close + } +} + +/** + * Thrown by `fetchWithRetries` when the server returns a non-OK status. + * Carries the status code so callers can distinguish transient failures + * (worth retrying) from terminal ones (worth surfacing immediately). + */ +class HttpError extends Error { + constructor(public readonly status: number, statusText: string) { + super(`HTTP ${status} ${statusText}`) + this.name = 'HttpError' + } +} + +/** + * `true` for HTTP statuses that have any reasonable chance of succeeding + * on a re-attempt: 5xx server errors, plus the few 4xx codes that the + * spec defines as transient (408 Request Timeout, 425 Too Early, 429 + * Too Many Requests). Other 4xx codes (401, 403, 404, …) are user- + * facing terminal errors — retrying just wastes the budget while the + * user waits to see the failure tray. + */ +function isTransientHttpStatus(status: number): boolean { + if (status >= 500) return true + return status === 408 || status === 425 || status === 429 +} + +/** + * One fetch with up to `retries` extra attempts. A `Range` header is + * forwarded when supplied; a non-OK status is converted to an + * {@link HttpError} so the caller can react. Retry budget is skipped on + * terminal 4xx (no amount of retrying turns a 403 into a 200) — those + * errors propagate on the first attempt. Network errors and transient + * statuses retry normally. When all retries are exhausted the last + * error is re-thrown for the caller to surface in its own failure UI. + */ +async function fetchWithRetries(args: { + url: string + rangeHeader: string | undefined + fetchInit: RequestInit | undefined + retries: number + delayMs: number +}): Promise { + let lastErr: unknown = new Error('no attempt made') + for (let attempt = 0; attempt <= args.retries; attempt++) { + try { + const headers = new Headers(args.fetchInit?.headers ?? undefined) + if (args.rangeHeader !== undefined) headers.set('Range', args.rangeHeader) + const response = await fetch(args.url, { + // `same-origin` keeps cookies on the initial Dataverse call + // (same-origin in both SPA and JSF embed) but drops them when + // the browser follows a redirect to S3-style storage. With + // `download-redirect=true` the 302 target is on the bucket's + // hostname; sending credentials there would require + // `Access-Control-Allow-Credentials: true`, incompatible with + // the typical `Allow-Origin: *` rule. + credentials: 'same-origin', + ...(args.fetchInit ?? {}), + headers + }) + if (!response.ok) { + throw new HttpError(response.status, response.statusText) + } + return response + } catch (err) { + lastErr = err + // Terminal 4xx: bail before burning the rest of the budget. + if (err instanceof HttpError && !isTransientHttpStatus(err.status)) { + throw err + } + if (attempt < args.retries) { + await new Promise((resolve) => setTimeout(resolve, args.delayMs)) + } + } + } + /* istanbul ignore next */ + throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)) +} + +/** + * Append a query parameter to a URL string without dragging in a URL + * parser. Inputs are well-formed URLs from the SDK / our own builders, + * so the simple "find a `?` or add one" rule is sufficient — no need + * to handle weird relative-with-fragment cases. + */ +function appendQueryParam(url: string, key: string, value: string): string { + const sep = url.includes('?') ? '&' : '?' + return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}` +} + +/** + * Range fetch with re-presign-on-403 fallback. When the cached + * `subsequentUrl` returns 403 (presigned URL has expired since the + * file's first chunk was retrieved), this helper transparently + * re-fetches the Dataverse access endpoint with `gbrecs=true` (so the + * download isn't double-counted in the guestbook), captures the new + * S3 redirect target, and returns the bytes for the requested range. + * Callers update their cached URL from the returned `refreshedUrl` so + * subsequent parts use the fresh presigning until *that* one expires. + * + * Anything other than 403 (network errors, 5xx, retried-out transient + * failures) propagates from the inner `fetchWithRetries` and aborts + * the chunked stream — re-presign helps for "URL expired", not for + * "access genuinely denied" or "S3 melted". + */ +async function fetchPartWithRefresh(args: { + cachedUrl: string + originalUrl: string + rangeHeader: string + fetchInit: RequestInit | undefined + retries: number + delayMs: number +}): Promise<{ response: Response; refreshedUrl: string | null }> { + try { + const response = await fetchWithRetries({ + url: args.cachedUrl, + rangeHeader: args.rangeHeader, + fetchInit: args.fetchInit, + retries: args.retries, + delayMs: args.delayMs + }) + return { response, refreshedUrl: null } + } catch (err) { + if (!(err instanceof HttpError) || err.status !== 403) { + throw err + } + const refreshUrl = appendQueryParam(args.originalUrl, 'gbrecs', 'true') + const refreshed = await fetchWithRetries({ + url: refreshUrl, + rangeHeader: args.rangeHeader, + fetchInit: args.fetchInit, + retries: args.retries, + delayMs: args.delayMs + }) + // Prefer the post-redirect URL when fetch followed the 303 to S3 + // (`response.url` differs from the request URL); fall back to the + // refresh URL itself when no redirect happened (test env, or a + // non-redirected storage driver). Either keeps subsequent parts + // off the guestbook path. + const newUrl = + refreshed.url && refreshed.url !== refreshUrl + ? /* istanbul ignore next */ refreshed.url + : refreshUrl + return { response: refreshed, refreshedUrl: newUrl } + } +} + +/** + * Streaming digest accumulator. Two branches matching what + * `FileUploaderHelper` already does for upload: + * - MD5 (Dataverse default): `js-md5`'s `create()/update()/hex()` — + * true streaming, no buffer of the file's bytes. + * - SHA-1 / SHA-256 / SHA-512: `crypto.subtle.digest` is one-shot, + * so we accumulate chunks (1 MiB max per slice) and digest the + * concatenation when the file closes. Memory cost is the file + * size for SHA — fine for typical research-data sizes, the same + * trade-off the upload helper accepts. A streaming-SHA WASM + * library would close the gap if it ever bites; not in scope. + * + * `null` for unsupported / unknown algorithm names — caller treats it + * as "skip verification for this file" rather than erroring out, so a + * file whose `checksum.type` is something exotic doesn't kill the zip. + */ +function makeDigestAccumulator( + algorithm: string +): { update: (bytes: Uint8Array) => void; finalize: () => Promise } | null { + const upper = algorithm.toUpperCase() + if (upper === 'MD5') { + const hash = md5.create() + return { + update: (bytes) => hash.update(bytes), + // Wrapped in Promise.resolve to unify the return type with the + // SHA path's `subtle.digest` Promise — caller awaits in both + // cases, MD5 just resolves synchronously. + finalize: () => Promise.resolve(hash.hex()) + } + } + // Map Dataverse's stored algorithm name to the Web Crypto identifier. + const subtleAlgo = + upper === 'SHA-1' || upper === 'SHA1' + ? 'SHA-1' + : upper === 'SHA-256' || upper === 'SHA256' + ? 'SHA-256' + : upper === 'SHA-512' || upper === 'SHA512' + ? 'SHA-512' + : null + if (!subtleAlgo) return null + const chunks: Uint8Array[] = [] + return { + update: (bytes) => { + // Copy the slice — the source buffer may be reused by the + // fetch reader between ticks. + chunks.push(new Uint8Array(bytes)) + }, + finalize: async () => { + const total = chunks.reduce((s, c) => s + c.length, 0) + const buf = new Uint8Array(total) + let off = 0 + for (const c of chunks) { + buf.set(c, off) + off += c.length + } + const digest = await window.crypto.subtle.digest(subtleAlgo, buf as BufferSource) + const out = new Uint8Array(digest) + let hex = '' + for (const b of out) hex += b.toString(16).padStart(2, '0') + return hex + } + } +} + +/** + * Wraps the first-part response and lazily fetches the remaining parts + * via Range requests as `client-zip` pulls bytes through the stream. + * + * If the server returned `200` to the first request (Range was either + * not set or not honored), the response is treated as the full file — + * no further parts are requested. Otherwise the response's `.url` + * (after the 303 to S3) is used for the subsequent part requests so + * those bypass Dataverse entirely; that's the path that gives us the + * resilience win on flaky cross-region links. + * + * When the file's tree row carries a `checksum`, the bytes are also + * fed into a streaming digest accumulator and compared at end-of-stream + * — a mismatch is reported via `onVerificationFailure` but does NOT + * fail the file (its bytes are already in the zip; the user is told + * which files need re-downloading). + */ +function buildChunkedStream(args: { + file: FileTreeFile + initialResponse: Response + originalUrl: string + partSize: number + partRetries: number + partRetryDelayMs: number + fetchInit: RequestInit | undefined + onProgress: (delta: number) => void + onVerificationFailure: (failure: StreamingZipVerificationFailure) => void + cancelled: () => boolean +}): ReadableStream { + const total = args.file.size + const usedRange = args.initialResponse.status === 206 + const numParts = usedRange ? Math.ceil(total / args.partSize) : 1 + // `subsequentUrl` is the URL chunks 1..N-1 hit. Mutable because a 403 + // mid-download (presigned URL expired) triggers a re-presign that + // returns a fresh URL we cache here for the rest of the file. Same + // pattern repeats every time the new URL itself expires. + let subsequentUrl = + args.initialResponse.url && args.initialResponse.url !== args.originalUrl + ? /* istanbul ignore next */ args.initialResponse.url + : args.originalUrl + + // Caller has already null-checked `initialResponse.body` (see the + // /* istanbul ignore next */ guard in the engine), so the assertion + // here only narrows the type — the runtime check is upstream. + const initialBody = args.initialResponse.body as ReadableStream + let partIndex = 0 + let currentReader: ReadableStreamDefaultReader | null = initialBody.getReader() + + // Digest accumulator: present only when the tree response gave us a + // checksum AND the algorithm is one we can compute. Files where the + // tree omitted `checksum` (e.g. ingested-tabular default form) get + // no accumulator — skipped silently. Files with an exotic algorithm + // also fall through to skip rather than error. + const expectedChecksum = args.file.checksum + const digest = expectedChecksum ? makeDigestAccumulator(expectedChecksum.type) : null + + return new ReadableStream({ + async pull(controller) { + if (args.cancelled()) { + /* istanbul ignore next */ + controller.close() + /* istanbul ignore next */ + return + } + // Drain the current part; on exhaustion, advance to the next part + // (or close the stream if all parts are done). + for (;;) { + if (!currentReader) { + if (partIndex >= numParts) { + // Stream is closing — if we have a digest, finalize and + // compare against the expected value. Mismatch is reported + // via the callback (the bytes are already in the zip; the + // user is told which files to re-download). + if (digest && expectedChecksum) { + const actual = await digest.finalize() + if (actual.toLowerCase() !== expectedChecksum.value.toLowerCase()) { + args.onVerificationFailure({ + path: args.file.path, + name: args.file.name, + size: args.file.size, + algorithm: expectedChecksum.type, + expected: expectedChecksum.value, + actual + }) + } + } + controller.close() + return + } + const start = partIndex * args.partSize + const end = Math.min(start + args.partSize, total) - 1 + let response: Response + try { + const result = await fetchPartWithRefresh({ + cachedUrl: subsequentUrl, + originalUrl: args.originalUrl, + rangeHeader: `bytes=${start}-${end}`, + fetchInit: args.fetchInit, + retries: args.partRetries, + delayMs: args.partRetryDelayMs + }) + response = result.response + if (result.refreshedUrl) { + subsequentUrl = result.refreshedUrl + } + } catch (err) { + controller.error(err instanceof Error ? err : new Error(String(err))) + return + } + /* istanbul ignore if */ + if (!response.body) { + controller.error(new Error('no body for range part')) + return + } + currentReader = response.body.getReader() + } + const { value, done } = await currentReader.read() + if (done) { + currentReader.releaseLock() + currentReader = null + partIndex += 1 + continue + } + if (value && value.byteLength > 0) { + if (digest) digest.update(value) + args.onProgress(value.byteLength) + controller.enqueue(value) + return + } + } + }, + /* istanbul ignore next */ + async cancel() { + if (currentReader) { + try { + await currentReader.cancel() + } catch { + // Reader may already be closed; cancellation is best-effort. + } + } + } + }) +} + +function triggerDownload(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name + a.rel = 'noopener noreferrer' + a.style.display = 'none' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + // Defer revoke so the browser actually starts the download. + setTimeout(() => URL.revokeObjectURL(url), 4_000) +} diff --git a/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.module.scss b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.module.scss new file mode 100644 index 000000000..0996bd39f --- /dev/null +++ b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.module.scss @@ -0,0 +1,9 @@ +.toggle { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.label { + font-weight: 700; +} diff --git a/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.tsx b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.tsx new file mode 100644 index 000000000..f59f4a580 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next' +import { Button, ButtonGroup } from '@iqss/dataverse-design-system' +import styles from './FilesViewToggle.module.scss' + +export type FilesViewMode = 'table' | 'tree' + +interface FilesViewToggleProps { + view: FilesViewMode + onChange: (view: FilesViewMode) => void +} + +export function FilesViewToggle({ view, onChange }: FilesViewToggleProps) { + const { t } = useTranslation('files') + return ( +
+ {t('view.toggle.changeView')} + + + + +
+ ) +} diff --git a/src/sections/dataset/dataset-files/filesViewSearchParams.ts b/src/sections/dataset/dataset-files/filesViewSearchParams.ts new file mode 100644 index 000000000..8713dee6f --- /dev/null +++ b/src/sections/dataset/dataset-files/filesViewSearchParams.ts @@ -0,0 +1,42 @@ +import { FilesViewMode } from './files-view-toggle/FilesViewToggle' + +export const VIEW_PARAM = 'view' +export const PATH_PARAM = 'path' + +/** + * Compute the next URLSearchParams when the user toggles between table + * and tree view. Switching to tree adds `?view=tree`; switching back to + * table removes both `view` and `path` so the URL doesn't keep a stale + * tree-only path query. + */ +export function nextSearchParamsForView( + current: URLSearchParams, + next: FilesViewMode +): URLSearchParams { + const updated = new URLSearchParams(current) + if (next === 'tree') { + updated.set(VIEW_PARAM, 'tree') + } else { + updated.delete(VIEW_PARAM) + updated.delete(PATH_PARAM) + } + return updated +} + +/** + * Compute the next URLSearchParams as the tree's currently-expanded + * path changes. Empty / falsy paths drop the `path` param entirely so + * the URL stays clean while at the root. + */ +export function nextSearchParamsForTreePath( + current: URLSearchParams, + next: string +): URLSearchParams { + const updated = new URLSearchParams(current) + if (next) { + updated.set(PATH_PARAM, next) + } else { + updated.delete(PATH_PARAM) + } + return updated +} diff --git a/src/sections/dataset/dataset-files/treeDownloadsRequireTermsGate.ts b/src/sections/dataset/dataset-files/treeDownloadsRequireTermsGate.ts new file mode 100644 index 000000000..fb5a48990 --- /dev/null +++ b/src/sections/dataset/dataset-files/treeDownloadsRequireTermsGate.ts @@ -0,0 +1,37 @@ +import { + Dataset, + DatasetPublishingStatus, + defaultLicense +} from '../../../dataset/domain/models/Dataset' + +/** + * True when the tree-view download paths must be gated behind a terms / + * guestbook / non-default-license acknowledgement that the table view + * already prompts for via its own modal. The tree's streaming-zip flow + * does not yet wire that modal in — until it does, callers should + * disable downloads when this returns true. + * + * Returns false on: + * - missing dataset (mount-time / loading) + * - draft versions (only owners can read draft files) + * - users with edit permission (they accept terms by virtue of editing) + * + * Otherwise: true if any of guestbook / non-default license / custom + * terms are present on the version. + */ +export function treeDownloadsRequireTermsGate( + dataset: + | Pick + | undefined + | null +): boolean { + if (!dataset) return false + const isDraft = dataset.version.publishingStatus === DatasetPublishingStatus.DRAFT + const canEdit = dataset.permissions.canUpdateDataset + if (isDraft || canEdit) return false + const hasGuestbook = dataset.guestbookId !== undefined + const hasNonDefaultLicense = + dataset.license !== undefined && dataset.license.name !== defaultLicense.name + const hasCustomTerms = dataset.termsOfUse?.customTerms !== undefined + return hasGuestbook || hasNonDefaultLicense || hasCustomTerms +} diff --git a/src/sections/edit-file-metadata/EditFileMetadata.tsx b/src/sections/edit-file-metadata/EditFileMetadata.tsx index 04e11b855..7f4c1f356 100644 --- a/src/sections/edit-file-metadata/EditFileMetadata.tsx +++ b/src/sections/edit-file-metadata/EditFileMetadata.tsx @@ -12,20 +12,18 @@ import { } from '@/sections/edit-file-metadata/EditFilesList' import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { useFile } from '@/sections/file/useFile' +import { EditFileMetadataReferrer } from './EditFileMetadataReferrer' import styles from './EditFileMetadata.module.scss' +// Re-export for backwards compatibility +export { EditFileMetadataReferrer } from './EditFileMetadataReferrer' + interface EditFileMetadataProps { fileId: number fileRepository: FileRepository referrer: EditFileMetadataReferrer } -// From where the user is coming from -export enum EditFileMetadataReferrer { - DATASET = 'dataset', - FILE = 'file' -} - export const EditFileMetadata = ({ fileId, fileRepository, referrer }: EditFileMetadataProps) => { const { t: tEditFileMetadata } = useTranslation('editFileMetadata') const { t: tFiles } = useTranslation('files') diff --git a/src/sections/edit-file-metadata/EditFileMetadataReferrer.ts b/src/sections/edit-file-metadata/EditFileMetadataReferrer.ts new file mode 100644 index 000000000..553ecd82f --- /dev/null +++ b/src/sections/edit-file-metadata/EditFileMetadataReferrer.ts @@ -0,0 +1,8 @@ +/** + * Enum indicating where the user came from when editing file metadata. + * Extracted to its own file to avoid circular import issues. + */ +export enum EditFileMetadataReferrer { + DATASET = 'dataset', + FILE = 'file' +} diff --git a/src/sections/file/FilesContext.ts b/src/sections/file/FilesContext.ts index 70a702a8a..e605bfc56 100644 --- a/src/sections/file/FilesContext.ts +++ b/src/sections/file/FilesContext.ts @@ -15,10 +15,11 @@ export const FilesContext = createContext({ export const useFilesContext = () => { const context = useContext(FilesContext) + // Unreachable while createContext is given a real default; kept as a guard + // for a future refactor that changes the default to null. + /* istanbul ignore if */ if (!context) { - /* istanbul ignore next */ throw new Error( - 'useFilesContext must be used within a FilesContext Provider' - ) + throw new Error('useFilesContext must be used within a FilesContext Provider') } return context } diff --git a/src/sections/replace-file/ReplaceFile.tsx b/src/sections/replace-file/ReplaceFile.tsx index 401782721..6518abe43 100644 --- a/src/sections/replace-file/ReplaceFile.tsx +++ b/src/sections/replace-file/ReplaceFile.tsx @@ -10,8 +10,12 @@ import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' import { AppLoader } from '../shared/layout/app-loader/AppLoader' import { NotFoundPage } from '../not-found-page/NotFoundPage' import { FileUploader, OperationType } from '../shared/file-uploader/FileUploader' +import { ReplaceFileReferrer } from './ReplaceFileReferrer' import styles from './ReplaceFile.module.scss' +// Re-export for backwards compatibility +export { ReplaceFileReferrer } from './ReplaceFileReferrer' + interface ReplaceFileProps { fileRepository: FileRepository datasetRepository: DatasetRepository @@ -21,12 +25,6 @@ interface ReplaceFileProps { referrer?: ReplaceFileReferrer } -// From where the user is coming from -export enum ReplaceFileReferrer { - DATASET = 'dataset', - FILE = 'file' -} - export const ReplaceFile = ({ fileRepository, datasetRepository, diff --git a/src/sections/replace-file/ReplaceFileReferrer.ts b/src/sections/replace-file/ReplaceFileReferrer.ts new file mode 100644 index 000000000..357ca74cf --- /dev/null +++ b/src/sections/replace-file/ReplaceFileReferrer.ts @@ -0,0 +1,8 @@ +/** + * Enum indicating where the user came from when replacing a file. + * Extracted to its own file to avoid circular import issues. + */ +export enum ReplaceFileReferrer { + DATASET = 'dataset', + FILE = 'file' +} diff --git a/src/sections/shared/file-uploader/FileUploader.tsx b/src/sections/shared/file-uploader/FileUploader.tsx index 6631b2852..0b0cea782 100644 --- a/src/sections/shared/file-uploader/FileUploader.tsx +++ b/src/sections/shared/file-uploader/FileUploader.tsx @@ -2,7 +2,7 @@ import { File as FileModel } from '@/files/domain/models/File' import { FileRepository } from '@/files/domain/repositories/FileRepository' import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' -import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFile' +import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFileReferrer' import { FileUploaderProvider } from './context/FileUploaderContext' import { useGetFixityAlgorithm } from './useGetFixityAlgorithm' import { FileUploaderGlobalConfig } from './context/fileUploaderReducer' diff --git a/src/sections/shared/file-uploader/FileUploaderHelper.ts b/src/sections/shared/file-uploader/FileUploaderHelper.ts index 0067fed89..f6edadee8 100644 --- a/src/sections/shared/file-uploader/FileUploaderHelper.ts +++ b/src/sections/shared/file-uploader/FileUploaderHelper.ts @@ -46,7 +46,10 @@ export class FileUploaderHelper { } public static async getChecksum(blob: Blob, algorithm: FixityAlgorithm): Promise { - if (algorithm === FixityAlgorithm.MD5) { + if (algorithm === FixityAlgorithm.NONE) { + // No checksum calculation needed + return '' + } else if (algorithm === FixityAlgorithm.MD5) { return await this.getMD5Checksum(blob) } else { return await this.getSubtleDigestChecksum(blob, algorithm) diff --git a/src/sections/shared/file-uploader/FileUploaderPanel.module.scss b/src/sections/shared/file-uploader/FileUploaderPanel.module.scss new file mode 100644 index 000000000..c1442ddb0 --- /dev/null +++ b/src/sections/shared/file-uploader/FileUploaderPanel.module.scss @@ -0,0 +1,16 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.helper_text { + color: $dv-subtext-color; + font-size: 14px; + margin-bottom: 1rem; + + a { + color: $dv-primary-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } +} diff --git a/src/sections/shared/file-uploader/FileUploaderPanel.tsx b/src/sections/shared/file-uploader/FileUploaderPanel.tsx index 899f94bd8..02402ea6c 100644 --- a/src/sections/shared/file-uploader/FileUploaderPanel.tsx +++ b/src/sections/shared/file-uploader/FileUploaderPanel.tsx @@ -1,19 +1,17 @@ -import { useMemo } from 'react' +import { useMemo, useCallback } from 'react' import { useDeepCompareEffect } from 'use-deep-compare' -import { toast } from 'react-toastify' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { useBlocker, useNavigate } from 'react-router-dom' -import { Stack } from '@iqss/dataverse-design-system' import { FileRepository } from '@/files/domain/repositories/FileRepository' import { QueryParamKey, Route } from '@/sections/Route.enum' import { DatasetNonNumericVersionSearchParam } from '@/dataset/domain/models/Dataset' +import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' -import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFile' +import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFileReferrer' import { useFileUploaderContext } from './context/FileUploaderContext' -import FileUploadInput from './file-upload-input/FileUploadInput' -import { UploadedFilesList } from './uploaded-files-list/UploadedFilesList' +import { FileUploaderPanelCore } from './FileUploaderPanelCore' import { ConfirmLeaveModal } from './confirm-leave-modal/ConfirmLeaveModal' -import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' +import styles from './FileUploaderPanel.module.scss' interface FileUploaderPanelProps { fileRepository: FileRepository @@ -33,8 +31,8 @@ const FileUploaderPanel = ({ referrer, fetchUploadLimits }: FileUploaderPanelProps) => { - const { t } = useTranslation('shared') const navigate = useNavigate() + const { t } = useTranslation('shared') const { fileUploaderState: { @@ -44,7 +42,6 @@ const FileUploaderPanel = ({ replaceOperationInfo, addFilesToDatasetOperationInfo }, - uploadedFiles, removeAllFiles } = useFileUploaderContext() @@ -56,15 +53,9 @@ const FileUploaderPanel = ({ const handleConfirmLeavePage = () => { if (navigationBlocker.state === 'blocked') { - // TODO - Remove the files from the S3 bucket we need an API endpoint for this. - removeAllFiles() - - // Cancel all the uploading files if there are any if (uploadingToCancelMap.size > 0) { - uploadingToCancelMap.forEach((cancel) => { - cancel() - }) + uploadingToCancelMap.forEach((cancel) => cancel()) } navigationBlocker.proceed() } @@ -76,60 +67,61 @@ const FileUploaderPanel = ({ } } - useDeepCompareEffect(() => { - const datasetPageRedirectUrl = `${Route.DATASETS}?${QueryParamKey.PERSISTENT_ID}=${datasetPersistentId}&${QueryParamKey.VERSION}=${DatasetNonNumericVersionSearchParam.DRAFT}` + const handleCancel = useCallback(() => navigate(-1), [navigate]) - // Listens to the replace operation info result and navigates to the new file page if the operation was successful - if (replaceOperationInfo.success && replaceOperationInfo.newFileIdentifier) { - toast.success(t('fileUploader.fileReplacedSuccessfully')) + const datasetPageUrl = `${Route.DATASETS}?${QueryParamKey.PERSISTENT_ID}=${datasetPersistentId}&${QueryParamKey.VERSION}=${DatasetNonNumericVersionSearchParam.DRAFT}` + // Navigate after a successful save/replace. This effect is registered after + // useBlocker, so React fires useBlocker's predicate-update effect first — + // by the time navigate() runs, the router's blocker fn already returns false + // and the leave modal stays hidden. + useDeepCompareEffect(() => { + if (replaceOperationInfo.success && replaceOperationInfo.newFileIdentifier) { if (referrer === ReplaceFileReferrer.DATASET) { - navigate(datasetPageRedirectUrl) - } - - if (referrer === ReplaceFileReferrer.FILE) { + navigate(datasetPageUrl) + } else if (referrer === ReplaceFileReferrer.FILE) { navigate( `${Route.FILES}?id=${replaceOperationInfo.newFileIdentifier}&${QueryParamKey.DATASET_VERSION}=${DatasetNonNumericVersionSearchParam.DRAFT}` ) } } - // Listens to the add files to dataset operation info result and navigates to the dataset page if the operation was successful if (addFilesToDatasetOperationInfo.success) { - toast.success(t('fileUploader.filesAddedToDatasetSuccessfully')) - navigate(datasetPageRedirectUrl) + navigate(datasetPageUrl) } - }, [ - replaceOperationInfo, - addFilesToDatasetOperationInfo, - datasetPersistentId, - t, - navigate, - referrer - ]) + }, [replaceOperationInfo, addFilesToDatasetOperationInfo, navigate, datasetPageUrl, referrer]) return ( - - +

+ + ) + }} + /> +

+ - {uploadedFiles.length > 0 && ( - - )} - -
+ ) } diff --git a/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx b/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx new file mode 100644 index 000000000..e8df27ea4 --- /dev/null +++ b/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx @@ -0,0 +1,72 @@ +import { useDeepCompareEffect } from 'use-deep-compare' +import { toast } from 'react-toastify' +import { useTranslation } from 'react-i18next' +import { Stack } from '@iqss/dataverse-design-system' +import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' +import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { useFileUploaderContext } from './context/FileUploaderContext' +import FileUploadInput from './file-upload-input/FileUploadInput' +import { UploadedFilesList } from './uploaded-files-list/UploadedFilesList' +import { UploaderFileRepository } from './types' + +export interface FileUploaderPanelCoreProps { + fileRepository: UploaderFileRepository + datasetRepository?: DatasetRepository + datasetPersistentId: string + fetchUploadLimits?: ( + datasetId: string | number, + datasetRepository: DatasetRepository + ) => Promise + /** Called when user clicks Cancel */ + onCancel: () => void +} + +export const FileUploaderPanelCore = ({ + fileRepository, + datasetRepository, + datasetPersistentId, + fetchUploadLimits, + onCancel +}: FileUploaderPanelCoreProps) => { + const { t } = useTranslation('shared') + + const { + fileUploaderState: { replaceOperationInfo, addFilesToDatasetOperationInfo }, + uploadedFiles + } = useFileUploaderContext() + + // Toast on success only. Post-success navigation is owned by each parent + // (SPA / standalone) so it can be colocated with that parent's blocking + // mechanism — useBlocker in the SPA, beforeunload in the standalone. Keeping + // navigate next to useBlocker is what makes React fire the blocker's + // predicate-update effect before the navigate, so the leave modal doesn't + // latch on a stale blocker fn. + useDeepCompareEffect(() => { + if (replaceOperationInfo.success && replaceOperationInfo.newFileIdentifier) { + toast.success(t('fileUploader.fileReplacedSuccessfully')) + } + + if (addFilesToDatasetOperationInfo.success) { + toast.success(t('fileUploader.filesAddedToDatasetSuccessfully')) + } + }, [replaceOperationInfo, addFilesToDatasetOperationInfo, t]) + + return ( + + + + {uploadedFiles.length > 0 && ( + + )} + + ) +} diff --git a/src/sections/shared/file-uploader/context/FileUploaderContext.tsx b/src/sections/shared/file-uploader/context/FileUploaderContext.tsx index fa981a2e1..ad1c5d464 100644 --- a/src/sections/shared/file-uploader/context/FileUploaderContext.tsx +++ b/src/sections/shared/file-uploader/context/FileUploaderContext.tsx @@ -9,6 +9,7 @@ import { ReplaceOperationInfo, AddFilesToDatasetOperationInfo } from './fileUploaderReducer' +import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' export interface FileUploaderContextValue { fileUploaderState: FileUploaderState @@ -88,7 +89,9 @@ export const FileUploaderProvider = ({ children, initialConfig }: FileUploaderPr () => Object.values(fileUploaderState.files).filter( (file): file is FileUploadState & { storageId: string; checksumValue: string } => - file.status === FileUploadStatus.DONE && !!file.storageId && !!file.checksumValue + file.status === FileUploadStatus.DONE && + !!file.storageId && + (file.checksumAlgorithm === FixityAlgorithm.NONE || file.checksumValue !== undefined) ), [fileUploaderState.files] ) diff --git a/src/sections/shared/file-uploader/context/fileUploaderReducer.ts b/src/sections/shared/file-uploader/context/fileUploaderReducer.ts index 6b57baeeb..09cfbddfa 100644 --- a/src/sections/shared/file-uploader/context/fileUploaderReducer.ts +++ b/src/sections/shared/file-uploader/context/fileUploaderReducer.ts @@ -150,14 +150,17 @@ export const fileUploaderReducer = ( case 'ADD_UPLOADING_TO_CANCEL': { const { key, cancel } = action - state.uploadingToCancelMap.set(key, cancel) - return state + return { + ...state, + uploadingToCancelMap: new Map(state.uploadingToCancelMap).set(key, cancel) + } } case 'REMOVE_UPLOADING_TO_CANCEL': { const { key } = action - state.uploadingToCancelMap.delete(key) - return state + const uploadingToCancelMap = new Map(state.uploadingToCancelMap) + uploadingToCancelMap.delete(key) + return { ...state, uploadingToCancelMap } } case 'SET_REPLACE_OPERATION_INFO': { diff --git a/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx b/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx index 4c385da67..ed96873f6 100644 --- a/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx +++ b/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx @@ -1,26 +1,25 @@ -import { ChangeEventHandler, DragEventHandler, useRef, useState } from 'react' +import { ChangeEventHandler, DragEventHandler, memo, useCallback, useRef, useState } from 'react' import { Accordion, Button, Card, ProgressBar } from '@iqss/dataverse-design-system' import { ExclamationTriangle, Plus, XLg } from 'react-bootstrap-icons' -import { Trans, useTranslation } from 'react-i18next' -import { Semaphore } from 'async-mutex' +import { useTranslation } from 'react-i18next' import { toast } from 'react-toastify' import cn from 'classnames' -import { FileRepository } from '@/files/domain/repositories/FileRepository' import MimeTypeDisplay from '@/files/domain/models/FileTypeToFriendlyTypeMap' -import { uploadFile } from '@/files/domain/useCases/uploadFile' import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' import { useFileUploaderContext } from '../context/FileUploaderContext' -import { FileUploadState, FileUploadStatus } from '../context/fileUploaderReducer' +import { FileUploadStatus } from '../context/fileUploaderReducer' import { OperationType } from '../FileUploader' import { FileUploaderHelper } from '../FileUploaderHelper' +import { useFileUploadOperations } from '../useFileUploadOperations' import { SwalModal } from '../../swal-modal/SwalModal' +import { UploaderFileRepository } from '../types' import styles from './FileUploadInput.module.scss' import { useUploadLimit } from './useUploadLimit' type FileUploadInputProps = { - fileRepository: FileRepository - datasetRepository: DatasetRepository + fileRepository: UploaderFileRepository + datasetRepository?: DatasetRepository datasetPersistentId: string fetchUploadLimits?: ( datasetId: string | number, @@ -28,9 +27,6 @@ type FileUploadInputProps = { ) => Promise } -const limit = 6 -const semaphore = new Semaphore(limit) - const maxFilesPerUpload = 1000 const FileUploadInput = ({ @@ -57,6 +53,7 @@ const FileUploadInput = ({ const { t } = useTranslation('shared') const inputRef = useRef(null) + const folderInputRef = useRef(null) const [isDragging, setIsDragging] = useState(false) const { uploadLimit } = useUploadLimit(datasetPersistentId, datasetRepository, fetchUploadLimits) @@ -70,81 +67,53 @@ const FileUploadInput = ({ const canKeepUploading = operationType === OperationType.ADD_FILES_TO_DATASET ? true : totalFiles === 0 - const onFileUploadFailed = (file: File) => { - removeUploadingToCancel(FileUploaderHelper.getFileKey(file)) - semaphore.release(1) - } - - const onFileUploadFinished = async (file: File) => { - const fileKey = FileUploaderHelper.getFileKey(file) - - try { - const checksumValue = await FileUploaderHelper.getChecksum(file, checksumAlgorithm) - updateFile(fileKey, { checksumValue }) - } finally { - removeUploadingToCancel(fileKey) - semaphore.release(1) - } - } - - const uploadOneFile = async (file: File) => { - if (FileUploaderHelper.isDS_StoreFile(file)) { - toast.info(t('fileUploader.fileUploadSkipped.dsStore')) - return - } - - if ( - operationType === OperationType.REPLACE_FILE && - originalFile.metadata.type.value !== file.type - ) { - const shouldContinue = await requestFileTypeDifferentConfirmation( - originalFile.metadata.type.value, - file.type - ) - - if (!shouldContinue) { - // Reset the file input, otherwise in case user cancels but then tries to upload the same file again, the input will not trigger the change event - if (inputRef.current) { - inputRef.current.value = '' + const validateBeforeUpload = useCallback( + async (file: File): Promise => { + if ( + operationType === OperationType.REPLACE_FILE && + originalFile.metadata.type.value !== file.type + ) { + const shouldContinue = await requestFileTypeDifferentConfirmation( + originalFile.metadata.type.value, + file.type + ) + + if (!shouldContinue) { + if (inputRef.current) { + inputRef.current.value = '' + } + return false } - // Stop the upload process for this file - return } - } - // File already uploaded - if (getFileByKey(FileUploaderHelper.getFileKey(file))) { - const fileInfo = getFileByKey(FileUploaderHelper.getFileKey(file)) as FileUploadState - toast.info( - t('fileUploader.fileUploadSkipped.alreadyUploaded', { fileName: fileInfo.fileName }) - ) + return true + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- requestFileTypeDifferentConfirmation is stable within the component + [operationType, originalFile] + ) - return + const { uploadOneFile, handleDroppedItems } = useFileUploadOperations({ + fileRepository, + datasetPersistentId, + checksumAlgorithm, + addFile, + updateFile, + getFileByKey, + addUploadingToCancel, + removeUploadingToCancel, + validateBeforeUpload, + onFileSkipped: (reason, file) => { + if (reason === 'ds_store') { + toast.info(t('fileUploader.fileUploadSkipped.dsStore')) + } else if (reason === 'already_uploaded') { + const fileInfo = getFileByKey(FileUploaderHelper.getFileKey(file)) + if (fileInfo) { + toast.info( + t('fileUploader.fileUploadSkipped.alreadyUploaded', { fileName: fileInfo.fileName }) + ) + } + } } - - await semaphore.acquire(1) - - const fileKey = FileUploaderHelper.getFileKey(file) - - addFile(file) - - const cancelFunction = uploadFile( - fileRepository, - datasetPersistentId, - file, - () => { - updateFile(fileKey, { status: FileUploadStatus.DONE }) - void onFileUploadFinished(file) - }, - () => { - updateFile(fileKey, { status: FileUploadStatus.FAILED }) - onFileUploadFailed(file) - }, - (now) => updateFile(fileKey, { progress: now }), - (storageId) => updateFile(fileKey, { storageId }) - ) - - addUploadingToCancel(fileKey, cancelFunction) - } + }) const handleInputFileChange: ChangeEventHandler = (event) => { const filesArray = Array.from(event.target.files || []) @@ -160,33 +129,18 @@ const FileUploadInput = ({ } } - // waiting on the possibility to test folder drop: https://github.com/cypress-io/cypress/issues/19696 - const addFromDir = (dir: FileSystemDirectoryEntry) => { - /* istanbul ignore next */ - const reader = dir.createReader() - - reader.readEntries((entries) => { - entries.forEach((entry) => { - if (entry.isFile) { - const fse = entry as FileSystemFileEntry - fse.file((file) => { - const fileWithPath = new File([file], file.name, { - type: file.type, - lastModified: file.lastModified - }) - - Object.defineProperty(fileWithPath, 'webkitRelativePath', { - value: entry.fullPath, - writable: true - }) - - void uploadOneFile(fileWithPath) - }) - } else if (entry.isDirectory) { - addFromDir(entry as FileSystemDirectoryEntry) - } - }) - }) + const handleFolderInputChange: ChangeEventHandler = (event) => { + const filesArray = Array.from(event.target.files || []) + + if (filesArray && filesArray.length > 0) { + for (const file of filesArray) { + void uploadOneFile(file) + } + } + + if (folderInputRef.current) { + folderInputRef.current.value = '' + } } const handleDropFiles: DragEventHandler = (event) => { @@ -202,6 +156,7 @@ const FileUploadInput = ({ } const droppedItems = event.dataTransfer.items + const droppedFiles = event.dataTransfer.files if (droppedItems.length > 0) { if (operationType === OperationType.REPLACE_FILE && droppedItems.length > 1) { @@ -209,16 +164,7 @@ const FileUploadInput = ({ return } - Array.from(droppedItems).forEach((droppedFile) => { - if (droppedFile.webkitGetAsEntry()?.isDirectory) { - addFromDir(droppedFile.webkitGetAsEntry() as FileSystemDirectoryEntry) - } else if (droppedFile.webkitGetAsEntry()?.isFile) { - const fse = droppedFile.webkitGetAsEntry() as FileSystemFileEntry - fse.file((file) => { - void uploadOneFile(file) - }) - } - }) + handleDroppedItems(droppedItems, droppedFiles) } } @@ -263,22 +209,6 @@ const FileUploadInput = ({ return (
-

- - ) - }} - /> -

- {t('fileUploader.accordionTitle')} @@ -315,6 +245,15 @@ const FileUploadInput = ({ ? t('fileUploader.selectFileMultiple') : t('fileUploader.selectFileSingle')} + {operationType === OperationType.ADD_FILES_TO_DATASET && ( + + )}