diff --git a/.changeset/review-hardening.md b/.changeset/review-hardening.md new file mode 100644 index 00000000..5a1f2732 --- /dev/null +++ b/.changeset/review-hardening.md @@ -0,0 +1,17 @@ +--- +"@matdata/yasgui": minor +"@matdata/yasqe": patch +"@matdata/yasr": patch +"@matdata/yasgui-utils": patch +--- + +Safer share links, storage and dialogs: + +- A query opened from a share link no longer runs on its own when it is an update, has syntax errors, or would send saved credentials or cookies; a message asks first. New `autoRunSharedQueries` option ("safe" by default, "always", "never"). +- A malformed `headers` or `outputSettings` parameter in a link is ignored instead of breaking the page. +- When the browser storage is full, MatGUI saves the configuration without query results, and otherwise keeps working and says so. It no longer deletes every tab, endpoint and credential. +- In-app dialogs and toasts replace every `alert`, `confirm` and `prompt`, so they follow the theme and work in sandboxed iframes; available to plugins and embedders as `Yasgui.dialogs`. +- Configuration import reads the Turtle file with a real parser, so tabs without an endpoint or with quotes in their name import correctly. +- Saving a Git query after a stale version tag no longer reports a conflict when the automatic retry succeeded. +- The OAuth 2.0 popup only accepts its result from the origin of the redirect URI. +- Yasqe `requestSendsCredentials()`. diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0d549b19..4fc7d950 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -9,7 +9,7 @@ module.exports = { parserOptions: { ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features sourceType: "module", // Allows for the use of imports, - project: expensive ? "./tsconfig-validate.json" : undefined, + project: expensive ? ["./tsconfig-validate.json", "./tsconfig-test.json"] : undefined, tsconfigRootDir: expensive ? "." : undefined, }, plugins: ["@typescript-eslint", "jest", "lodash"], diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..426495b9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +version: 2 +updates: + # Monorepo packages (packages/*) and build tooling + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + codemirror: + patterns: + - "@codemirror/*" + - "@lezer/*" + matdata-plugins: + patterns: + - "@matdata/*" + dev-tooling: + dependency-type: development + update-types: + - minor + - patch + + # Documentation website + - package-ecosystem: npm + directory: /website + schedule: + interval: monthly + groups: + docusaurus: + patterns: + - "@docusaurus/*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 15c80292..a9d08c8c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + workflow_dispatch: jobs: build: @@ -31,6 +32,16 @@ jobs: - name: Build project run: npm run build + # After the build, which generates packages/yasgui/src/version.ts + - name: Type-check + run: npm run util:validateTs + + - name: Lint + run: npm run util:lint + + - name: Bundle size + run: npm run util:bundleSize + - name: Run tests run: npm run test diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index feaaf831..00000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: Run tests - -on: push - -jobs: - tests: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Set up NodeJS - uses: actions/setup-node@v6 - with: - node-version: "lts/*" - cache: npm - - - name: Disable AppArmor - run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Run tests - run: npm run test diff --git a/.gitignore b/.gitignore index 7eb4e25b..71f8d8cb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ website/node_modules website/build website/.docusaurus +bundle-stats diff --git a/README.md b/README.md index e7d40abd..d3ab03c1 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,12 @@ The **documentation for Matgui is hosted on GitHub Pages**: -- **📚 Documentation Website**: [https://matgui-doc.matdata.eu/](https://matdata-eu.github.io/Yasgui/) +- **📚 Documentation Website**: [https://matgui-doc.matdata.eu/](https://matgui-doc.matdata.eu/) - User Guide, Developer Guide, API Reference - Built with Docusaurus - Version-tagged with the repository -- **🚀 Development Build**: [https://matgui-doc.matdata.eu/dev/main/](https://matdata-eu.github.io/Yasgui/dev/main/) +- **🚀 Development Build**: [https://matgui-doc.matdata.eu/dev/main/](https://matgui-doc.matdata.eu/dev/main/) - Live build from the main branch - Updated automatically with every commit - Test latest features before release diff --git a/docker/Dockerfile b/docker/Dockerfile index 4dd709ad..ef93d473 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Use the official Node.js image as the base image -FROM node:20 +FROM node:24 # Set the working directory WORKDIR /app @@ -36,6 +36,7 @@ COPY docker/src/ /usr/share/nginx/html/ USER root +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf COPY docker/change-default-endpoint.sh /docker-entrypoint.d/ RUN chmod +x /docker-entrypoint.d/change-default-endpoint.sh RUN chown 1001:0 -R /usr/share/nginx/html/ diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 00000000..34224d25 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,36 @@ +# MatGUI static site. Replaces the default server of nginx-unprivileged (which listens on 8080). +server { + listen 8080; + listen [::]:8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # The bundle is about 5 MB of JavaScript; gzip brings the transfer down to about 1.5 MB + gzip on; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_vary on; + gzip_proxied any; + gzip_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml text/plain; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Fonts and images emitted by the build carry a content hash in their name + location ~* "-[A-Z0-9]{8}\.(woff2?|ttf|eot|png|jpg|svg)$" { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header X-Content-Type-Options "nosniff" always; + } + + # yasgui.min.js and .css keep their name across releases: let browsers revalidate them + location ~* \.(js|css)$ { + add_header Cache-Control "no-cache"; + add_header X-Content-Type-Options "nosniff" always; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 962b7dba..bac79faa 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -664,6 +664,13 @@ interface Config { // Populate config from URL parameters populateFromUrl: boolean | ((config: TabJson) => TabJson); // default: true + // Whether a query opened from a share link runs on load. Update queries never do. + // "safe": runs, unless the request would carry credentials (Authorization or API key + // header, or withCredentials) or the query is invalid; then a message asks first + // "always": runs unless it is an update + // "never": loads the query without running it + autoRunSharedQueries?: "safe" | "always" | "never"; // default: "safe" + // Auto-create first tab on init autoAddOnInit: boolean; // default: true @@ -2577,6 +2584,37 @@ yasgui.helpMenu.removeItem("contact"); `yasgui.whatsNew` (unless `showWhatsNew: false`) has `open()`, `close()`, `hasUnseen()` and `getLatestVersion()`. The release entries live in `packages/yasgui/src/whatsNew/releases.ts`, newest first; add the next version there when preparing a release. The newest version a user has opened is stored as `whatsNewSeen` in the persisted config. +#### Dialogs and notifications + +`Yasgui.dialogs` shows alerts, confirmations and toasts in the MatGUI style, inside the Yasgui root so they follow the theme. Unlike `window.alert` and `window.confirm` they don't block the page, and they keep working when MatGUI is embedded in a sandboxed iframe (where `confirm()` silently returns `false`). Plugins and embedding pages can use them too. + +```javascript +const { showAlert, showConfirm, showChoice, showToast, showCopyValue } = Yasgui.dialogs; + +await showAlert("The query was saved.", { title: "Saved" }); + +if (await showConfirm("Its queries stay in the repository.", { title: "Remove workspace?", confirmLabel: "Remove", danger: true })) { + // ... +} + +const choice = await showChoice( + "This query has unsaved changes.", + [ + { label: "Cancel", value: "cancel" }, + { label: "Discard", value: "discard", kind: "danger" }, + { label: "Save", value: "save", kind: "primary" }, + ], + "cancel", // returned on Escape or a click outside +); + +showToast("Copied", { kind: "success" }); // kind: "info" | "success" | "warning" | "error" +showToast("Results are outdated", { action: { label: "Run again", onClick: () => yasgui.getTab().query() } }); + +await showCopyValue("Copy the URI of this query", "https://example.org/query/1"); +``` + +Each function takes a `host` option: an element inside the Yasgui instance the dialog belongs to. Without it, the dialog opens in the last Yasgui instance created on the page. + ### Tab Class Represents a query tab. diff --git a/docs/user-guide.md b/docs/user-guide.md index 890bb24f..c37a75af 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1277,6 +1277,8 @@ _OAuth 2.0:_ - **Be cautious on shared computers**: Clear your browser data when using Matgui on shared or public computers - **OAuth 2.0 tokens**: Access tokens are automatically refreshed when expired (if refresh token is available) - **Token security**: OAuth 2.0 uses secure PKCE flow (Proof Key for Code Exchange) for enhanced security +- **Shared links don't use your credentials on their own**: a link that would query an endpoint with your saved credentials asks before it runs (see [Share Queries](#share-queries)) +- **When the browser storage is full**, MatGUI first stops keeping query results with the tabs; if that isn't enough it keeps working without saving and tells you. It never deletes your tabs or credentials to make room **How Authentication Works:** @@ -1645,6 +1647,12 @@ Matgui supports URL-based query sharing: - `endpoint`: The endpoint URL - Other tab configurations +**Opening a shared link:** the query of the link opens in a tab and runs straight away, except in these cases, where MatGUI shows a message and waits for you: + +- **Update queries** (INSERT, DELETE, LOAD, CLEAR, DROP...) never run from a link. Review the query and press **Run** yourself if you want to change the data. +- **Queries that would use your saved credentials** for that endpoint (or cookies) wait until you click **Run query** in the message, so a link someone sends you can't run a query as you without you seeing it. +- **Queries with syntax errors** are loaded without running. + **Command-Line Formats:** All command-line formats (cURL, PowerShell, wget) include: diff --git a/esbuild.config.js b/esbuild.config.js index 142c2142..a183d573 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -146,6 +146,9 @@ async function buildPackage(name, entryPoint, globalName) { }, }); extractCss(iifeResult, name); + // Bundle composition, for `npm run util:bundleSize` (kept out of build/ so it isn't distributed) + fs.mkdirSync("bundle-stats", { recursive: true }); + fs.writeFileSync(`bundle-stats/${name}.json`, JSON.stringify(iifeResult.metafile)); // ESM build: consumed by bundlers (vite/webpack/esbuild) and Node via `import`. // Uses the `.mjs` extension so it is always treated as an ES module. diff --git a/package-lock.json b/package-lock.json index e94648e5..20c61f15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,20 +16,18 @@ "@matdata/yasgui-explain-plugin": "^1.0.0", "@matdata/yasgui-geo-plugin": "^1.3.0", "@matdata/yasgui-graph-plugin": "^1.7.0", - "@matdata/yasgui-table-plugin": "^1.7.1", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "autoprefixer": "^10.4.21", - "better-npm-run": "^0.1.1", - "chai": "^6.2.0", - "fs-extra": "^11.3.2", - "mocha": "^12.0.3" + "@matdata/yasgui-table-plugin": "^1.7.1" }, "devDependencies": { "@changesets/cli": "^2.29.7", "@types/chai": "^5.2.3", "@types/fs-extra": "^11.0.4", "@types/mocha": "^10.0.10", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "autoprefixer": "^10.4.21", + "better-npm-run": "^0.1.1", + "chai": "^6.2.0", "cross-env": "^7.0.3", "esbuild": "^0.28.2", "esbuild-sass-plugin": "^3.3.1", @@ -37,8 +35,10 @@ "eslint-config-prettier": "^6.10.0", "eslint-plugin-jest": "^23.8.1", "eslint-plugin-lodash": "^7.1.0", + "fs-extra": "^11.3.2", "husky": "^9.1.7", "lint-staged": "^16.2.6", + "mocha": "^12.0.3", "postcss": "^8.4.49", "prettier": "^3.6.2", "puppeteer": "^25.16.0", @@ -53,6 +53,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1112,6 +1113,7 @@ "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -1130,6 +1132,7 @@ "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -1139,6 +1142,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.4", @@ -1162,6 +1166,7 @@ "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1172,6 +1177,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -1184,20 +1190,12 @@ "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz", - "integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/@fortawesome/fontawesome-free": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.1.0.tgz", @@ -1207,23 +1205,12 @@ "node": ">=6" } }, - "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz", - "integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==", - "license": "(CC-BY-4.0 AND MIT)", - "dependencies": { - "@fortawesome/fontawesome-common-types": "7.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", + "dev": true, "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", @@ -1238,6 +1225,7 @@ "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1248,6 +1236,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -1260,6 +1249,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.22" @@ -1274,6 +1264,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@inquirer/external-editor": { @@ -1609,6 +1600,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1622,6 +1614,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1631,6 +1624,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2498,13 +2492,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/blueimp-md5": { - "version": "2.18.2", - "resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.2.tgz", - "integrity": "sha512-dJ9yRry9Olt5GAWlgCtE5dK9d/Dfhn/V7hna86eEO2Pn76+E8Y0S0n61iEUEGhWXXgtKtHxtZLVNwL8X+vLHzg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2547,16 +2534,6 @@ "license": "MIT", "peer": true }, - "node_modules/@types/jquery": { - "version": "3.5.33", - "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.33.tgz", - "integrity": "sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/sizzle": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2655,13 +2632,6 @@ "htmlparser2": "^4.1.0" } }, - "node_modules/@types/sizzle": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", - "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2673,6 +2643,7 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", @@ -2837,6 +2808,7 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", @@ -2865,6 +2837,7 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "7.18.0", @@ -2882,6 +2855,7 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/typescript-estree": "7.18.0", @@ -2909,6 +2883,7 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2922,6 +2897,7 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/types": "7.18.0", @@ -2950,6 +2926,7 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", @@ -2972,6 +2949,7 @@ "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "7.18.0", @@ -2989,6 +2967,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, "license": "ISC" }, "node_modules/abort-controller": { @@ -3007,6 +2986,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3019,6 +2999,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -3028,6 +3009,7 @@ "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -3070,6 +3052,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3079,6 +3062,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3094,12 +3078,14 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3119,6 +3105,7 @@ "version": "10.4.23", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3164,6 +3151,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -3190,6 +3178,7 @@ "version": "2.11.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3202,6 +3191,7 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/better-npm-run/-/better-npm-run-0.1.1.tgz", "integrity": "sha512-SBBYsUsb6bYcUMF9QUWy39GX5kzD4CoRBP11gx/k5jYkUr4Tr+irAokIeQX5FgfCRz0Q27rt8U0J4D2TlRgQFA==", + "dev": true, "license": "MIT", "dependencies": { "commander": "^2.9.0", @@ -3235,16 +3225,11 @@ "@types/geojson": "^7946.0.16" } }, - "node_modules/blueimp-md5": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", - "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3254,6 +3239,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3266,12 +3252,14 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, "license": "ISC" }, "node_modules/browserslist": { "version": "4.29.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3344,6 +3332,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3353,6 +3342,7 @@ "version": "1.0.30001816", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3373,6 +3363,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -3382,6 +3373,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -3401,17 +3393,6 @@ "dev": true, "license": "MIT" }, - "node_modules/choices.js": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/choices.js/-/choices.js-9.1.0.tgz", - "integrity": "sha512-6NnqiE/MNnNAiMzdW7phJ49nMQylkKMQ6La6PAS1+h1VhrGt38MOPnjzEJ3cRaECieqaGpl9eFGtI2icW27r8A==", - "license": "MIT", - "dependencies": { - "deepmerge": "^4.2.2", - "fuse.js": "^3.4.6", - "redux": "^4.1.2" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -3555,6 +3536,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3567,6 +3549,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -3584,31 +3567,11 @@ "license": "MIT", "peer": true }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/column-resizer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/column-resizer/-/column-resizer-1.4.0.tgz", - "integrity": "sha512-KM5Jh/UBKwVUr01oEGN/OvxF6gZIEn4c1Qde4iHSqNru9hxq93ao3u93qb9N1E1TZ2Sxjh4x7OHGe8v/P8FgkA==", - "license": "BSD-3-Clause", - "dependencies": { - "string-hash": "~1.1.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, "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==", + "dev": true, "license": "MIT" }, "node_modules/component-emitter": { @@ -3628,6 +3591,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/crelt": { @@ -3659,6 +3623,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", @@ -3669,29 +3634,11 @@ "node": ">= 8" } }, - "node_modules/datatables.net": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-2.3.5.tgz", - "integrity": "sha512-Qrwc+vuw8GHo42u1usWTuriNAMW0VvLPSW3j8g3GxvatiD8wS/ZGW32VAYLLfmF4Hz0C/fo2KB3xZBfcpqqVTQ==", - "license": "MIT", - "dependencies": { - "jquery": ">=1.7" - } - }, - "node_modules/datatables.net-dt": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/datatables.net-dt/-/datatables.net-dt-2.3.5.tgz", - "integrity": "sha512-JJkMNM03RUkv4jwAqyXsDHqUTEvti3+15466QiauzqRCmBOSwrHoglt1sRbvBwsv2isrCObp50JemgfG8R3wEQ==", - "license": "MIT", - "dependencies": { - "datatables.net": "2.3.5", - "jquery": ">=1.7" - } - }, "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", "dependencies": { "ms": "^2.1.3" @@ -3705,30 +3652,13 @@ } } }, - "node_modules/decode-uri-component": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.5.0.tgz", - "integrity": "sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -3760,6 +3690,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3769,6 +3700,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, "license": "MIT", "dependencies": { "path-type": "^4.0.0" @@ -3781,6 +3713,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -3893,6 +3826,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-2.0.0.tgz", "integrity": "sha512-Y+zZAmv7p2zOdpyZcSIA+aIxohsyfTcNaMeh3YJn9exq85bQhso65Wz9IhjYYNB4zyvXnfi7Ae+FuygARljVJw==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/echarts": { @@ -3915,6 +3849,7 @@ "version": "1.5.459", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", + "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { @@ -3961,12 +3896,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es6-object-assign": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz", - "integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==", - "license": "MIT" - }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -4029,6 +3958,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4038,6 +3968,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4051,6 +3982,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", @@ -4154,6 +4086,7 @@ "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -4196,6 +4129,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4208,6 +4142,7 @@ "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4218,6 +4153,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -4234,6 +4170,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -4249,6 +4186,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4261,6 +4199,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -4276,6 +4215,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -4291,6 +4231,7 @@ "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", @@ -4322,6 +4263,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -4334,6 +4276,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -4346,6 +4289,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -4355,6 +4299,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -4402,6 +4347,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -4418,6 +4364,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4430,18 +4377,21 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, "license": "MIT" }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -4451,6 +4401,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" @@ -4463,6 +4414,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4472,12 +4424,12 @@ } }, "node_modules/filter-obj": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", - "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-6.1.0.tgz", + "integrity": "sha512-xdMtCAODmPloU9qtmPcdBV9Kd27NtMse+4ayThxqIHUES5Z2S6bGpap5PpdmNM56ub7y3i1eyr+vJJIIgWGKmA==", "license": "MIT", "engines": { - "node": ">=14.16" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4501,6 +4453,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4513,6 +4466,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", @@ -4527,12 +4481,14 @@ "version": "3.4.4", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, "license": "ISC" }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -4546,6 +4502,7 @@ "version": "11.3.2", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -4560,6 +4517,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -4587,15 +4545,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fuse.js": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-3.6.1.tgz", - "integrity": "sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==", - "license": "Apache-2.0", - "engines": { - "node": ">=6" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4633,6 +4582,7 @@ "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -4650,6 +4600,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -4662,6 +4613,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -4671,6 +4623,7 @@ "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4683,6 +4636,7 @@ "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.8" @@ -4698,6 +4652,7 @@ "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.20.2" @@ -4713,6 +4668,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, "license": "MIT", "dependencies": { "array-union": "^2.1.0", @@ -4733,18 +4689,21 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, "license": "MIT" }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4849,6 +4808,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -4865,6 +4825,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -4881,6 +4842,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -4900,6 +4862,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -4910,6 +4873,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/is-core-module": { @@ -4932,6 +4896,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4957,6 +4922,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4969,6 +4935,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -4978,6 +4945,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5000,6 +4968,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5022,18 +4991,14 @@ "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/jquery": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, "funding": [ { "type": "github", @@ -5056,24 +5021,28 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, "license": "MIT" }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -5101,6 +5070,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -5136,6 +5106,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", @@ -5514,6 +5485,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.startcase": { @@ -5576,6 +5548,7 @@ "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -5585,6 +5558,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -5600,6 +5574,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -5626,6 +5601,7 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -5641,6 +5617,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -5657,6 +5634,7 @@ "version": "12.0.3", "resolved": "https://registry.npmjs.org/mocha/-/mocha-12.0.3.tgz", "integrity": "sha512-beammieDdiWdkjANWeWPpkxUROYGi5FN88fPOkLiticz7HTg3Y+Pho3EYE6SUdcuWnZ3h2WD8EXCA55JF4o6wg==", + "dev": true, "license": "MIT", "dependencies": { "browser-stdout": "^1.3.1", @@ -5687,6 +5665,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz", "integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==", + "dev": true, "funding": [ { "type": "github", @@ -5703,6 +5682,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -5712,6 +5692,7 @@ "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -5724,6 +5705,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -5739,6 +5721,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5751,6 +5734,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.5.0.tgz", "integrity": "sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==", + "dev": true, "funding": [ { "type": "github", @@ -5773,6 +5757,7 @@ "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.8" @@ -5788,6 +5773,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -5801,6 +5787,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14.16" @@ -5813,6 +5800,7 @@ "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", "dependencies": { "has-flag": "^4.0.0" @@ -5848,6 +5836,7 @@ "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" }, "node_modules/n3": { @@ -5867,6 +5856,7 @@ "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, "funding": [ { "type": "github", @@ -5885,6 +5875,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, "license": "MIT" }, "node_modules/node-addon-api": { @@ -5899,6 +5890,7 @@ "version": "2.0.59", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5908,6 +5900,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5917,6 +5910,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -5942,6 +5936,7 @@ "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "license": "MIT", "dependencies": { "deep-is": "^0.1.3", @@ -6044,6 +6039,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -6056,6 +6052,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6065,6 +6062,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6074,6 +6072,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" @@ -6090,6 +6089,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -6106,6 +6106,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6115,12 +6116,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6143,6 +6146,7 @@ "version": "8.5.30", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6171,12 +6175,14 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8.0" @@ -6224,6 +6230,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6287,17 +6294,16 @@ "license": "MIT" }, "node_modules/query-string": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.5.1.tgz", - "integrity": "sha512-/zO3RwuRCMTIcEgq6YMv4OrtEE1XzBG7w5N6zc6ydYnkWYOWsnLI/5894hYEzfESfMOT4cHCsRTIdxsSl1KjGg==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-10.0.0.tgz", + "integrity": "sha512-QYodx/Ywk8lCcOmsyupXVN91yWPAdjq0xVqE2FE2BHFYnma71C8txR8JCBptjzoUpkfAK7Qjua9VjYRTru4LYg==", "license": "MIT", "dependencies": { - "decode-uri-component": "^0.5.0", - "filter-obj": "^5.1.0", - "split-on-first": "^3.0.0" + "filter-obj": "^6.1.0", + "split-on-first": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6307,6 +6313,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -6448,15 +6455,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/redux": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", - "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, "node_modules/remove-accents": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.4.tgz", @@ -6515,6 +6513,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -6533,6 +6532,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" @@ -6548,6 +6548,7 @@ "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6559,6 +6560,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", @@ -6579,6 +6581,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -6625,6 +6628,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -7099,6 +7103,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7111,6 +7116,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.4.tgz", "integrity": "sha512-FqlEuoTq/zRmA4Lg6aOqyH3lCHbUtCB0bmHCeFniX2GBdDqMJM3FwdvEhlE5Pim1dlt2FfBdVPmUNNiGhhKqpw==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=20.0.0" @@ -7120,6 +7126,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" @@ -7132,6 +7139,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" @@ -7154,6 +7162,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7209,6 +7218,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7247,12 +7257,12 @@ } }, "node_modules/split-on-first": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz", - "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-4.0.0.tgz", + "integrity": "sha512-pyrgNqiEBHA9KlEtg9quYGagtrlTyukL3ryQ+RmjRn9SYmcNQmfa+sWIsI+hlcL4WrmFdrt7IfM6o8ktBAooDQ==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7293,12 +7303,6 @@ "node": ">=0.6.19" } }, - "node_modules/string-hash": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", - "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==", - "license": "CC0-1.0" - }, "node_modules/string-width": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", @@ -7349,6 +7353,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" @@ -7371,6 +7376,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7389,6 +7395,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -7458,6 +7465,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, "license": "MIT" }, "node_modules/tinyexec": { @@ -7522,6 +7530,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -7534,6 +7543,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -7585,6 +7595,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -7597,6 +7608,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -7616,6 +7628,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7635,6 +7648,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -7644,6 +7658,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -7674,6 +7689,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -7860,6 +7876,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" @@ -7884,6 +7901,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7893,6 +7911,7 @@ "version": "10.0.3", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.3.tgz", "integrity": "sha512-6z2Iis68Wqth93/G/wJP9u+R3O+d2XTlgWChGCwuT1qLbBsOYueGRZuJ++v3mtDP5KjYdy+WzvWC+VWETSVXJA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/wrap-ansi": { @@ -7977,6 +7996,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -8105,6 +8125,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -8170,17 +8191,17 @@ "version": "4.6.1", "license": "MIT", "dependencies": { + "@matdata/yasgui-chart-plugin": "^1.2.0", + "@matdata/yasgui-explain-plugin": "^1.0.0", + "@matdata/yasgui-geo-plugin": "^1.3.0", "@matdata/yasgui-graph-plugin": "^1.7.0", - "@matdata/yasgui-table-plugin": "^1.4.0", + "@matdata/yasgui-table-plugin": "^1.7.1", "@matdata/yasgui-utils": "^4.6.1", "@matdata/yasqe": "^4.6.1", "@matdata/yasr": "^4.6.1", "@tarekraafat/autocomplete.js": "^7.2.0", "autosuggest-highlight": "^3.1.1", - "blueimp-md5": "^2.12.0", - "choices.js": "^9.0.1", "dompurify": "^3.4.17", - "es6-object-assign": "^1.1.0", "jsuri": "^1.3.1", "lodash-es": "^4.17.15", "n3": "^1.3.5", @@ -8188,7 +8209,6 @@ }, "devDependencies": { "@types/autosuggest-highlight": "^3.1.0", - "@types/blueimp-md5": "^2.7.0", "@types/jsuri": "^1.3.30", "@types/lodash-es": "^4.17.3", "@types/node": "^22.5.4" @@ -8225,7 +8245,7 @@ "@matdata/yasgui-utils": "^4.6.1", "leaflet": "^1.9.4", "lodash-es": "^4.17.15", - "query-string": "^9.5.1" + "query-string": "^10.0.0" }, "devDependencies": { "@types/leaflet": "^1.9.21", @@ -8263,24 +8283,17 @@ "@codemirror/legacy-modes": "^6.5.3", "@codemirror/state": "^6.7.2", "@codemirror/view": "^6.43.10", - "@fortawesome/free-solid-svg-icons": "^7.1.0", "@json2csv/plainjs": "^7.0.4", "@lezer/highlight": "^1.2.3", "@matdata/yasgui-utils": "^4.6.1", "@matdata/yasqe": "^4.6.1", "codemirror-lang-turtle": "^0.0.2", - "colors": "^1.4.0", - "column-resizer": "^1.4.0", - "datatables.net": "^2.0.5", - "datatables.net-dt": "^2.0.5", "dompurify": "^3.4.17", - "jquery": "^3.7.1", "lodash-es": "^4.17.15", "n3": "^1.3.5", "papaparse": "^5.3.1" }, "devDependencies": { - "@types/jquery": "^3.5.32", "@types/lodash-es": "^4.17.3", "@types/n3": "^1.1.5", "@types/node": "^22.5.4", diff --git a/package.json b/package.json index 625e6f34..9f1e5a91 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,16 @@ "prepare": "husky", "build": "better-npm-run build", "dev": "better-npm-run dev", - "util:lint": "cross-env ESLINT_STRICT=true eslint --ext .tsx --ext .ts \"packages/*/src/**/*.{ts,tsx}\" \"packages/*/test/**/*.{ts,tsx}\" \"packages/*/grammar/**/*.{ts,tsx}\"", + "util:lint": "cross-env ESLINT_STRICT=true eslint --ext .tsx --ext .ts \"packages/*/src/**/*.{ts,tsx}\" \"test/**/*.ts\" \"packages/*/grammar/**/*.{ts,tsx}\"", "util:validateTs": "tsc -p ./tsconfig-validate.json --noEmit", "util:fetchLov": "node fetchLovData.js", + "util:bundleSize": "node scripts/bundle-size.mjs", "util:prettify": "prettier --parser typescript --write \"packages/*/src/**/*.{ts,tsx}\" && prettier --parser css --write \"packages/*/src/**/*.{css,scss}\"", "pretest": "node --input-type=module -e \"import fs from 'fs'; if (!fs.existsSync('./build') || fs.readdirSync('./build').length === 0) { console.log('Run \\\"npm run build\\\" before running a test'); process.exit(1); }\"", "test:dev": "cross-env TEST_ON_DEV_BUILD=1 npm run test", "test": "npm run puppeteer-test && npm run unit-test", "test-live": "cross-env TEST_ON_DEV_BUILD=1 npm run test", - "unit-test": "tsc -p ./tsconfig-test.json && node ./test/fix-esm-imports.mjs && node ./node_modules/mocha/bin/mocha.js \"build/test/**/*-test.js\" --require source-map-support/register || exit 0", + "unit-test": "tsc -p ./tsconfig-test.json && node ./test/fix-esm-imports.mjs && node ./node_modules/mocha/bin/mocha.js \"build/test/**/*-test.js\" --require source-map-support/register", "puppeteer-test": "tsc -p ./tsconfig-test.json && node ./test/fix-esm-imports.mjs && node ./node_modules/mocha/bin/mocha.js --timeout 30000 ./build/test/test/run.js --require source-map-support/register", "prerelease": "npm run build", "release": "changeset publish" @@ -57,20 +58,18 @@ "@matdata/yasgui-explain-plugin": "^1.0.0", "@matdata/yasgui-geo-plugin": "^1.3.0", "@matdata/yasgui-graph-plugin": "^1.7.0", - "@matdata/yasgui-table-plugin": "^1.7.1", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "autoprefixer": "^10.4.21", - "better-npm-run": "^0.1.1", - "chai": "^6.2.0", - "fs-extra": "^11.3.2", - "mocha": "^12.0.3" + "@matdata/yasgui-table-plugin": "^1.7.1" }, "devDependencies": { "@changesets/cli": "^2.29.7", "@types/chai": "^5.2.3", "@types/fs-extra": "^11.0.4", "@types/mocha": "^10.0.10", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "autoprefixer": "^10.4.21", + "better-npm-run": "^0.1.1", + "chai": "^6.2.0", "cross-env": "^7.0.3", "esbuild": "^0.28.2", "esbuild-sass-plugin": "^3.3.1", @@ -78,8 +77,10 @@ "eslint-config-prettier": "^6.10.0", "eslint-plugin-jest": "^23.8.1", "eslint-plugin-lodash": "^7.1.0", + "fs-extra": "^11.3.2", "husky": "^9.1.7", "lint-staged": "^16.2.6", + "mocha": "^12.0.3", "postcss": "^8.4.49", "prettier": "^3.6.2", "puppeteer": "^25.16.0", diff --git a/packages/utils/src/Storage.ts b/packages/utils/src/Storage.ts index 29fff422..04485c06 100644 --- a/packages/utils/src/Storage.ts +++ b/packages/utils/src/Storage.ts @@ -9,12 +9,21 @@ export interface ItemWrapper { interface QuotaExceededError extends Error { quotaExceeded?: boolean; } + +/** Sweeping expired keys parses every value in localStorage, so do it at most this often */ +const EXPIRY_SWEEP_INTERVAL_MS = 60 * 1000; +let lastExpirySweep = 0; + export default class Storage { private namespace: string; constructor(namespace: string) { this.namespace = namespace; } - public set(key: string | undefined, val: any, expInSeconds: number, onQuotaExceeded: (e: any) => void) { + /** + * Store a value. When the storage quota is exceeded and `onQuotaExceeded` is given, that callback handles it + * and nothing is thrown; other errors are thrown. + */ + public set(key: string | undefined, val: any, expInSeconds: number, onQuotaExceeded?: (e: any) => void) { if (!store.enabled) return; //this is probably in private mode. Don't run, as we might get Js errors this.removeExpiredKeys(); if (key && val !== undefined) { @@ -28,15 +37,12 @@ export default class Storage { time: new Date().getTime() / 1000, }); } catch (e) { - if (e instanceof Error) { - const quotaExceededError = e as QuotaExceededError; - quotaExceededError.quotaExceeded = isQuotaExceeded(e); - if (quotaExceededError.quotaExceeded && onQuotaExceeded) { - onQuotaExceeded(e); - } else { - throw quotaExceededError; - } + const quotaExceeded = isQuotaExceeded(e); + if (quotaExceeded && onQuotaExceeded) { + onQuotaExceeded(e); + return; } + if (e instanceof Error) (e as QuotaExceededError).quotaExceeded = quotaExceeded; throw e; } } @@ -45,56 +51,51 @@ export default class Storage { if (!store.enabled) return; //this is probably in private mode. Don't run, as we might get Js errors if (key) store.remove(key); } - removeExpiredKeys() { + /** + * Remove the keys whose expiry time has passed. Runs at most once a minute unless `force` is set, since it + * reads and parses everything in localStorage. + */ + removeExpiredKeys(force = false) { if (!store.enabled) return; + const now = Date.now(); + if (!force && now - lastExpirySweep < EXPIRY_SWEEP_INTERVAL_MS) return; + lastExpirySweep = now; store.each((value: ItemWrapper, key: string) => { - if (value && value.exp && new Date().getTime() / 1000 - value.time > value.exp) { + if (value && value.exp && now / 1000 - value.time > value.exp) { this.remove(key); } }); } + /** Remove every key stored in this namespace */ removeAll() { - if (!store.enabled) return; //this is probably in private mode. Don't run, as we might get Js errors + this.removeNamespace(); } removeNamespace() { + if (!store.enabled) return; + const keys: string[] = []; store.each((value: ItemWrapper, key: string) => { - if (value.namespace && value.namespace === this.namespace) this.remove(key); + if (value && value.namespace && value.namespace === this.namespace) keys.push(key); }); + keys.forEach((key) => this.remove(key)); } get(key?: string): V | undefined { if (!store.enabled) return; //this is probably in private mode. Don't run, as we might get Js errors if (!key) return; this.removeExpiredKeys(); - if (key) { - var info: ItemWrapper = store.get(key); - if (!info) { - return; - } - return info.val; - } else { + const info: ItemWrapper | undefined = store.get(key); + if (!info) return; + if (info.exp && Date.now() / 1000 - info.time > info.exp) { + this.remove(key); return; } + return info.val; } } -function isQuotaExceeded(e: any) { - var quotaExceeded = false; - if (e) { - if (e.code) { - switch (e.code) { - case 22: - quotaExceeded = true; - break; - case 1014: - // Firefox - if (e.name === "NS_ERROR_DOM_QUOTA_REACHED") { - quotaExceeded = true; - } - break; - } - } else if (e.number === -2147024882) { - // Internet Explorer 8 - quotaExceeded = true; - } - } - return quotaExceeded; + +export function isQuotaExceeded(e: any): boolean { + if (!e) return false; + // Current browsers + if (e.name === "QuotaExceededError" || e.name === "NS_ERROR_DOM_QUOTA_REACHED") return true; + // Older WebKit/Blink (22) and Firefox (1014) + return e.code === 22 || e.code === 1014; } diff --git a/packages/yasgui/package.json b/packages/yasgui/package.json index 2b110a8e..02c2d2c1 100644 --- a/packages/yasgui/package.json +++ b/packages/yasgui/package.json @@ -35,25 +35,24 @@ "directory": "packages/yasgui" }, "dependencies": { - "@tarekraafat/autocomplete.js": "^7.2.0", + "@matdata/yasgui-chart-plugin": "^1.2.0", + "@matdata/yasgui-explain-plugin": "^1.0.0", + "@matdata/yasgui-geo-plugin": "^1.3.0", + "@matdata/yasgui-graph-plugin": "^1.7.0", + "@matdata/yasgui-table-plugin": "^1.7.1", "@matdata/yasgui-utils": "^4.6.1", "@matdata/yasqe": "^4.6.1", "@matdata/yasr": "^4.6.1", + "@tarekraafat/autocomplete.js": "^7.2.0", "autosuggest-highlight": "^3.1.1", - "blueimp-md5": "^2.12.0", - "choices.js": "^9.0.1", "dompurify": "^3.4.17", - "es6-object-assign": "^1.1.0", "jsuri": "^1.3.1", "lodash-es": "^4.17.15", "n3": "^1.3.5", - "sortablejs": "^1.10.2", - "@matdata/yasgui-graph-plugin": "^1.7.0", - "@matdata/yasgui-table-plugin": "^1.4.0" + "sortablejs": "^1.10.2" }, "devDependencies": { "@types/autosuggest-highlight": "^3.1.0", - "@types/blueimp-md5": "^2.7.0", "@types/jsuri": "^1.3.30", "@types/lodash-es": "^4.17.3", "@types/node": "^22.5.4" diff --git a/packages/yasgui/src/CommandPalette.ts b/packages/yasgui/src/CommandPalette.ts index e1573115..8b932cc9 100644 --- a/packages/yasgui/src/CommandPalette.ts +++ b/packages/yasgui/src/CommandPalette.ts @@ -1,3 +1,4 @@ +import { showToast } from "./dialogs"; import { addClass } from "@matdata/yasgui-utils"; import type { Yasgui } from "./"; import { icon, IconName, modKeyLabel } from "./icons"; @@ -184,7 +185,7 @@ export default class CommandPalette { this.close(false); Promise.resolve() .then(() => item.run()) - .catch((error) => window.alert(error?.message || String(error))); + .catch((error) => showToast(error?.message || String(error), { host: this.yasgui.rootEl, kind: "error" })); } /** List the saved queries of every workspace, at most once a minute */ diff --git a/packages/yasgui/src/ConfigExportImport.ts b/packages/yasgui/src/ConfigExportImport.ts index 6e879165..8ccaa988 100644 --- a/packages/yasgui/src/ConfigExportImport.ts +++ b/packages/yasgui/src/ConfigExportImport.ts @@ -3,7 +3,8 @@ * Handles serialization and deserialization of Matgui configuration to/from RDF Turtle format */ -import { PersistedJson } from "./PersistentConfig"; +import * as N3 from "n3"; +import type { PersistedJson } from "./PersistentConfig"; // Matgui Configuration Ontology export const YASGUI_NS = "https://yasgui.matdata.eu/ontology#"; @@ -178,26 +179,8 @@ function escapeTurtleString(str: string): string { } /** - * Unescape Turtle string - */ -function unescapeTurtleString(str: string): string { - return str - .replace(/\\n/g, "\n") - .replace(/\\r/g, "\r") - .replace(/\\t/g, "\t") - .replace(/\\"/g, '"') - .replace(/\\\\/g, "\\"); -} - -/** - * Parse Turtle format back to configuration - * This is a simplified parser focused on the structure we generate. - * - * Note: This parser is designed to handle the specific Turtle format - * produced by serializeToTurtle(). For production use with arbitrary - * Turtle input, consider using a robust RDF library like N3.js. - * The current implementation uses regex patterns that work well for - * our serialization output but may not handle all valid Turtle syntax. + * Parse Turtle back to configuration, with a real Turtle parser: the export of any version, or a file edited by + * hand, is read by its triples rather than by the layout of the text. */ export function parseFromTurtle(turtle: string): Partial { const config: Partial = { @@ -207,124 +190,128 @@ export function parseFromTurtle(turtle: string): Partial { customEndpointButtons: [], }; + let quads: N3.Quad[]; try { - // Extract endpoint history - const endpointHistoryMatch = turtle.match(/yasgui:endpointHistory\s*\(([\s\S]*?)\)/); - if (endpointHistoryMatch) { - const endpoints = endpointHistoryMatch[1].match(/"([^"]*)"/g); - if (endpoints) { - config.endpointHistory = endpoints.map((e) => unescapeTurtleString(e.slice(1, -1))); - } - } + quads = new N3.Parser().parse(turtle); + } catch (error) { + console.error("Error parsing Turtle configuration:", error); + throw new Error( + `Failed to parse configuration. Please check the format${error instanceof Error ? ` (${error.message})` : ""}.`, + ); + } - // Extract active tab - const activeTabMatch = turtle.match(/yasgui:activeTab\s+"([^"]*)"/); - if (activeTabMatch) { - config.active = unescapeTurtleString(activeTabMatch[1]); + const bySubject = new Map(); + for (const quad of quads) { + const key = termKey(quad.subject); + const list = bySubject.get(key); + if (list) list.push(quad); + else bySubject.set(key, [quad]); + } + const objects = (subject: N3.Term, predicate: string): N3.Term[] => + (bySubject.get(termKey(subject)) || []).filter((q) => q.predicate.value === predicate).map((q) => q.object); + const literal = (subject: N3.Term, predicate: string): string | undefined => { + const object = objects(subject, predicate)[0]; + return object && object.termType === "Literal" ? object.value : undefined; + }; + const list = (head: N3.Term | undefined): N3.Term[] => { + const items: N3.Term[] = []; + const seen = new Set(); + let node = head; + while (node && node.value !== RDF_NS + "nil" && !seen.has(termKey(node))) { + seen.add(termKey(node)); + const first = objects(node, RDF_NS + "first")[0]; + if (first) items.push(first); + node = objects(node, RDF_NS + "rest")[0]; } + return items; + }; - // Extract prefixes - const prefixesMatch = turtle.match(/yasgui:prefixesValue\s+"""([\s\S]*?)"""/); - if (prefixesMatch) { - config.prefixes = unescapeTurtleString(prefixesMatch[1]); - } + const root = quads.find( + (q) => q.predicate.value === RDF_NS + "type" && q.object.value === YASGUI_NS + "Configuration", + )?.subject; + if (!root) { + throw new Error("Failed to parse configuration: no yasgui:Configuration found."); + } - // Extract auto capture enabled - const autoCaptureMatch = turtle.match(/yasgui:autoCaptureEnabled\s+"([^"]*)"/); - if (autoCaptureMatch) { - config.autoCaptureEnabled = autoCaptureMatch[1] === "true"; - } + config.endpointHistory = list(objects(root, YASGUI_NS + "endpointHistory")[0]) + .filter((term) => term.termType === "Literal") + .map((term) => term.value); - // Extract theme - const themeMatch = turtle.match(/yasgui:theme\s+"([^"]*)"/); - if (themeMatch && (themeMatch[1] === "light" || themeMatch[1] === "dark")) { - config.theme = themeMatch[1] as "light" | "dark"; - } + const active = literal(root, YASGUI_NS + "activeTab"); + if (active !== undefined) config.active = active; - // Extract orientation - const orientationMatch = turtle.match(/yasgui:orientation\s+"([^"]*)"/); - if (orientationMatch && (orientationMatch[1] === "vertical" || orientationMatch[1] === "horizontal")) { - config.orientation = orientationMatch[1] as "vertical" | "horizontal"; - } + const prefixes = literal(root, YASGUI_NS + "prefixesValue"); + if (prefixes !== undefined) config.prefixes = prefixes; - // Extract custom endpoint buttons - const buttonPattern = - /yasgui:customEndpointButton\s+\[([\s\S]*?)rdfs:label\s+"([^"]*)"\s*;\s*sd:endpoint\s+"([^"]*)"/g; - let buttonMatch; - while ((buttonMatch = buttonPattern.exec(turtle)) !== null) { - config.customEndpointButtons!.push({ - label: unescapeTurtleString(buttonMatch[2]), - endpoint: unescapeTurtleString(buttonMatch[3]), - }); - } + const autoCapture = literal(root, YASGUI_NS + "autoCaptureEnabled"); + if (autoCapture !== undefined) config.autoCaptureEnabled = autoCapture === "true"; - // Extract tabs - simplified parsing - const tabPattern = - /dcterms:identifier\s+"([^"]*)"\s*;\s*rdfs:label\s+"([^"]*)"\s*;[\s\S]*?(?=dcterms:identifier|yasgui:customEndpointButton|\]\.|\]\s*,\s*\[)/g; - const tabBlocks = turtle.match(/yasgui:tab\s+\[([\s\S]*)\]\s*\./); - - if (tabBlocks) { - const tabsContent = tabBlocks[1]; - const tabIdMatches = [...tabsContent.matchAll(/dcterms:identifier\s+"([^"]*)"/g)]; - const tabNameMatches = [...tabsContent.matchAll(/rdfs:label\s+"([^"]*)"/g)]; - const queryMatches = [...tabsContent.matchAll(/sp:text\s+"""([\s\S]*?)"""/g)]; - const endpointMatches = [...tabsContent.matchAll(/sd:endpoint\s+"([^"]*)"/g)]; - const methodMatches = [...tabsContent.matchAll(/yasgui:requestMethod\s+"([^"]*)"/g)]; - - // Extract tab-level orientation - const tabOrientationMatches = [...tabsContent.matchAll(/yasgui:orientation\s+"([^"]*)"/g)]; - - tabIdMatches.forEach((match, index) => { - const tabId = unescapeTurtleString(match[1]); - const tabName = tabNameMatches[index] ? unescapeTurtleString(tabNameMatches[index][1]) : "Query"; - const query = queryMatches[index] ? unescapeTurtleString(queryMatches[index][1]) : ""; - const endpoint = endpointMatches[index] ? unescapeTurtleString(endpointMatches[index][1]) : ""; - const method = methodMatches[index] ? unescapeTurtleString(methodMatches[index][1]) : "POST"; - const orientation = tabOrientationMatches[index] - ? (unescapeTurtleString(tabOrientationMatches[index][1]) as "vertical" | "horizontal") - : undefined; - - config.tabs!.push(tabId); - config.tabConfig![tabId] = { - id: tabId, - name: tabName, - yasqe: { - value: query, - }, - requestConfig: { - queryArgument: undefined, - endpoint: endpoint, - method: method as "GET" | "POST", - acceptHeaderGraph: "text/turtle", - acceptHeaderSelect: "application/sparql-results+json", - acceptHeaderUpdate: "application/sparql-results+json", - namedGraphs: [], - defaultGraphs: [], - args: [], - headers: {}, - withCredentials: false, - adjustQueryBeforeRequest: false, - basicAuth: undefined, - bearerAuth: undefined, - apiKeyAuth: undefined, - oauth2Auth: undefined, - }, - yasr: { - settings: {}, - response: undefined, - }, - orientation: orientation, - }; - }); - } - } catch (error) { - console.error("Error parsing Turtle configuration:", error); - throw new Error("Failed to parse configuration. Please check the format."); + const theme = literal(root, YASGUI_NS + "theme"); + if (theme === "light" || theme === "dark") config.theme = theme; + + const orientation = literal(root, YASGUI_NS + "orientation"); + if (orientation === "vertical" || orientation === "horizontal") config.orientation = orientation; + + for (const button of objects(root, YASGUI_NS + "customEndpointButton")) { + const label = literal(button, RDFS_NS + "label"); + const endpoint = literal(button, SD_NS + "endpoint"); + if (label !== undefined && endpoint !== undefined) config.customEndpointButtons!.push({ label, endpoint }); } + // Tabs are blank nodes without an order of their own: keep the order in which they appear in the file + objects(root, YASGUI_NS + "tab").forEach((tab, index) => { + const tabId = literal(tab, DCTERMS_NS + "identifier") || `imported-${index + 1}`; + if (config.tabConfig![tabId]) return; + const tabOrientation = literal(tab, YASGUI_NS + "orientation"); + const acceptHeader = (predicate: string) => { + const header = objects(tab, predicate)[0]; + return header ? literal(header, HTTP_NS + "headerValue") : undefined; + }; + const method = literal(tab, YASGUI_NS + "requestMethod"); + const editorHeight = literal(tab, SCHEMA_NS + "height"); + const selectedPlugin = literal(tab, YASGUI_NS + "selectedPlugin"); + + config.tabs!.push(tabId); + config.tabConfig![tabId] = { + id: tabId, + name: literal(tab, RDFS_NS + "label") || "Query", + yasqe: { + value: literal(tab, SP_NS + "text") || "", + ...(editorHeight ? { editorHeight } : {}), + }, + requestConfig: { + queryArgument: undefined, + endpoint: literal(tab, SD_NS + "endpoint") || "", + method: method === "GET" ? "GET" : "POST", + acceptHeaderGraph: acceptHeader(YASGUI_NS + "acceptHeaderGraph") || "text/turtle", + acceptHeaderSelect: acceptHeader(YASGUI_NS + "acceptHeaderSelect") || "application/sparql-results+json", + acceptHeaderUpdate: "application/sparql-results+json", + namedGraphs: [], + defaultGraphs: [], + args: [], + headers: {}, + withCredentials: false, + adjustQueryBeforeRequest: false, + basicAuth: undefined, + bearerAuth: undefined, + apiKeyAuth: undefined, + oauth2Auth: undefined, + }, + yasr: { + settings: selectedPlugin ? { selectedPlugin } : {}, + response: undefined, + }, + orientation: tabOrientation === "vertical" || tabOrientation === "horizontal" ? tabOrientation : undefined, + }; + }); + return config; } +function termKey(term: N3.Term): string { + return `${term.termType}:${term.value}`; +} + /** * Download configuration as a file */ diff --git a/packages/yasgui/src/OAuth2Utils.ts b/packages/yasgui/src/OAuth2Utils.ts index e7e6bf92..167a0a73 100644 --- a/packages/yasgui/src/OAuth2Utils.ts +++ b/packages/yasgui/src/OAuth2Utils.ts @@ -52,6 +52,15 @@ export interface OAuth2TokenResponse { token_type: string; } +/** Origin of a URL, or of the current page for a relative one */ +export function originOf(url: string): string { + try { + return new URL(url, window.location.href).origin; + } catch { + return window.location.origin; + } +} + /** * Check if a token has expired */ @@ -76,9 +85,8 @@ export async function startOAuth2Flow(config: OAuth2Config): Promise { clearInterval(checkInterval); window.removeEventListener("message", messageHandler); - // Clean up sessionStorage - sessionStorage.removeItem(`oauth2_code_verifier_${flowId}`); - sessionStorage.removeItem(`oauth2_state_${flowId}`); }; const checkInterval = setInterval(() => { @@ -169,17 +174,23 @@ export async function startOAuth2Flow(config: OAuth2Config): Promise { - // Validate origin if needed (should match redirect URI origin) + // Only the redirect page, on the origin of the redirect URI, may report the result + if (event.origin !== redirectOrigin) return; if (event.data && event.data.type === "oauth2_callback" && !flowCompleted) { flowCompleted = true; cleanup(); diff --git a/packages/yasgui/src/PersistentConfig.ts b/packages/yasgui/src/PersistentConfig.ts index d4b4bdc2..3f68ac23 100644 --- a/packages/yasgui/src/PersistentConfig.ts +++ b/packages/yasgui/src/PersistentConfig.ts @@ -1,3 +1,4 @@ +import { showToast } from "./dialogs"; import { Storage as YStorage } from "@matdata/yasgui-utils"; import Yasgui, { EndpointButton, EndpointConfig } from "./"; import * as Tab from "./Tab"; @@ -141,6 +142,35 @@ export default class PersistentConfig { this.handleLocalStorageQuotaFull, ); } + + /** + * The browser storage is full. Query results kept with the tabs are by far the largest part, so save the + * configuration without them; if even that doesn't fit, keep working from memory and tell the user, without + * deleting anything they saved. + */ + private handleLocalStorageQuotaFull = (_e: any) => { + const tabConfig: PersistedJson["tabConfig"] = {}; + for (const id in this.persistedJson.tabConfig) { + const tab = this.persistedJson.tabConfig[id]; + tabConfig[id] = tab && tab.yasr ? { ...tab, yasr: { ...tab.yasr, response: undefined } } : tab; + } + const withoutResponses: PersistedJson = { ...this.persistedJson, lastClosedTab: undefined, tabConfig }; + let saved = true; + this.storage.set(this.storageId, withoutResponses, this.yasgui.config.persistencyExpire, () => (saved = false)); + if (saved) { + console.warn("Local storage is full: saved the configuration without the query results of the tabs"); + return; + } + console.warn("Local storage is full: the configuration could not be saved"); + if (!this.warnedStorageFull) { + this.warnedStorageFull = true; + showToast( + "Your browser's storage for this site is full, so changes to tabs and settings are not being saved. Close tabs you don't need, or export your configuration from the settings.", + { host: this.yasgui.rootEl, kind: "error", duration: 0 }, + ); + } + }; + private warnedStorageFull = false; private fromStorage(): PersistedJson { const stored = this.storage.get(this.storageId); this.hadStoredConfig = !!stored; @@ -154,11 +184,6 @@ export default class PersistentConfig { return this.persistedJson; } - private handleLocalStorageQuotaFull(_e: any) { - console.warn("Localstorage quota exceeded. Clearing all Matgui configurations"); - PersistentConfig.clear(); - } - /** False on a first visit, before anything was stored */ public hasStoredConfig(): boolean { return this.hadStoredConfig; diff --git a/packages/yasgui/src/Tab.ts b/packages/yasgui/src/Tab.ts index 5f91f898..e3e071fe 100644 --- a/packages/yasgui/src/Tab.ts +++ b/packages/yasgui/src/Tab.ts @@ -1,3 +1,4 @@ +import { showAlert, showChoice } from "./dialogs"; import { EventEmitter } from "events"; import { addClass, removeClass, getAsValue } from "@matdata/yasgui-utils"; import { TabListEl } from "./TabElements"; @@ -241,7 +242,7 @@ export class Tab extends EventEmitter { const workspace = this.yasgui.persistentConfig.getWorkspace(meta.workspaceId); if (!workspace) { - window.alert("Selected workspace no longer exists"); + void showAlert("Selected workspace no longer exists", { host: this.yasgui.rootEl }); return; } @@ -263,35 +264,38 @@ export class Tab extends EventEmitter { await backend.writeQuery(queryId, this.getQueryTextForSave(), { expectedVersionTag }); } catch (e) { const err = asWorkspaceBackendError(e); - if (err.code === "CONFLICT") { - if (meta.backendType === "git") { - // Best-effort self-heal: some providers use a file sha for optimistic concurrency. - // If our stored version tag is stale/incorrect but the remote content is unchanged, - // refresh the tag and retry once. - try { - const latest = await backend.readQuery(queryId); - const latestHash = hashQueryText(latest.queryText); - if (meta.lastSavedTextHash && meta.lastSavedTextHash === latestHash && latest.versionTag) { - await backend.writeQuery(queryId, this.getQueryTextForSave(), { expectedVersionTag: latest.versionTag }); - } else { - window.alert( - "Save conflict. Resolve the conflict externally (e.g., pull/rebase/merge) and then try saving again.", - ); - return; - } - } catch { - window.alert( - "Save conflict. Resolve the conflict externally (e.g., pull/rebase/merge) and then try saving again.", - ); - return; - } - } else { - window.alert("Save conflict. Refresh the query and try again."); + if (err.code !== "CONFLICT") { + void showAlert(err.message, { host: this.yasgui.rootEl, title: "Could not save the query" }); + return; + } + if (meta.backendType !== "git") { + void showAlert("Someone else changed this query since you opened it. Refresh the query and try again.", { + host: this.yasgui.rootEl, + title: "Save conflict", + }); + return; + } + // Best-effort self-heal: some providers use a file sha for optimistic concurrency. + // If our stored version tag is stale/incorrect but the remote content is unchanged, + // refresh the tag and retry once. + const gitConflict = () => + showAlert( + "The file changed in the repository since you opened it. Resolve the conflict there (pull, rebase or merge), then save again.", + { host: this.yasgui.rootEl, title: "Save conflict" }, + ); + try { + const latest = await backend.readQuery(queryId); + const latestHash = hashQueryText(latest.queryText); + if (!(meta.lastSavedTextHash && meta.lastSavedTextHash === latestHash && latest.versionTag)) { + void gitConflict(); return; } + await backend.writeQuery(queryId, this.getQueryTextForSave(), { expectedVersionTag: latest.versionTag }); + // Saved on the retry: carry on as after a normal save + } catch { + void gitConflict(); + return; } - window.alert(err.message); - return; } const read = await backend.readQuery(queryId); @@ -309,7 +313,9 @@ export class Tab extends EventEmitter { public async saveAsManagedQuery(): Promise { if (getWritableWorkspaces(this.yasgui.persistentConfig.getWorkspaces()).length === 0) { - window.alert("There is no workspace to save to. Read-only workspaces cannot be saved to."); + void showAlert("There is no workspace to save to. Read-only workspaces cannot be saved to.", { + host: this.yasgui.rootEl, + }); return; } @@ -343,11 +349,11 @@ export class Tab extends EventEmitter { const workspace = this.yasgui.persistentConfig.getWorkspace(result.workspaceId); if (!workspace) { - window.alert("Selected workspace no longer exists"); + void showAlert("Selected workspace no longer exists", { host: this.yasgui.rootEl }); return; } if (isWorkspaceReadOnly(workspace)) { - window.alert("This workspace is read-only"); + void showAlert("This workspace is read-only", { host: this.yasgui.rootEl }); return; } @@ -389,7 +395,7 @@ export class Tab extends EventEmitter { } catch (e) { modal.notifySaveComplete(); const err = asWorkspaceBackendError(e); - window.alert(err.message); + void showAlert(err.message, { host: this.yasgui.rootEl }); } } @@ -547,19 +553,26 @@ export class Tab extends EventEmitter { public close() { if (this.isManagedQueryTab() && this.hasUnsavedManagedChanges()) { - const wantsSave = window.confirm("This managed query has unsaved changes. Save before closing?"); - if (wantsSave) { - void this.saveManagedQueryOrSaveAsManagedQuery().then(() => { - // Only close if we actually saved (metadata updated so no longer dirty) - if (!this.hasUnsavedManagedChanges()) { - this.closeNow(); - } - }); - return; - } - - const discard = window.confirm("Discard changes and close the tab?"); - if (!discard) return; + void showChoice( + "This saved query has unsaved changes.", + [ + { label: "Cancel", value: "cancel" }, + { label: "Discard changes", value: "discard", kind: "danger" }, + { label: "Save and close", value: "save", kind: "primary" }, + ], + "cancel", + { host: this.yasgui.rootEl, title: `Close “${this.getName()}”?` }, + ).then((choice) => { + if (choice === "discard") { + this.closeNow(); + } else if (choice === "save") { + return this.saveManagedQueryOrSaveAsManagedQuery().then(() => { + // Only close if we actually saved (metadata updated so no longer dirty) + if (!this.hasUnsavedManagedChanges()) this.closeNow(); + }); + } + }); + return; } this.closeNow(); @@ -1142,7 +1155,7 @@ export class Tab extends EventEmitter { const workspace = this.yasgui.persistentConfig.getWorkspace(meta.workspaceId); if (!workspace) { - window.alert("Selected workspace no longer exists"); + void showAlert("Selected workspace no longer exists", { host: this.yasgui.rootEl }); return; } @@ -1158,7 +1171,7 @@ export class Tab extends EventEmitter { const queryId = this.getManagedQueryIdFromMetadata(meta); if (!queryId) return; if (!backend.renameQuery) { - window.alert("This workspace does not support renaming queries"); + void showAlert("This workspace does not support renaming queries", { host: this.yasgui.rootEl }); return; } @@ -1169,7 +1182,7 @@ export class Tab extends EventEmitter { this.yasgui.queryBrowser.invalidateAndRefresh(meta.workspaceId); } catch (e) { const err = asWorkspaceBackendError(e); - window.alert(err.message); + void showAlert(err.message, { host: this.yasgui.rootEl }); } finally { this.getTabListEl().setAsRenaming(false); } @@ -1204,7 +1217,9 @@ export class Tab extends EventEmitter { }); if (!backend.deleteQuery) { - window.alert("This workspace does not support deleting queries, so the old file could not be removed."); + void showAlert("This workspace does not support deleting queries, so the old file could not be removed.", { + host: this.yasgui.rootEl, + }); } else { await backend.deleteQuery(oldPath); } @@ -1224,7 +1239,7 @@ export class Tab extends EventEmitter { this.yasgui.queryBrowser.invalidateAndRefresh(meta.workspaceId); } catch (e) { const err = asWorkspaceBackendError(e); - window.alert(err.message); + void showAlert(err.message, { host: this.yasgui.rootEl }); } finally { this.getTabListEl().setAsRenaming(false); } @@ -1355,15 +1370,17 @@ export class Tab extends EventEmitter { } catch (error) { console.error("Failed to refresh OAuth 2.0 token:", error); // Token refresh failed, user needs to re-authenticate - alert( - "Your OAuth 2.0 session has expired and could not be refreshed. Please re-authenticate by clicking the Settings button (gear icon) and selecting the SPARQL Endpoints tab.", + void showAlert( + "Your OAuth 2.0 session for this endpoint has expired and could not be refreshed. Sign in again under Settings, Endpoints.", + { host: this.yasgui.rootEl, title: "Sign in again" }, ); return false; } } else { // No refresh token available, user needs to re-authenticate - alert( - "Your OAuth 2.0 session has expired. Please re-authenticate by clicking the Settings button (gear icon) and selecting the SPARQL Endpoints tab.", + void showAlert( + "Your OAuth 2.0 session for this endpoint has expired. Sign in again under Settings, Endpoints.", + { host: this.yasgui.rootEl, title: "Sign in again" }, ); return false; } diff --git a/packages/yasgui/src/TabContextMenu.ts b/packages/yasgui/src/TabContextMenu.ts index 432bcd2f..de828244 100644 --- a/packages/yasgui/src/TabContextMenu.ts +++ b/packages/yasgui/src/TabContextMenu.ts @@ -1,3 +1,4 @@ +import { showCopyValue } from "./dialogs"; import { addClass } from "@matdata/yasgui-utils"; import { default as Yasgui, getRandomId } from "./"; import Tab from "./Tab"; @@ -127,7 +128,7 @@ export default class TabContextMenu { this.saveAsRqFileEl.onclick = () => { if (!tab) return; - tab.downloadAsRqFile(); + tab.downloadAsRqFile().catch((error) => console.error("Failed to download the query", error)); this.closeConfigMenu(); }; @@ -150,7 +151,7 @@ export default class TabContextMenu { try { await navigator.clipboard.writeText(uri); } catch { - window.prompt("Copy this URI:", uri); + void showCopyValue("Copy the URI of this query", uri, { host: this.yasgui.rootEl }); } this.closeConfigMenu(); }; diff --git a/packages/yasgui/src/TabSettingsModal.ts b/packages/yasgui/src/TabSettingsModal.ts index 3f35ceb2..d5c9d3b0 100644 --- a/packages/yasgui/src/TabSettingsModal.ts +++ b/packages/yasgui/src/TabSettingsModal.ts @@ -1,3 +1,4 @@ +import { showAlert, showConfirm, showToast } from "./dialogs"; import { addClass, removeClass } from "@matdata/yasgui-utils"; import "./TabSettingsModal.scss"; import Tab from "./Tab"; @@ -473,10 +474,16 @@ export default class TabSettingsModal { onDeleteRequested: (workspaceId) => { const w = this.tab.yasgui.persistentConfig.getWorkspace(workspaceId); const name = w?.label || workspaceId; - if (confirm(`Delete workspace "${name}"? This only removes the local configuration.`)) { + void showConfirm("This only removes the workspace from this browser. Its queries stay where they are.", { + host: this.tab.yasgui.rootEl, + title: `Remove workspace “${name}”?`, + confirmLabel: "Remove", + danger: true, + }).then((confirmed) => { + if (!confirmed) return; this.tab.yasgui.persistentConfig.deleteWorkspace(workspaceId); form.render(); - } + }); }, }); @@ -1009,11 +1016,17 @@ export default class TabSettingsModal { deleteButton.textContent = "Delete"; addClass(deleteButton, "deleteEndpointButton"); deleteButton.onclick = () => { - if (confirm(`Delete endpoint "${config.endpoint}"?`)) { + void showConfirm(config.endpoint, { + host: this.tab.yasgui.rootEl, + title: "Delete this endpoint and its saved credentials?", + confirmLabel: "Delete", + danger: true, + }).then((confirmed) => { + if (!confirmed) return; this.tab.yasgui.persistentConfig.deleteEndpointConfig(config.endpoint); this.renderEndpointsList(container); this.tab.refreshEndpointButtons(); - } + }); }; actionsCell.appendChild(deleteButton); addClass(actionsCell, "centerCell"); @@ -1050,28 +1063,30 @@ export default class TabSettingsModal { const endpoint = endpointInput.value.trim(); if (!endpoint) { - alert("Please enter an endpoint URL."); + void showAlert("Please enter an endpoint URL.", { host: this.tab.yasgui.rootEl }); return; } // Validate URL format // Check for supported protocol first if (!/^https?:\/\//i.test(endpoint)) { - alert("Endpoint URL must start with http:// or https://"); + void showAlert("Endpoint URL must start with http:// or https://", { host: this.tab.yasgui.rootEl }); return; } try { new URL(endpoint); } catch (e) { // Show the error message if available, otherwise a generic one - alert(e instanceof Error && e.message ? "Malformed URL: " + e.message : "Please enter a valid URL."); + void showAlert(e instanceof Error && e.message ? "Malformed URL: " + e.message : "Please enter a valid URL.", { + host: this.tab.yasgui.rootEl, + }); return; } // Check if endpoint already exists const existing = this.tab.yasgui.persistentConfig.getEndpointConfig(endpoint); if (existing) { - alert("This endpoint is already in the list."); + void showAlert("This endpoint is already in the list.", { host: this.tab.yasgui.rootEl }); return; } @@ -1442,7 +1457,7 @@ export default class TabSettingsModal { }); closeModalAndRefresh(); } else { - alert("Please enter both username and password."); + void showAlert("Please enter both username and password.", { host: this.tab.yasgui.rootEl }); } } else if (authType === "bearer") { const token = tokenInput.value.trim(); @@ -1456,7 +1471,7 @@ export default class TabSettingsModal { }); closeModalAndRefresh(); } else { - alert("Please enter a bearer token."); + void showAlert("Please enter a bearer token.", { host: this.tab.yasgui.rootEl }); } } else if (authType === "apiKey") { const headerName = headerNameInput.value.trim(); @@ -1472,7 +1487,7 @@ export default class TabSettingsModal { }); closeModalAndRefresh(); } else { - alert("Please enter both header name and API key."); + void showAlert("Please enter both header name and API key.", { host: this.tab.yasgui.rootEl }); } } else if (authType === "oauth2") { const clientId = clientIdInput.value.trim(); @@ -1482,7 +1497,9 @@ export default class TabSettingsModal { const scope = scopeInput.value.trim(); if (!clientId || !authorizationEndpoint || !tokenEndpoint) { - alert("Please enter Client ID, Authorization Endpoint, and Token Endpoint."); + void showAlert("Please enter Client ID, Authorization Endpoint, and Token Endpoint.", { + host: this.tab.yasgui.rootEl, + }); return; } @@ -1537,11 +1554,11 @@ export default class TabSettingsModal { }); closeModalAndRefresh(); - alert("OAuth 2.0 authentication successful!"); + showToast("Signed in with OAuth 2.0", { host: this.tab.yasgui.rootEl, kind: "success" }); }) .catch((error) => { console.error("OAuth 2.0 authentication failed:", error); - alert("OAuth 2.0 authentication failed: " + error.message); + void showAlert(error.message, { host: this.tab.yasgui.rootEl, title: "OAuth 2.0 sign-in failed" }); saveButton.disabled = false; saveButton.textContent = "Save & Authenticate"; }); @@ -2005,8 +2022,12 @@ export default class TabSettingsModal { const parsedConfig = ConfigExportImport.parseFromTurtle(turtleContent); // Confirm with user before importing - const confirmMsg = `This will replace your current configuration with ${parsedConfig.tabs?.length || 0} tab(s). Continue?`; - if (!confirm(confirmMsg)) { + const tabCount = parsedConfig.tabs?.length || 0; + const confirmed = await showConfirm( + `Your current tabs and settings are replaced by the ${tabCount} tab${tabCount === 1 ? "" : "s"} and settings of the file.`, + { host: this.tab.yasgui.rootEl, title: "Import this configuration?", confirmLabel: "Import" }, + ); + if (!confirmed) { return; } @@ -2152,7 +2173,7 @@ export default class TabSettingsModal { const clearStorageButton = document.createElement("button"); clearStorageButton.textContent = "Clear Persistent Storage"; addClass(clearStorageButton, "dangerButton"); - clearStorageButton.onclick = () => this.clearPersistentStorage(); + clearStorageButton.onclick = () => void this.clearPersistentStorage(); storageSection.appendChild(clearStorageButton); container.appendChild(storageSection); @@ -2179,16 +2200,10 @@ export default class TabSettingsModal { return linkContainer; } - private clearPersistentStorage() { - const confirmed = confirm( - "Are you sure you want to clear all persistent storage?\n\n" + - "This will delete:\n" + - "- All saved tabs and queries\n" + - "- Endpoint configurations and history\n" + - "- Authentication settings\n" + - "- Editor preferences\n" + - "- Prefixes\n\n" + - "This action cannot be undone and the page will reload.", + private async clearPersistentStorage() { + const confirmed = await showConfirm( + "This deletes all saved tabs and queries, endpoints and their credentials, endpoint history, editor preferences and prefixes from this browser, then reloads the page. It cannot be undone.", + { host: this.tab.yasgui.rootEl, title: "Clear all stored data?", confirmLabel: "Clear and reload", danger: true }, ); if (confirmed) { diff --git a/packages/yasgui/src/ai/AiSettingsForm.ts b/packages/yasgui/src/ai/AiSettingsForm.ts index c47656af..a492b979 100644 --- a/packages/yasgui/src/ai/AiSettingsForm.ts +++ b/packages/yasgui/src/ai/AiSettingsForm.ts @@ -1,3 +1,4 @@ +import { showConfirm } from "../dialogs"; /** * "AI" page of the settings: the AI services as cards (the site's, the user's own, and "No AI service") with the * default one picked, and a form to add or edit one of the user's own services: a provider, its base URL (filled in, @@ -193,7 +194,7 @@ export default class AiSettingsForm { const remove = el("button", "yasgui-aiSettings__linkButton danger", "Remove"); remove.type = "button"; remove.setAttribute("aria-label", `Remove ${serviceName}`); - remove.addEventListener("click", () => this.removeService(service)); + remove.addEventListener("click", () => void this.removeService(service)); const meta = [ service.model || config.defaultModel || "no model chosen", hostOf(baseUrl), @@ -290,8 +291,13 @@ export default class AiSettingsForm { this.render(); } - private removeService(service: AiServiceSettings) { - if (!confirm(`Remove the AI service “${this.serviceName(service)}” and its API key from this browser?`)) return; + private async removeService(service: AiServiceSettings) { + const confirmed = await showConfirm("Its API key is deleted from this browser too.", { + title: `Remove the AI service “${this.serviceName(service)}”?`, + confirmLabel: "Remove", + danger: true, + }); + if (!confirmed) return; const services = (this.settings().services || []).filter((candidate) => candidate.id !== service.id); const changes: Parameters[0] = { services: services.length ? services : undefined, diff --git a/packages/yasgui/src/ai/aiSettings.ts b/packages/yasgui/src/ai/aiSettings.ts index 78c8b978..25f029f7 100644 --- a/packages/yasgui/src/ai/aiSettings.ts +++ b/packages/yasgui/src/ai/aiSettings.ts @@ -57,7 +57,7 @@ function siteConnection(proxy: AiProxyConfig, index: number): AiConnection { label: proxy.label || hostOf(proxy.url), privacyUrl: proxy.privacyUrl, withCredentials: proxy.withCredentials, - getHeaders: headers ? async () => (typeof headers === "function" ? await headers() : headers) : undefined, + getHeaders: headers ? async () => (typeof headers === "function" ? headers() : headers) : undefined, }; } diff --git a/packages/yasgui/src/dialogs.scss b/packages/yasgui/src/dialogs.scss new file mode 100644 index 00000000..15a04b93 --- /dev/null +++ b/packages/yasgui/src/dialogs.scss @@ -0,0 +1,228 @@ +// In-app alert/confirm dialogs and toasts (dialogs.ts) + +// `.yasgui > div` lays out the tab panels as a column; these layers are not part of it +.yasgui > div.yasgui-dialog:not(.tabsList) { + flex: none; + flex-direction: row; + overflow: visible; + min-height: 0; +} + +.yasgui > div.yasgui-toasts:not(.tabsList) { + flex: none; + flex-direction: column; + overflow: visible; + min-height: 0; +} + +.yasgui-dialog { + position: fixed; + inset: 0; + // Above the settings modal, the What's new dialog and the command palette + z-index: 10100; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 96px 16px 16px; + box-sizing: border-box; + background: var(--yasgui-scrim, rgba(13, 20, 28, 0.32)); + font-family: var(--yasgui-font-family, inherit); + font-size: 13px; + color: var(--yasgui-ink, #1c2633); + + &__box { + width: 440px; + max-width: 100%; + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + box-sizing: border-box; + border-radius: 12px; + background: var(--yasgui-surface-raised, #ffffff); + box-shadow: var(--yasgui-shadow-overlay, 0 24px 48px rgba(13, 20, 28, 0.18), 0 0 0 1px rgba(13, 20, 28, 0.06)); + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + } + + &__message { + margin: 0; + line-height: 1.5; + color: var(--yasgui-ink-body, #3a4552); + white-space: pre-line; + overflow-wrap: anywhere; + } + + &__field { + width: 100%; + box-sizing: border-box; + padding: 7px 10px; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: var(--yasgui-radius-sm, 6px); + background: var(--yasgui-surface, #f1f4f8); + color: var(--yasgui-ink, #1c2633); + font-family: var(--yasgui-font-mono, monospace); + font-size: 12px; + } + + &__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; + } + + &__button { + min-height: 32px; + padding: 0 14px; + border: 1px solid var(--yasgui-line, #dfe5ec); + border-radius: var(--yasgui-radius-button, 8px); + background: var(--yasgui-surface-raised, #ffffff); + color: var(--yasgui-ink, #1c2633); + font: inherit; + font-weight: 500; + cursor: pointer; + + &:hover { + background: var(--yasgui-surface-sunken, #edf0f4); + } + + &:focus-visible { + outline: 2px solid var(--yasgui-focus, #044a8c); + outline-offset: 2px; + } + + &--primary { + border-color: transparent; + background: var(--yasgui-brand-fill, #0563bb); + color: var(--yasgui-on-brand, #ffffff); + + &:hover { + background: var(--yasgui-brand-fill-strong, #044a8c); + } + } + + &--danger { + border-color: transparent; + background: var(--yasgui-danger, #b42318); + color: #ffffff; + + &:hover { + filter: brightness(0.92); + background: var(--yasgui-danger, #b42318); + } + } + } +} + +.yasgui-toasts { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + z-index: 10110; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: max-content; + max-width: calc(100vw - 32px); + pointer-events: none; + font-family: var(--yasgui-font-family, inherit); + font-size: 13px; +} + +.yasgui-toast { + pointer-events: auto; + display: flex; + align-items: center; + gap: 10px; + max-width: 560px; + padding: 8px 8px 8px 12px; + border-radius: 10px; + background: var(--yasgui-surface-raised, #ffffff); + color: var(--yasgui-ink, #1c2633); + box-shadow: var(--yasgui-shadow-overlay, 0 16px 48px rgba(28, 38, 51, 0.22), 0 0 0 1px rgba(28, 38, 51, 0.06)); + animation: yasgui-toast-in 160ms ease-out; + + &__icon { + display: flex; + flex-shrink: 0; + color: var(--yasgui-brand, #0563bb); + } + + &--success &__icon { + color: var(--yasgui-success, #067647); + } + + &--warning &__icon { + color: var(--yasgui-highlight-ink, #6e4d00); + } + + &--error &__icon { + color: var(--yasgui-danger, #b42318); + } + + &__message { + line-height: 1.45; + overflow-wrap: anywhere; + } + + &__action, + &__close { + flex-shrink: 0; + border: none; + border-radius: var(--yasgui-radius-button, 8px); + background: transparent; + font: inherit; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--yasgui-focus, #044a8c); + outline-offset: 1px; + } + } + + &__action { + padding: 4px 10px; + color: var(--yasgui-brand, #0563bb); + font-weight: 600; + + &:hover { + background: var(--yasgui-brand-soft, #e3eefa); + } + } + + &__close { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + color: var(--yasgui-ink-muted, #56606c); + + &:hover { + background: var(--yasgui-surface-sunken, #edf0f4); + } + } +} + +@keyframes yasgui-toast-in { + from { + opacity: 0; + transform: translateY(8px); + } +} + +@media (prefers-reduced-motion: reduce) { + .yasgui-toast { + animation: none; + } +} diff --git a/packages/yasgui/src/dialogs.ts b/packages/yasgui/src/dialogs.ts new file mode 100644 index 00000000..36b9af8b --- /dev/null +++ b/packages/yasgui/src/dialogs.ts @@ -0,0 +1,287 @@ +/** + * In-app replacements for window.alert, window.confirm and window.prompt, plus toasts. + * + * Native dialogs block the page, can't follow the theme, and are suppressed in sandboxed iframes (where + * `confirm()` silently returns false). These render inside the Yasgui root, so they get the theme tokens. + */ +import { addClass } from "@matdata/yasgui-utils"; +import { icon } from "./icons"; +import "./dialogs.scss"; + +let defaultHost: HTMLElement | undefined; + +/** The Yasgui root that dialogs open in when a call doesn't name one (the last Yasgui instance created) */ +export function setDialogHost(host: HTMLElement) { + defaultHost = host; +} + +function resolveHost(host?: HTMLElement): HTMLElement { + if (host && host.isConnected) { + return (host.closest(".yasgui") as HTMLElement | null) || host; + } + if (defaultHost && defaultHost.isConnected) return defaultHost; + return (document.querySelector(".yasgui") as HTMLElement | null) || document.body; +} + +export interface DialogOptions { + /** Element inside the Yasgui instance the dialog belongs to (defaults to the last Yasgui instance) */ + host?: HTMLElement; + title?: string; +} + +export interface ConfirmOptions extends DialogOptions { + confirmLabel?: string; + cancelLabel?: string; + /** Style the confirm button as destructive */ + danger?: boolean; +} + +interface DialogButton { + label: string; + value: string; + kind?: "primary" | "danger" | "secondary"; +} + +let dialogCounter = 0; + +function openDialog( + message: string, + buttons: DialogButton[], + options: DialogOptions & { cancelValue: string; extra?: HTMLElement; focus?: HTMLElement }, +): Promise { + const host = resolveHost(options.host); + const previouslyFocused = document.activeElement as HTMLElement | null; + const id = `yasgui-dialog-${++dialogCounter}`; + + const overlay = document.createElement("div"); + addClass(overlay, "yasgui-dialog"); + + const dialog = document.createElement("div"); + addClass(dialog, "yasgui-dialog__box"); + dialog.setAttribute("role", buttons.length > 1 ? "alertdialog" : "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-describedby", `${id}-message`); + + if (options.title) { + const title = document.createElement("h2"); + addClass(title, "yasgui-dialog__title"); + title.id = `${id}-title`; + title.textContent = options.title; + dialog.setAttribute("aria-labelledby", title.id); + dialog.appendChild(title); + } + + const body = document.createElement("p"); + addClass(body, "yasgui-dialog__message"); + body.id = `${id}-message`; + body.textContent = message; + dialog.appendChild(body); + + if (options.extra) dialog.appendChild(options.extra); + + const actions = document.createElement("div"); + addClass(actions, "yasgui-dialog__actions"); + dialog.appendChild(actions); + overlay.appendChild(dialog); + + return new Promise((resolve) => { + let done = false; + const finish = (value: string) => { + if (done) return; + done = true; + document.removeEventListener("keydown", onKeydown, true); + overlay.remove(); + if (previouslyFocused && previouslyFocused.isConnected && typeof previouslyFocused.focus === "function") { + previouslyFocused.focus(); + } + resolve(value); + }; + + const buttonEls: HTMLButtonElement[] = []; + for (const button of buttons) { + const el = document.createElement("button"); + el.type = "button"; + addClass(el, "yasgui-dialog__button", `yasgui-dialog__button--${button.kind || "secondary"}`); + el.textContent = button.label; + el.addEventListener("click", () => finish(button.value)); + actions.appendChild(el); + buttonEls.push(el); + } + + const focusables = () => + Array.from( + dialog.querySelectorAll( + "button, input, textarea, select, a[href], [tabindex]:not([tabindex='-1'])", + ), + ); + + const onKeydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + finish(options.cancelValue); + } else if (event.key === "Tab") { + // Keep focus inside the dialog + const items = focusables(); + if (!items.length) return; + const first = items[0]; + const last = items[items.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + document.addEventListener("keydown", onKeydown, true); + overlay.addEventListener("mousedown", (event) => { + if (event.target === overlay) finish(options.cancelValue); + }); + + host.appendChild(overlay); + const primary = buttonEls.find((_el, i) => buttons[i].kind === "primary" || buttons[i].kind === "danger"); + (options.focus || primary || buttonEls[buttonEls.length - 1])?.focus(); + }); +} + +/** Replacement for window.alert */ +export function showAlert(message: string, options: DialogOptions = {}): Promise { + return openDialog(message, [{ label: "OK", value: "ok", kind: "primary" }], { + ...options, + cancelValue: "ok", + }).then(() => undefined); +} + +/** Replacement for window.confirm: resolves true when the user confirms */ +export function showConfirm(message: string, options: ConfirmOptions = {}): Promise { + return openDialog( + message, + [ + { label: options.cancelLabel || "Cancel", value: "cancel" }, + { label: options.confirmLabel || "OK", value: "confirm", kind: options.danger ? "danger" : "primary" }, + ], + { ...options, cancelValue: "cancel" }, + ).then((value) => value === "confirm"); +} + +/** + * A choice between more than two buttons, such as "Save", "Discard" and "Cancel". Resolves with the value of + * the button that was clicked, or `cancelValue` on Escape. + */ +export function showChoice( + message: string, + choices: Array<{ label: string; value: V; kind?: "primary" | "danger" | "secondary" }>, + cancelValue: V, + options: DialogOptions = {}, +): Promise { + return openDialog(message, choices, { ...options, cancelValue }) as Promise; +} + +/** Replacement for `window.prompt("Copy this:", value)`: shows the value selected, with a Copy button */ +export function showCopyValue(message: string, value: string, options: DialogOptions = {}): Promise { + const field = document.createElement("input"); + field.type = "text"; + field.readOnly = true; + field.value = value; + addClass(field, "yasgui-dialog__field"); + field.setAttribute("aria-label", message); + field.addEventListener("focus", () => field.select()); + const copy = () => + navigator.clipboard + ?.writeText(value) + .then(() => showToast("Copied to clipboard", { kind: "success", host: options.host })) + .catch(() => field.select()); + const promise = openDialog( + message, + [ + { label: "Close", value: "close" }, + { label: "Copy", value: "copy", kind: "primary" }, + ], + { ...options, cancelValue: "close", extra: field, focus: field }, + ).then((result) => { + if (result === "copy") void copy(); + }); + return promise; +} + +export type ToastKind = "info" | "success" | "warning" | "error"; + +export interface ToastOptions { + host?: HTMLElement; + kind?: ToastKind; + /** Milliseconds before it hides; 0 keeps it until closed (default: 4000, or 0 when there is an action) */ + duration?: number; + action?: { label: string; onClick: () => void }; +} + +const TOAST_ICONS: Record[0]> = { + info: "help", + success: "check", + warning: "alert", + error: "alert", +}; + +function toastStack(host: HTMLElement): HTMLElement { + let stack = host.querySelector(":scope > .yasgui-toasts"); + if (!stack) { + stack = document.createElement("div"); + addClass(stack, "yasgui-toasts"); + stack.setAttribute("role", "region"); + stack.setAttribute("aria-label", "Notifications"); + host.appendChild(stack); + } + return stack; +} + +/** A short message at the bottom of the screen, optionally with one action button */ +export function showToast(message: string, options: ToastOptions = {}): { close: () => void } { + const host = resolveHost(options.host); + const kind = options.kind || "info"; + const toast = document.createElement("div"); + addClass(toast, "yasgui-toast", `yasgui-toast--${kind}`); + toast.setAttribute("role", kind === "error" || kind === "warning" ? "alert" : "status"); + + const iconEl = document.createElement("span"); + addClass(iconEl, "yasgui-toast__icon"); + iconEl.innerHTML = icon(TOAST_ICONS[kind], 16); + toast.appendChild(iconEl); + + const text = document.createElement("span"); + addClass(text, "yasgui-toast__message"); + text.textContent = message; + toast.appendChild(text); + + let timer: number | undefined; + const close = () => { + if (timer !== undefined) window.clearTimeout(timer); + toast.remove(); + }; + + if (options.action) { + const action = document.createElement("button"); + action.type = "button"; + addClass(action, "yasgui-toast__action"); + action.textContent = options.action.label; + const onClick = options.action.onClick; + action.addEventListener("click", () => { + close(); + onClick(); + }); + toast.appendChild(action); + } + + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + addClass(closeBtn, "yasgui-toast__close"); + closeBtn.setAttribute("aria-label", "Dismiss"); + closeBtn.innerHTML = icon("x", 14); + closeBtn.addEventListener("click", close); + toast.appendChild(closeBtn); + + toastStack(host).appendChild(toast); + const duration = options.duration ?? (options.action ? 0 : 4000); + if (duration > 0) timer = window.setTimeout(close, duration); + return { close }; +} diff --git a/packages/yasgui/src/index.ts b/packages/yasgui/src/index.ts index 58b1c1f6..ac9aa42b 100644 --- a/packages/yasgui/src/index.ts +++ b/packages/yasgui/src/index.ts @@ -16,6 +16,8 @@ import GeoPlugin from "@matdata/yasgui-geo-plugin"; import GraphPlugin from "@matdata/yasgui-graph-plugin"; import TablePlugin from "@matdata/yasgui-table-plugin"; import { moveTabIdToFront, removeTabId } from "./tabNavigationHistory"; +import * as dialogs from "./dialogs"; +import { setDialogHost, showToast } from "./dialogs"; import "@matdata/yasgui-chart-plugin/dist/yasgui-chart-plugin.min.css"; import "@matdata/yasgui-explain-plugin/dist/yasgui-explain-plugin.min.css"; import "@matdata/yasgui-graph-plugin/dist/yasgui-graph-plugin.min.css"; @@ -103,6 +105,13 @@ export interface Config { endpointButtons?: EndpointButton[]; //The function allows us to modify the config before we pass it on to a tab populateFromUrl: boolean | ((configFromUrl: PersistedTabJson) => PersistedTabJson); + /** + * Whether a query opened from a share link runs on load (default: "safe"). + * "safe": runs, unless the request would carry saved credentials (auth headers or cookies) or the query is + * invalid; then the user is asked to review it first. "always": runs unless it is an update. "never": loads + * the query without running it. Update queries never run from a link. + */ + autoRunSharedQueries?: shareLink.AutoRunSharedQueries; autoAddOnInit: boolean; persistenceId: ((yasr: Yasgui) => string) | string | null; persistenceLabelConfig: string; @@ -245,11 +254,14 @@ export class Yasgui extends EventEmitter { private navigationSnapshot: string[] | null = null; private navigationCursor = 0; public static Tab = Tab; + /** In-app alert, confirm and toast, in the MatGUI style, for plugins and embedding pages */ + public static dialogs = dialogs; constructor(parent: HTMLElement, config: PartialConfig) { super(); this.rootEl = document.createElement("div"); addClass(this.rootEl, "yasgui"); parent.appendChild(this.rootEl); + setDialogHost(this.rootEl); this.config = merge({}, Yasgui.defaults, config); @@ -356,7 +368,7 @@ export class Yasgui extends EventEmitter { this.markTabSelected(activeTabId); this.recordTabInRecentHistory(activeTabId); if (executeIdAfterInit && executeIdAfterInit === activeTabId) { - (this.getTab(activeTabId) as Tab).query().catch(() => {}); + this.runSharedQuery(this.getTab(activeTabId) as Tab); } // } } @@ -376,6 +388,36 @@ export class Yasgui extends EventEmitter { }, 300); } } + /** + * Run the query of a tab opened from a share link, unless that would be unsafe without the user reviewing it + */ + private runSharedQuery(tab: Tab) { + const yasqe = tab.getYasqe(); + if (!yasqe) return; + yasqe.checkSyntax(); + let sendsCredentials = false; + try { + sendsCredentials = yasqe.requestSendsCredentials(); + } catch { + // A failing request config is reported when the query runs; treat it as credentialed to be safe + sendsCredentials = true; + } + const blocker = shareLink.getSharedQueryRunBlocker( + { queryMode: yasqe.getQueryMode(), queryValid: yasqe.queryValid, sendsCredentials }, + this.config.autoRunSharedQueries, + ); + if (!blocker) { + tab.query().catch(() => {}); + return; + } + const canRun = blocker === "credentials" || blocker === "disabled"; + showToast(shareLink.sharedQueryBlockedMessage(blocker, tab.getEndpoint()), { + host: this.rootEl, + kind: blocker === "disabled" ? "info" : "warning", + duration: canRun ? 0 : 10000, + action: canRun ? { label: "Run query", onClick: () => tab.query().catch(() => {}) } : undefined, + }); + } public hasFullscreen(fullscreen: boolean) { if (fullscreen) { this.emit("fullscreen-enter", this); diff --git a/packages/yasgui/src/linkUtils.ts b/packages/yasgui/src/linkUtils.ts index fc838e90..2425c3bd 100644 --- a/packages/yasgui/src/linkUtils.ts +++ b/packages/yasgui/src/linkUtils.ts @@ -4,6 +4,8 @@ import { default as Tab, PersistedJson } from "./Tab"; import Yasr from "@matdata/yasr"; import { PlainRequestConfig } from "@matdata/yasqe"; import { getAsValue } from "@matdata/yasgui-utils"; +import { parseJsonParam } from "./shareLinkSafety"; +export * from "./shareLinkSafety"; var getUrlParams = function (_url?: string) { var urlFromWindow = false; if (!_url) { @@ -127,7 +129,7 @@ export function getConfigFromUrl(defaults: PersistedJson, _url?: string): Persis console.warn(`Output format plugin "${value}" not found`); } } else if (key == "outputSettings") { - pluginsConfig = JSON.parse(value); + pluginsConfig = parseJsonParam("outputSettings", value); } else if (key == "contentTypeConstruct") { options.requestConfig.acceptHeaderGraph = value; } else if (key == "contentTypeSelect") { @@ -145,8 +147,10 @@ export function getConfigFromUrl(defaults: PersistedJson, _url?: string): Persis if (!Array.isArray(options.requestConfig.defaultGraphs)) options.requestConfig.defaultGraphs = []; options.requestConfig.defaultGraphs.push(value); } else if (key == "headers") { - if (!options.requestConfig.headers) options.requestConfig.headers = {}; - options.requestConfig.headers = JSON.parse(value); + const headers = parseJsonParam("headers", value); + if (headers && typeof headers === "object" && !Array.isArray(headers)) { + options.requestConfig.headers = headers; + } } else { //regular arguments. So store them as regular arguments if (!options.requestConfig.args) options.requestConfig.args = []; diff --git a/packages/yasgui/src/queryManagement/QueryBrowser.ts b/packages/yasgui/src/queryManagement/QueryBrowser.ts index 08444017..5342ed39 100644 --- a/packages/yasgui/src/queryManagement/QueryBrowser.ts +++ b/packages/yasgui/src/queryManagement/QueryBrowser.ts @@ -1,3 +1,4 @@ +import { showCopyValue } from "../dialogs"; import type Yasgui from "../index"; import { addClass, removeClass } from "@matdata/yasgui-utils"; import type { WorkspaceConfig, FolderEntry } from "./types"; @@ -800,7 +801,7 @@ export default class QueryBrowser { try { await navigator.clipboard.writeText(uri); } catch { - window.prompt("Copy this URI:", uri); + void showCopyValue("Copy the URI of this query", uri, { host: this.yasgui.rootEl }); } }), ); diff --git a/packages/yasgui/src/queryManagement/SaveManagedQueryModal.ts b/packages/yasgui/src/queryManagement/SaveManagedQueryModal.ts index e816261d..9a72f605 100644 --- a/packages/yasgui/src/queryManagement/SaveManagedQueryModal.ts +++ b/packages/yasgui/src/queryManagement/SaveManagedQueryModal.ts @@ -416,7 +416,7 @@ export default class SaveManagedQueryModal { const isSparql = workspace?.type === "sparql"; if (!workspaceId) { - window.alert("Please select a workspace"); + this.reportInvalid(this.workspaceSelectEl, "Select a workspace to save to"); return; } @@ -426,7 +426,7 @@ export default class SaveManagedQueryModal { if (isGit) { resolvedFilename = resolvedFilename.replace(/\.(rq|sparql)$/i, ""); if (!resolvedFilename) { - window.alert("Please enter a filename"); + this.reportInvalid(this.filenameEl, "Enter a filename"); return; } // Derive a tab label for convenience. @@ -434,18 +434,18 @@ export default class SaveManagedQueryModal { resolvedName = base || resolvedFilename; } else if (isSparql) { if (!resolvedName) { - window.alert("Please enter a name"); + this.reportInvalid(this.nameEl, "Enter a name"); return; } resolvedFilename = this.suggestFilenameFromName(resolvedName) || normalizeQueryFilename(resolvedName); } else { // Fallback (should not happen): require both. if (!resolvedFilename) { - window.alert("Please enter a filename"); + this.reportInvalid(this.filenameEl, "Enter a filename"); return; } if (!resolvedName) { - window.alert("Please enter a name"); + this.reportInvalid(this.nameEl, "Enter a name"); return; } } @@ -521,12 +521,22 @@ export default class SaveManagedQueryModal { this.nameEl.focus(); } - return await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }); } + /** Show a validation message on the field itself, rather than in a separate dialog */ + private reportInvalid(field: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, message: string) { + field.setCustomValidity(message); + field.reportValidity(); + field.focus(); + const reset = () => field.setCustomValidity(""); + field.addEventListener("input", reset, { once: true }); + field.addEventListener("change", reset, { once: true }); + } + private suggestFilenameFromName(name: string): string | undefined { const trimmed = name.trim(); if (!trimmed) return undefined; diff --git a/packages/yasgui/src/shareLinkSafety.ts b/packages/yasgui/src/shareLinkSafety.ts new file mode 100644 index 00000000..2cf67f16 --- /dev/null +++ b/packages/yasgui/src/shareLinkSafety.ts @@ -0,0 +1,53 @@ +/** + * Why a query that came in through a share link must not run on its own, if it must not. + * + * A link can name any query and endpoint. Running it on load would let a crafted link run an update, or a + * query that carries the credentials this browser saved for that endpoint, as the person who opens it. + */ +export type SharedQueryRunBlocker = "update" | "invalid" | "credentials"; + +export interface SharedQueryRunInfo { + queryMode: "update" | "query"; + queryValid: boolean; + /** The request would carry an Authorization or API key header, or cookies */ + sendsCredentials: boolean; +} + +export type AutoRunSharedQueries = "safe" | "always" | "never"; + +export function getSharedQueryRunBlocker( + info: SharedQueryRunInfo, + mode: AutoRunSharedQueries = "safe", +): SharedQueryRunBlocker | "disabled" | undefined { + if (mode === "never") return "disabled"; + // Updates never run from a link, whatever the mode + if (info.queryMode === "update") return "update"; + if (mode === "always") return undefined; + // An invalid query may be an update the tokenizer couldn't classify + if (!info.queryValid) return "invalid"; + if (info.sendsCredentials) return "credentials"; + return undefined; +} + +export function sharedQueryBlockedMessage(blocker: SharedQueryRunBlocker | "disabled", endpoint: string): string { + switch (blocker) { + case "update": + return "This link holds an update query, so it was not run. Review it, then press Run if you want to change the data."; + case "credentials": + return `This link was not run because it would use your saved credentials for ${endpoint}. Review the query first.`; + case "invalid": + return "This link holds a query with syntax errors, so it was not run."; + case "disabled": + return "Query loaded from the link. Press Run to execute it."; + } +} + +/** Parse a JSON-valued share link parameter, ignoring a malformed or truncated value */ +export function parseJsonParam(key: string, value: string): T | undefined { + try { + return JSON.parse(value); + } catch { + console.warn(`Ignoring the "${key}" parameter of the link: it is not valid JSON`); + return undefined; + } +} diff --git a/packages/yasqe/package.json b/packages/yasqe/package.json index fe0b37c6..a0d1484e 100644 --- a/packages/yasqe/package.json +++ b/packages/yasqe/package.json @@ -49,7 +49,7 @@ "@matdata/yasgui-utils": "^4.6.1", "leaflet": "^1.9.4", "lodash-es": "^4.17.15", - "query-string": "^9.5.1" + "query-string": "^10.0.0" }, "devDependencies": { "@types/leaflet": "^1.9.21", diff --git a/packages/yasqe/src/index.ts b/packages/yasqe/src/index.ts index ab835b8c..9a75dd96 100644 --- a/packages/yasqe/src/index.ts +++ b/packages/yasqe/src/index.ts @@ -1614,9 +1614,9 @@ export class Yasqe extends EditorFacade { addClass(this.queryBtn, "state-normal"); } } + /** The browser storage is full: keep the query in the editor, rather than deleting every saved query */ public handleLocalStorageQuotaFull(_e: any) { - console.warn("Localstorage quota exceeded. Clearing all queries"); - Yasqe.clearStorage(); + console.warn("Local storage quota exceeded: the query was not saved"); } public saveQuery() { @@ -2354,6 +2354,16 @@ export class Yasqe extends EditorFacade { return ajaxConfig ? Sparql.hasAuthenticationCredentials(ajaxConfig) : false; } + /** + * True when a request from this editor would carry credentials: an Authorization or API key header, or + * cookies (`withCredentials`) + */ + public requestSendsCredentials(config?: Sparql.YasqeAjaxConfig): boolean { + const ajaxConfig = Sparql.getAjaxConfig(this, config); + if (!ajaxConfig) return false; + return Sparql.hasAuthenticationCredentials(ajaxConfig) || !!ajaxConfig.withCredentials; + } + /** * True while a query started from this editor is running */ diff --git a/packages/yasqe/src/tooltip.ts b/packages/yasqe/src/tooltip.ts index f74e9602..47aeb4ce 100644 --- a/packages/yasqe/src/tooltip.ts +++ b/packages/yasqe/src/tooltip.ts @@ -12,7 +12,7 @@ export default function tooltip(_yasqe: Yasqe, parent: HTMLElement, html: string // escapes the `overflow` clipping of the CodeMirror gutter/scroller, which would // otherwise hide the tooltip behind the editor. let tooltip: HTMLDivElement | undefined; - parent.onmouseover = function (this: HTMLElement) { + parent.onmouseover = function (this: GlobalEventHandlers) { if (!tooltip) { tooltip = document.createElement("div"); tooltip.className = "yasqe_tooltip"; @@ -22,7 +22,7 @@ export default function tooltip(_yasqe: Yasqe, parent: HTMLElement, html: string tooltip.style.display = "block"; // `this` is the element the handler fires on. The gutter marker is rendered as a clone of // `parent`, so position relative to `this` rather than the (possibly detached) `parent`. - const rect = this.getBoundingClientRect(); + const rect = (this as HTMLElement).getBoundingClientRect(); tooltip.style.position = "fixed"; tooltip.style.left = `${rect.right + 6}px`; tooltip.style.top = `${rect.top}px`; diff --git a/packages/yasr/package.json b/packages/yasr/package.json index 64e952b9..bf500b99 100644 --- a/packages/yasr/package.json +++ b/packages/yasr/package.json @@ -45,24 +45,17 @@ "@codemirror/legacy-modes": "^6.5.3", "@codemirror/state": "^6.7.2", "@codemirror/view": "^6.43.10", - "@fortawesome/free-solid-svg-icons": "^7.1.0", "@json2csv/plainjs": "^7.0.4", "@lezer/highlight": "^1.2.3", "@matdata/yasgui-utils": "^4.6.1", "@matdata/yasqe": "^4.6.1", "codemirror-lang-turtle": "^0.0.2", - "colors": "^1.4.0", - "column-resizer": "^1.4.0", - "datatables.net": "^2.0.5", - "datatables.net-dt": "^2.0.5", "dompurify": "^3.4.17", - "jquery": "^3.7.1", "lodash-es": "^4.17.15", "n3": "^1.3.5", "papaparse": "^5.3.1" }, "devDependencies": { - "@types/jquery": "^3.5.32", "@types/lodash-es": "^4.17.3", "@types/n3": "^1.1.5", "@types/node": "^22.5.4", diff --git a/packages/yasr/src/index.ts b/packages/yasr/src/index.ts index 372f2fc3..52f90568 100644 --- a/packages/yasr/src/index.ts +++ b/packages/yasr/src/index.ts @@ -750,9 +750,9 @@ export class Yasr extends EventEmitter { } } + /** The browser storage is full: keep working without persisting, rather than deleting what was saved */ public handleLocalStorageQuotaFull(_e: any) { - console.warn("Localstorage quota exceeded. Clearing all queries"); - Yasr.clearStorage(); + console.warn("Local storage quota exceeded: the results view settings were not saved"); } public getResponseFromStorage() { @@ -798,7 +798,11 @@ export class Yasr extends EventEmitter { if (storageId && this.results) { const storeObj = this.results.getAsStoreObject(this.config.maxPersistentResponseSize); if (storeObj && !storeObj.error) { - this.storage.set(storageId, storeObj, this.config.persistencyExpire, this.handleLocalStorageQuotaFull); + this.storage.set(storageId, storeObj, this.config.persistencyExpire, () => { + // Too large to keep: drop the previous stored response too, so it isn't shown for this query + console.warn("Local storage quota exceeded: the query response was not saved"); + this.storage.remove(storageId); + }); } else { //remove old string; this.storage.remove(storageId); diff --git a/packages/yasr/src/jquery/extendJquery.js b/packages/yasr/src/jquery/extendJquery.js deleted file mode 100644 index 73930078..00000000 --- a/packages/yasr/src/jquery/extendJquery.js +++ /dev/null @@ -1 +0,0 @@ -import "./tableToCsv.js"; diff --git a/packages/yasr/src/jquery/tableToCsv.js b/packages/yasr/src/jquery/tableToCsv.js deleted file mode 100644 index edf337ba..00000000 --- a/packages/yasr/src/jquery/tableToCsv.js +++ /dev/null @@ -1,88 +0,0 @@ -"use strict"; -import $ from "jquery"; - -$.fn.tableToCsv = function (config) { - var csvString = ""; - config = $.extend( - { - quote: '"', - delimiter: ",", - lineBreak: "\n", - }, - config, - ); - - var needToQuoteString = function (value) { - //quote when it contains whitespace or the delimiter - var needQuoting = false; - if (value.match("[\\w|" + config.delimiter + "|" + config.quote + "]")) { - needQuoting = true; - } - return needQuoting; - }; - var addValueToString = function (value) { - //Quotes in the string need to be escaped - value.replace(config.quote, config.quote + config.quote); - if (needToQuoteString(value)) { - value = config.quote + value + config.quote; - } - csvString += " " + value + " " + config.delimiter; - }; - - var addRowToString = function (rowArray) { - rowArray.forEach(function (val) { - addValueToString(val); - }); - csvString += config.lineBreak; - }; - - var tableArrays = []; - var $el = $(this); - var rowspans = {}; - - var totalColCount = 0; - $el.find("tr:first *").each(function () { - if ($(this).attr("colspan")) { - totalColCount += +$(this).attr("colspan"); - } else { - totalColCount++; - } - }); - - $el.find("tr").each(function (rowId, tr) { - var $tr = $(tr); - var rowArray = []; - - var htmlColId = 0; - var actualColId = 0; - while (actualColId < totalColCount) { - if (rowspans[actualColId]) { - rowArray.push(rowspans[actualColId].text); - rowspans[actualColId].rowSpan--; - if (!rowspans[actualColId].rowSpan) rowspans[actualColId] = null; - actualColId++; - continue; - } - - var $cell = $tr.find(":nth-child(" + (htmlColId + 1) + ")"); - if (!$cell) break; - var colspan = $cell.attr("colspan") || 1; - var rowspan = $cell.attr("rowspan") || 1; - - for (var i = 0; i < colspan; i++) { - rowArray.push($cell.text()); - if (rowspan > 1) { - rowspans[actualColId] = { - rowSpan: rowspan - 1, - text: $cell.text(), - }; - } - actualColId++; - } - htmlColId++; - } - addRowToString(rowArray); - }); - - return csvString; -}; diff --git a/scripts/bundle-size.mjs b/scripts/bundle-size.mjs new file mode 100644 index 00000000..00893c12 --- /dev/null +++ b/scripts/bundle-size.mjs @@ -0,0 +1,78 @@ +// Reports what the browser bundles (build/.min.js) are made of, and fails when one grows past its budget. +// +// npm run util:bundleSize report + budget check +// npm run util:bundleSize -- --top 40 +// +// Budgets are in bytes of minified, uncompressed JavaScript. Raise one deliberately, in the PR that needs it. +import fs from "fs"; +import path from "path"; +import zlib from "zlib"; + +const BUDGETS = { + yasgui: 5_400_000, + yasqe: 1_800_000, + yasr: 650_000, + utils: 60_000, +}; + +const topArg = process.argv.indexOf("--top"); +const TOP = topArg > -1 ? Number(process.argv[topArg + 1]) || 20 : 15; + +const kb = (bytes) => `${(bytes / 1024).toFixed(0).padStart(6)} KB`; + +/** Group an input path by npm package, or by source folder for our own code */ +function groupOf(input) { + const parts = input.split(/[\\/]/); + const nm = parts.lastIndexOf("node_modules"); + if (nm > -1) { + const name = parts[nm + 1].startsWith("@") ? `${parts[nm + 1]}/${parts[nm + 2]}` : parts[nm + 1]; + return name; + } + const pkg = parts.indexOf("packages"); + if (pkg > -1) { + const file = parts.slice(pkg + 1).join("/"); + if (file.endsWith(".json")) return file; + return `${parts[pkg + 1]} (${parts.slice(pkg + 3, pkg + 4).join("/") || "src"})`; + } + return input; +} + +let failed = false; +for (const [name, budget] of Object.entries(BUDGETS)) { + const bundle = path.join("build", `${name}.min.js`); + const statsFile = path.join("bundle-stats", `${name}.json`); + if (!fs.existsSync(bundle)) { + console.error(`${bundle} not found: run "npm run build" first`); + process.exit(1); + } + const size = fs.statSync(bundle).size; + const gzip = zlib.gzipSync(fs.readFileSync(bundle), { level: 9 }).length; + const over = size > budget; + failed ||= over; + console.log( + `\n${name}.min.js ${kb(size)} minified, ${kb(gzip)} gzipped budget ${kb(budget)} ${over ? "OVER BUDGET" : "ok"}`, + ); + + if (!fs.existsSync(statsFile)) continue; + const meta = JSON.parse(fs.readFileSync(statsFile, "utf8")); + const output = Object.entries(meta.outputs).find(([file]) => file.endsWith(".js")); + if (!output) continue; + const groups = new Map(); + for (const [input, { bytesInOutput }] of Object.entries(output[1].inputs)) { + const group = groupOf(input); + groups.set(group, (groups.get(group) || 0) + bytesInOutput); + } + [...groups.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP) + .forEach(([group, bytes]) => + console.log(` ${kb(bytes)} ${((bytes / size) * 100).toFixed(1).padStart(5)}% ${group}`), + ); +} + +if (failed) { + console.error( + "\nA bundle is over its budget. Look at what grew above, or raise the budget in scripts/bundle-size.mjs.", + ); + process.exit(1); +} diff --git a/test/run.ts b/test/run.ts index c0875b86..17d7d38f 100644 --- a/test/run.ts +++ b/test/run.ts @@ -15,6 +15,7 @@ import "./quick-start-browser.js"; import "./ai-assistant-browser.js"; import "./sparql-anything-browser.js"; import "./whats-new-browser.js"; +import "./share-link-browser.js"; declare var window: Window & { Yasqe: typeof Yasqe; diff --git a/test/share-link-browser.ts b/test/share-link-browser.ts new file mode 100644 index 00000000..0a679d12 --- /dev/null +++ b/test/share-link-browser.ts @@ -0,0 +1,159 @@ +import * as path from "path"; +import * as http from "http"; +import * as chai from "chai"; +import * as puppeteer from "puppeteer"; +import { it, describe, before, beforeEach, after, afterEach } from "mocha"; +import { setup, destroy, closePage, getPage, TEST_ORIGIN } from "./utils.js"; + +const expect = chai.expect; + +// Requests to this endpoint get a 404 from the test server; we only count them +const ENDPOINT = `${TEST_ORIGIN}/share-link-endpoint`; + +function linkHash(params: { [key: string]: string }) { + return "#" + new URLSearchParams(params).toString(); +} + +describe("Share links", function () { + let browser: puppeteer.Browser; + let page: puppeteer.Page; + let server: http.Server | undefined; + let endpointRequests: number; + + before(async function () { + const refs = await setup(this, path.resolve("./build")); + browser = refs.browser; + server = refs.server; + }); + + beforeEach(async function () { + this.timeout(30000); + page = await getPage(browser, "yasgui.html"); + await page.evaluate(() => localStorage.clear()); + endpointRequests = 0; + page.on("request", (request) => { + if (request.url().startsWith(ENDPOINT)) endpointRequests++; + }); + }); + + afterEach(async () => { + await closePage(this, page); + }); + + after(async function () { + return destroy(browser, server); + }); + + async function open(params: { [key: string]: string }) { + // Through a blank page, so the link loads as a new document rather than as a hash change + await page.goto("about:blank"); + await page.goto(`${TEST_ORIGIN}/yasgui.html?noExternalServices=true${linkHash(params)}`, { + waitUntil: "networkidle2", + }); + await page.waitForFunction(() => "yasgui" in window); + // Give a query that would run on load the time to start + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + it("runs a read query without credentials", async function () { + await open({ query: "SELECT * WHERE { ?s ?p ?o } LIMIT 1", endpoint: ENDPOINT }); + expect(endpointRequests).to.be.greaterThan(0); + expect(await page.$(".yasgui-toast")).to.equal(null); + }); + + it("never runs an update query", async function () { + await open({ query: "INSERT DATA { }", endpoint: ENDPOINT }); + expect(endpointRequests).to.equal(0); + const toast = await page.$eval(".yasgui-toast", (el) => el.textContent || ""); + expect(toast).to.contain("update query"); + // The query is loaded for review + const value = await page.evaluate(() => (window as any).yasgui.getTab().getQuery()); + expect(value).to.contain("INSERT DATA"); + }); + + it("asks before running a query that would send saved credentials", async function () { + await page.evaluate((endpoint) => { + (window as any).yasgui.persistentConfig.addOrUpdateEndpoint(endpoint, { + authentication: { type: "basic", username: "u", password: "p" }, + }); + }, ENDPOINT); + await open({ query: "SELECT * WHERE { ?s ?p ?o } LIMIT 1", endpoint: ENDPOINT }); + expect(endpointRequests).to.equal(0); + const toast = await page.$eval(".yasgui-toast", (el) => el.textContent || ""); + expect(toast).to.contain("saved credentials"); + await page.click(".yasgui-toast__action"); + await page.waitForFunction(() => !document.querySelector(".yasgui-toast")); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(endpointRequests).to.be.greaterThan(0); + }); + + it("loads a link with malformed JSON parameters", async function () { + await open({ query: "SELECT * WHERE { ?s ?p ?o } LIMIT 1", endpoint: ENDPOINT, headers: '{"X-Broken":' }); + const value = await page.evaluate(() => (window as any).yasgui.getTab().getQuery()); + expect(value).to.contain("SELECT"); + }); +}); + +describe("In-app dialogs", function () { + let browser: puppeteer.Browser; + let page: puppeteer.Page; + let server: http.Server | undefined; + + before(async function () { + const refs = await setup(this, path.resolve("./build")); + browser = refs.browser; + server = refs.server; + }); + + beforeEach(async function () { + this.timeout(30000); + page = await getPage(browser, "yasgui.html"); + }); + + afterEach(async () => { + await closePage(this, page); + }); + + after(async function () { + return destroy(browser, server); + }); + + it("resolves a confirm with the button that was clicked, and false on Escape", async function () { + await page.evaluate(() => { + (window as any).confirmResult = (window as any).Yasgui.dialogs.showConfirm("Delete it?", { + title: "Delete", + confirmLabel: "Delete", + danger: true, + }); + }); + await page.waitForSelector(".yasgui-dialog [role=alertdialog]"); + // The confirm button has focus + const focused = await page.evaluate(() => document.activeElement?.textContent); + expect(focused).to.equal("Delete"); + await page.click(".yasgui-dialog__button--danger"); + expect(await page.evaluate(() => (window as any).confirmResult)).to.equal(true); + expect(await page.$(".yasgui-dialog")).to.equal(null); + + await page.evaluate(() => { + (window as any).confirmResult = (window as any).Yasgui.dialogs.showConfirm("Delete it?"); + }); + await page.waitForSelector(".yasgui-dialog"); + await page.keyboard.press("Escape"); + expect(await page.evaluate(() => (window as any).confirmResult)).to.equal(false); + }); + + it("closes a tab holding unsaved saved-query changes only after the choice", async function () { + const tabCount = () => page.evaluate(() => (window as any).yasgui.persistentConfig.getTabs().length); + const before = await tabCount(); + await page.evaluate(() => { + const tab = (window as any).yasgui.getTab(); + tab.isManagedQueryTab = () => true; + tab.hasUnsavedManagedChanges = () => true; + tab.close(); + }); + await page.waitForSelector(".yasgui-dialog"); + expect(await tabCount()).to.equal(before); + await page.keyboard.press("Escape"); + expect(await tabCount()).to.equal(before); + }); +}); diff --git a/test/unit/config-export-import-test.ts b/test/unit/config-export-import-test.ts new file mode 100644 index 00000000..072f3827 --- /dev/null +++ b/test/unit/config-export-import-test.ts @@ -0,0 +1,85 @@ +import * as chai from "chai"; +import { describe, it } from "mocha"; + +import { parseFromTurtle, serializeToTurtle } from "../../packages/yasgui/src/ConfigExportImport.js"; + +const expect = chai.expect; + +function tab(id: string, name: string, value: string, endpoint?: string, extra: any = {}) { + return { + id, + name, + yasqe: { value }, + requestConfig: { endpoint, method: "GET", acceptHeaderSelect: "application/sparql-results+xml" }, + yasr: { settings: { selectedPlugin: "Graph" }, response: undefined }, + ...extra, + }; +} + +describe("Configuration export and import", () => { + const config: any = { + endpointHistory: ["https://a.example/sparql", 'https://b.example/sparql?x="1"'], + tabs: ["t1", "t2", "t3"], + active: "t2", + tabConfig: { + t1: tab( + "t1", + 'Say "hi"', + 'SELECT * WHERE { ?s ?p "a \\"quoted\\" value" }\n# second line', + "https://a.example/sparql", + { + orientation: "horizontal", + }, + ), + // No endpoint: must not shift the endpoints of the other tabs + t2: tab("t2", "No endpoint", "ASK {}"), + t3: tab("t3", "Third", 'SELECT """long""" {}', "https://c.example/sparql"), + }, + prefixes: "PREFIX ex: \n", + autoCaptureEnabled: true, + theme: "dark", + customEndpointButtons: [{ label: "A", endpoint: "https://a.example/sparql" }], + }; + + it("round-trips tabs with quotes, newlines and a missing endpoint", () => { + const parsed = parseFromTurtle(serializeToTurtle(config)); + expect(parsed.tabs).to.deep.equal(["t1", "t2", "t3"]); + expect(parsed.active).to.equal("t2"); + expect(parsed.tabConfig!.t1.name).to.equal('Say "hi"'); + expect(parsed.tabConfig!.t1.yasqe.value).to.equal(config.tabConfig.t1.yasqe.value); + expect(parsed.tabConfig!.t1.requestConfig.endpoint).to.equal("https://a.example/sparql"); + expect(parsed.tabConfig!.t1.requestConfig.method).to.equal("GET"); + expect(parsed.tabConfig!.t1.requestConfig.acceptHeaderSelect).to.equal("application/sparql-results+xml"); + expect(parsed.tabConfig!.t1.orientation).to.equal("horizontal"); + expect(parsed.tabConfig!.t1.yasr.settings.selectedPlugin).to.equal("Graph"); + expect(parsed.tabConfig!.t2.requestConfig.endpoint).to.equal(""); + expect(parsed.tabConfig!.t3.requestConfig.endpoint).to.equal("https://c.example/sparql"); + expect(parsed.tabConfig!.t3.yasqe.value).to.equal('SELECT """long""" {}'); + }); + + it("keeps the configuration-level settings apart from the tab settings", () => { + const parsed = parseFromTurtle(serializeToTurtle(config)); + expect(parsed.endpointHistory).to.deep.equal(config.endpointHistory); + expect(parsed.prefixes).to.equal(config.prefixes); + expect(parsed.autoCaptureEnabled).to.equal(true); + expect(parsed.theme).to.equal("dark"); + // Only a tab has an orientation here, so the configuration has none + expect(parsed.orientation).to.equal(undefined); + expect(parsed.customEndpointButtons).to.deep.equal(config.customEndpointButtons); + }); + + it("never imports credentials", () => { + const parsed = parseFromTurtle(serializeToTurtle(config)); + const req: any = parsed.tabConfig!.t1.requestConfig; + expect(req.basicAuth).to.equal(undefined); + expect(req.bearerAuth).to.equal(undefined); + }); + + it("reports malformed Turtle", () => { + expect(() => parseFromTurtle("this is not turtle {")).to.throw(/Failed to parse configuration/); + }); + + it("reports Turtle that is not a configuration", () => { + expect(() => parseFromTurtle(" .")).to.throw(/no yasgui:Configuration/); + }); +}); diff --git a/test/unit/share-link-safety-test.ts b/test/unit/share-link-safety-test.ts new file mode 100644 index 00000000..501e3ec4 --- /dev/null +++ b/test/unit/share-link-safety-test.ts @@ -0,0 +1,60 @@ +import * as chai from "chai"; +import { describe, it } from "mocha"; + +import { + getSharedQueryRunBlocker, + parseJsonParam, + sharedQueryBlockedMessage, +} from "../../packages/yasgui/src/shareLinkSafety.js"; + +const expect = chai.expect; + +const select = { queryMode: "query" as const, queryValid: true, sendsCredentials: false }; + +describe("Share link auto-run", () => { + it("runs a valid read query without credentials", () => { + expect(getSharedQueryRunBlocker(select)).to.equal(undefined); + }); + + it("never runs an update, in any mode", () => { + const update = { ...select, queryMode: "update" as const }; + expect(getSharedQueryRunBlocker(update)).to.equal("update"); + expect(getSharedQueryRunBlocker(update, "always")).to.equal("update"); + expect(getSharedQueryRunBlocker(update, "never")).to.equal("disabled"); + }); + + it("asks before sending saved credentials", () => { + expect(getSharedQueryRunBlocker({ ...select, sendsCredentials: true })).to.equal("credentials"); + expect(getSharedQueryRunBlocker({ ...select, sendsCredentials: true }, "always")).to.equal(undefined); + }); + + it("does not run an invalid query, which may be an unrecognised update", () => { + expect(getSharedQueryRunBlocker({ ...select, queryValid: false })).to.equal("invalid"); + }); + + it("runs nothing when disabled", () => { + expect(getSharedQueryRunBlocker(select, "never")).to.equal("disabled"); + }); + + it("names the endpoint when credentials are involved", () => { + expect(sharedQueryBlockedMessage("credentials", "https://example.org/sparql")).to.contain( + "https://example.org/sparql", + ); + }); +}); + +describe("Share link JSON parameters", () => { + it("parses valid JSON", () => { + expect(parseJsonParam("headers", '{"X-Test":"1"}')).to.deep.equal({ "X-Test": "1" }); + }); + + it("ignores a truncated value instead of throwing", () => { + const warn = console.warn; + console.warn = () => {}; + try { + expect(parseJsonParam("outputSettings", '{"pageSize":')).to.equal(undefined); + } finally { + console.warn = warn; + } + }); +}); diff --git a/test/unit/storage-test.ts b/test/unit/storage-test.ts new file mode 100644 index 00000000..1f7681e4 --- /dev/null +++ b/test/unit/storage-test.ts @@ -0,0 +1,46 @@ +import * as chai from "chai"; +import { describe, it, beforeEach, afterEach } from "mocha"; +import store from "store"; + +import Storage, { isQuotaExceeded } from "../../packages/utils/src/Storage.js"; + +const expect = chai.expect; + +describe("Storage", () => { + let original: typeof store.set; + beforeEach(() => { + original = store.set; + }); + afterEach(() => { + store.set = original; + }); + + it("recognises quota errors from current and older browsers", () => { + expect(isQuotaExceeded({ name: "QuotaExceededError", code: 22 })).to.equal(true); + expect(isQuotaExceeded({ name: "NS_ERROR_DOM_QUOTA_REACHED", code: 1014 })).to.equal(true); + expect(isQuotaExceeded({ name: "TypeError" })).to.equal(false); + expect(isQuotaExceeded(undefined)).to.equal(false); + }); + + it("lets the quota callback handle a full storage without throwing", function () { + if (!store.enabled) this.skip(); + (store as any).set = () => { + const error = new Error("full"); + error.name = "QuotaExceededError"; + throw error; + }; + let handled = 0; + const storage = new Storage("test"); + expect(() => storage.set("key", { a: 1 }, 60, () => handled++)).to.not.throw(); + expect(handled).to.equal(1); + }); + + it("still throws errors that are not about the quota", function () { + if (!store.enabled) this.skip(); + (store as any).set = () => { + throw new TypeError("circular"); + }; + const storage = new Storage("test"); + expect(() => storage.set("key", { a: 1 }, 60, () => {})).to.throw(TypeError); + }); +}); diff --git a/test/utils.ts b/test/utils.ts index 308080e4..6d788d86 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -5,7 +5,8 @@ import * as path from "path"; import * as Mocha from "mocha"; import _ from "lodash"; // eslint-disable-line const TEST_ON_DEV_BUILD = !!process.env["TEST_ON_DEV_BUILD"]; -const PORT = TEST_ON_DEV_BUILD ? 4000 : 40001; +const PORT = Number(process.env["TEST_PORT"]) || (TEST_ON_DEV_BUILD ? 4000 : 40001); +export const TEST_ORIGIN = `http://localhost:${PORT}`; const MIME_TYPES: { [ext: string]: string } = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 451c2752..a57f835b 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -16,7 +16,7 @@ const config: Config = { }, // Set the production url of your site here - url: "https://matgui-docs.matdata.eu", + url: "https://matgui-doc.matdata.eu", // Set the // pathname under which your site is served // For GitHub pages deployment, it is often '//' baseUrl: "/",