diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..4e8ca404 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI Pipeline + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Check formatting and lint + run: npm run check + + - name: Type check + run: npm run typecheck diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4762a2e0..b10a7561 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,10 +1,10 @@ -name: "CodeQL" +name: 'CodeQL' on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: analyze: @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - language: ["javascript"] + language: ['javascript'] steps: - name: Checkout repository diff --git a/.github/workflows/nodejs-tests.yml b/.github/workflows/nodejs-tests.yml index 0fba63a7..ad2273df 100644 --- a/.github/workflows/nodejs-tests.yml +++ b/.github/workflows/nodejs-tests.yml @@ -2,9 +2,9 @@ name: Node.js Tests on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: node-tests: diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 00000000..0a4b97de --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..a8637945 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +set -e +npx lint-staged +npm run typecheck diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..b618ea4e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + - id: check-yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..6ccf3748 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "printWidth": 80, + "trailingComma": "es5", + "semi": false +} diff --git a/AGENTS.md b/AGENTS.md index 90a9d905..d6ea50f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,20 +19,24 @@ This file follows the AGENTS.md conventions (see https://agents.md/) and provide --- ## Quick setup + - Install dependencies (root): - `npm install` - Run the test suite locally: - `npm test` - Run a specific package/tests (replace `` with the folder path): - - `npx jest "/tests"` -- Run tests with coverage report: - - `npm test -- --coverage` -- Verify coverage meets thresholds: - - `node scripts/check-coverage.js` + - npx jest "/tests" + +--- + +## Why AGENTS.md? + +AGENTS.md is for precise, agent-focused instructions that complement README files. Use it to document build steps, dev commands, test steps, and any non-obvious processes an automated tool should know. --- ## Agent operation guidance (canonical guidance adapted) + - Prefer interactive or dev commands when iterating (e.g., `npm run dev`) and avoid running production-only commands (e.g., `npm run build`) from an interactive agent session. - Keep dependencies and lockfiles in sync. If you update deps, update the lockfile and restart relevant dev/test processes. - Prefer small, focused commands for iterative work (run the specific tests you care about rather than the full suite when possible). @@ -42,6 +46,7 @@ This file follows the AGENTS.md conventions (see https://agents.md/) and provide --- ## Tests & CI (repo conventions) + - **Follow Test-Driven Development (TDD): write tests before implementing features or bug fixes.** Add tests first and iterate until they pass; include the tests in the same PR as the implementation. - **Achieve and maintain excellent test coverage.** Minimum thresholds: 100% lines, 95% statements/functions, 85% branches. Verify locally with `npm test -- --coverage` (or `npx jest --coverage`) and ensure CI coverage meets these requirements. PRs that reduce coverage below these thresholds will be rejected. - **NEVER add coverage "ignore" comments (e.g., `/* istanbul ignore next */`) to artificially boost test coverage.** If code is truly difficult to test, adjust coverage thresholds or improve mocking strategies instead. Coverage ignore comments mask untested code and are not acceptable. @@ -54,11 +59,13 @@ This file follows the AGENTS.md conventions (see https://agents.md/) and provide ### Testing GAS functions Google Apps Script `.gs` files cannot be `require()`-d in Jest. To test GAS logic: + 1. Extract the function to `src/index.js` and export it with `module.exports`. 2. Accept GAS services (`GmailApp`, `DocumentApp`, etc.) as parameters rather than accessing globals. 3. In the test file, create a wrapper function that injects `global.GmailApp`, `global.DocumentApp`, etc. GAS `code.gs` files may optionally include a guard for Jest imports: + ```js if (typeof module !== 'undefined' && module.exports) { module.exports = { ... }; @@ -68,9 +75,10 @@ if (typeof module !== 'undefined' && module.exports) { --- ## Code style & commits + - Follow repository style and lint rules. - - ALWAYS ensure `npm run lint` and `npm test` passes before committing. - - ALWAYS ensure `npm test -- --coverage` passes before committing. +- ALWAYS ensure `npm run lint` and `npm test` passes before committing. +- ALWAYS ensure `npm test -- --coverage` passes before committing. - Keep commits small and include tests with behavior changes. - Follow existing code style — no new linting or build tooling unless essential. @@ -88,17 +96,20 @@ if (typeof module !== 'undefined' && module.exports) { --- ## Security & secrets + - Never commit secrets. Use GitHub Actions secrets or an external secret manager and document required secrets in `/README.md`. - Request maintainer review for agents requiring elevated permissions or access to sensitive data. --- ## How to use this file + - Agents will read the nearest AGENTS.md (this one is at the repo root). - If a subproject needs different guidance, it may include its own `AGENTS.md` or a clear `README.md` explaining the differences. --- ## References + - AGENTS.md canonical guidance: https://agents.md/ - Example repository: https://github.com/agentsmd/agents.md/blob/main/AGENTS.md diff --git a/GEMINI.md b/GEMINI.md index 2ffd9ffc..285e0f5b 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1 +1 @@ -@./AGENTS.md \ No newline at end of file +@./AGENTS.md diff --git a/README.md b/README.md index a057d9a3..9f102f1a 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,16 @@ ![googleappscripts](header.jpg) - A collection of personal productivity scripts built on Google Apps Script. These tools are designed to automate repetitive tasks across Gmail, Google Drive, and Google Docs, helping you reclaim your time and keep your digital workspace organized. Open sourced under the MIT License to help others build their own automation workflows. ## 📂 Script Catalog -| Script Name | Description | Documentation | -| :--- | :--- | :--- | +| Script Name | Description | Documentation | +| :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------ | | **Gmail to Drive By Labels** | Automatically archives emails from specific Gmail labels into a Google Doc (text) and Google Drive Folder (attachments). Features robust text cleaning (removing quoted replies/legal footers) and smart content-based attachment de-duplication. | [View Readme](./src/gmail-to-drive-by-labels/README.md) | -| **Calendar to Sheets** | Syncs Google Calendar events into a Google Sheet, keeping rows up to date on changes and deletions. | [View Readme](./src/calendar-to-sheets/README.md) | +| **Calendar to Sheets** | Syncs Google Calendar events into a Google Sheet, keeping rows up to date on changes and deletions. | [View Readme](./src/calendar-to-sheets/README.md) | ## 🚀 Getting Started @@ -37,4 +36,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file --- -*Note: These scripts are provided "as is". Always test on a small batch of data before running on important files.* +_Note: These scripts are provided "as is". Always test on a small batch of data before running on important files._ diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 00000000..4fedde6d --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +module.exports = { extends: ['@commitlint/config-conventional'] } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..e02473b7 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,64 @@ +const tseslint = require('@typescript-eslint/eslint-plugin') +const tsParser = require('@typescript-eslint/parser') +const prettierPlugin = require('eslint-plugin-prettier') +const prettierConfig = require('eslint-config-prettier') + +/** @type {import('eslint').Linter.FlatConfig[]} */ +module.exports = [ + // TypeScript-ESLint flat/recommended base + ...tseslint.configs['flat/recommended'], + { + files: ['**/*.{js,ts}'], + languageOptions: { + parser: tsParser, + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + // Node.js globals + require: 'readonly', + module: 'readonly', + exports: 'readonly', + process: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + Buffer: 'readonly', + console: 'readonly', + // Jest globals + describe: 'readonly', + test: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + jest: 'readonly', + // Google Apps Script globals + GmailApp: 'readonly', + DriveApp: 'readonly', + DocumentApp: 'readonly', + SpreadsheetApp: 'readonly', + CalendarApp: 'readonly', + Logger: 'readonly', + Session: 'readonly', + Utilities: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + prettier: prettierPlugin, + }, + rules: { + ...tseslint.configs.recommended.rules, + ...prettierConfig.rules, + 'prettier/prettier': 'error', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_' }, + ], + }, + }, + { + ignores: ['node_modules/', 'coverage/', '**/*.gs'], + }, +] diff --git a/jest.config.js b/jest.config.js index c0d98fcf..010598fa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,19 +1,32 @@ module.exports = { + preset: 'ts-jest/presets/js-with-ts', testEnvironment: 'node', setupFilesAfterEnv: ['/test-utils/setup.js'], testMatch: ['**/tests/**/*.test.[jt]s?(x)'], + transform: { + '^.+\\.[jt]sx?$': [ + 'ts-jest', + { + tsconfig: { + allowJs: true, + strict: false, + }, + diagnostics: false, + }, + ], + }, collectCoverageFrom: [ - 'src/**/*.{js,gs}', + 'src/**/*.{js,ts,gs}', '!**/node_modules/**', '!**/coverage/**', - '!**/tests/**' + '!**/tests/**', ], coverageThreshold: { global: { branches: 85, functions: 95, lines: 99, - statements: 95 - } - } -}; + statements: 95, + }, + }, +} diff --git a/package-lock.json b/package-lock.json index 17ab076d..9c2cb592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,21 @@ "name": "google-app-scripts", "version": "0.0.0", "devDependencies": { - "jest": "^29.7.0" + "@commitlint/cli": "^20.4.3", + "@commitlint/config-conventional": "^20.4.3", + "@types/google-apps-script": "^2.0.8", + "@types/jest": "^30.0.0", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "husky": "^9.1.7", + "jest": "^30.2.0", + "lint-staged": "^16.3.2", + "prettier": "^3.8.1", + "ts-jest": "^29.4.6", + "typescript": "^5.9.3" } }, "node_modules/@babel/code-frame": { @@ -507,524 +521,1795 @@ "dev": true, "license": "MIT" }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@commitlint/cli": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.3.tgz", + "integrity": "sha512-Z37EMoDT7+Upg500vlr/vZrgRsb6Xc5JAA3Tv7BYbobnN/ZpqUeZnSLggBg2+1O+NptRDtyujr2DD1CPV2qwhA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "@commitlint/format": "^20.4.3", + "@commitlint/lint": "^20.4.3", + "@commitlint/load": "^20.4.3", + "@commitlint/read": "^20.4.3", + "@commitlint/types": "^20.4.3", + "tinyexec": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=v18" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@commitlint/config-conventional": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.3.tgz", + "integrity": "sha512-9RtLySbYQAs8yEqWEqhSZo9nYhbm57jx7qHXtgRmv/nmeQIjjMcwf6Dl+y5UZcGWgWx435TAYBURONaJIuCjWg==", "dev": true, "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.4.3", + "conventional-changelog-conventionalcommits": "^9.2.0" + }, "engines": { - "node": ">=8" + "node": ">=v18" } }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "node_modules/@commitlint/config-validator": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.4.3.tgz", + "integrity": "sha512-jCZpZFkcSL3ZEdL5zgUzFRdytv3xPo8iukTe9VA+QGus/BGhpp1xXSVu2B006GLLb2gYUAEGEqv64kTlpZNgmA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" + "@commitlint/types": "^20.4.3", + "ajv": "^8.11.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "node_modules/@commitlint/config-validator/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/@commitlint/config-validator/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@commitlint/ensure": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.4.3.tgz", + "integrity": "sha512-WcXGKBNn0wBKpX8VlXgxqedyrLxedIlLBCMvdamLnJFEbUGJ9JZmBVx4vhLV3ZyA8uONGOb+CzW0Y9HDbQ+ONQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" + "@commitlint/types": "^20.4.3", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "node_modules/@commitlint/execute-rule": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", + "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", "dev": true, "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "node_modules/@commitlint/format": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.4.3.tgz", + "integrity": "sha512-UDJVErjLbNghop6j111rsHJYGw6MjCKAi95K0GT2yf4eeiDHy3JDRLWYWEjIaFgO+r+dQSkuqgJ1CdMTtrvHsA==", "dev": true, "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3" + "@commitlint/types": "^20.4.3", + "picocolors": "^1.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@commitlint/is-ignored": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.4.3.tgz", + "integrity": "sha512-W5VQKZ7fdJ1X3Tko+h87YZaqRMGN1KvQKXyCM8xFdxzMIf1KCZgN4uLz3osLB1zsFcVS4ZswHY64LI26/9ACag==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "@commitlint/types": "^20.4.3", + "semver": "^7.6.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "node_modules/@commitlint/is-ignored/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "node_modules/@commitlint/lint": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.3.tgz", + "integrity": "sha512-CYOXL23e+nRKij81+d0+dymtIi7Owl9QzvblJYbEfInON/4MaETNSLFDI74LDu+YJ0ML5HZyw9Vhp9QpckwQ0A==", "dev": true, "license": "MIT", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" + "@commitlint/is-ignored": "^20.4.3", + "@commitlint/parse": "^20.4.3", + "@commitlint/rules": "^20.4.3", + "@commitlint/types": "^20.4.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": ">=v18" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "node_modules/@commitlint/load": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.4.3.tgz", + "integrity": "sha512-3cdJOUVP+VcgHa7bhJoWS+Z8mBNXB5aLWMBu7Q7uX8PSeWDzdbrBlR33J1MGGf7r1PZDp+mPPiFktk031PgdRw==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@commitlint/config-validator": "^20.4.3", + "@commitlint/execute-rule": "^20.0.0", + "@commitlint/resolve-extends": "^20.4.3", + "@commitlint/types": "^20.4.3", + "cosmiconfig": "^9.0.1", + "cosmiconfig-typescript-loader": "^6.1.0", + "is-plain-obj": "^4.1.0", + "lodash.mergewith": "^4.6.2", + "picocolors": "^1.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "node_modules/@commitlint/message": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", + "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "node_modules/@commitlint/parse": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.4.3.tgz", + "integrity": "sha512-hzC3JCo3zs3VkQ833KnGVuWjWIzR72BWZWjQM7tY/7dfKreKAm7fEsy71tIFCRtxf2RtMP2d3RLF1U9yhFSccA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "@commitlint/types": "^20.4.3", + "conventional-changelog-angular": "^8.2.0", + "conventional-commits-parser": "^6.3.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "node_modules/@commitlint/read": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.4.3.tgz", + "integrity": "sha512-j42OWv3L31WfnP8WquVjHZRt03w50Y/gEE8FAyih7GQTrIv2+pZ6VZ6pWLD/ml/3PO+RV2SPtRtTp/MvlTb8rQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" + "@commitlint/top-level": "^20.4.3", + "@commitlint/types": "^20.4.3", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8", + "tinyexec": "^1.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "node_modules/@commitlint/resolve-extends": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.4.3.tgz", + "integrity": "sha512-QucxcOy+00FhS9s4Uy0OyS5HeUV+hbC6OLqkTSIm6fwMdKva+OEavaCDuLtgd9akZZlsUo//XzSmPP3sLKBPog==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "@commitlint/config-validator": "^20.4.3", + "@commitlint/types": "^20.4.3", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "node_modules/@commitlint/rules": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.3.tgz", + "integrity": "sha512-Yuosd7Grn5qiT7FovngXLyRXTMUbj9PYiSkvUgWK1B5a7+ZvrbWDS7epeUapYNYatCy/KTpPFPbgLUdE+MUrBg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@commitlint/ensure": "^20.4.3", + "@commitlint/message": "^20.4.3", + "@commitlint/to-lines": "^20.0.0", + "@commitlint/types": "^20.4.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@commitlint/to-lines": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", + "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "engines": { + "node": ">=v18" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@commitlint/top-level": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", + "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "escalade": "^3.2.0" + }, + "engines": { + "node": ">=v18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@commitlint/types": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.4.3.tgz", + "integrity": "sha512-51OWa1Gi6ODOasPmfJPq6js4pZoomima4XLZZCrkldaH2V5Nb3bVhNXPeT6XV0gubbainSpTw4zi68NqAeCNCg==", "dev": true, "license": "MIT", + "dependencies": { + "conventional-commits-parser": "^6.3.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">=6.0.0" + "node": ">=v18" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "optional": true, "dependencies": { - "type-detect": "4.0.8" + "tslib": "^2.4.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "optional": true, "dependencies": { - "@sinonjs/commons": "^3.0.0" + "tslib": "^2.4.0" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@eslint-community/regexpp": { + "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", - "dependencies": { - "@babel/types": "^7.0.0" + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "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", - "dependencies": { - "@babel/types": "^7.28.2" + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "@types/istanbul-lib-coverage": "*" + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@types/istanbul-lib-report": "*" + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@types/node": { - "version": "25.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", - "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "undici-types": "~7.16.0" + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@types/yargs-parser": "*" + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.2.0", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/google-apps-script": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@types/google-apps-script/-/google-apps-script-2.0.8.tgz", + "integrity": "sha512-mGPmzzdgBu1DlwrjOhFQ8u0se6AF/z4OpaTzOGwNKxwXZjE7J+IssfE3oL24j/S/p6aFiSTIptHZvbyqeZD8vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", + "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "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" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "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/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -1036,13 +2321,16 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { @@ -1085,76 +2373,66 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.8.0" + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", "dev": true, "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", "test-exclude": "^6.0.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" + "node": ">=12" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" + "@types/babel__core": "^7.20.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/babel-preset-current-node-syntax": { @@ -1185,20 +2463,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "node_modules/balanced-match": { @@ -1219,14 +2497,13 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -1276,6 +2553,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -1362,9 +2652,9 @@ } }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -1378,12 +2668,62 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true, "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1395,8 +2735,71 @@ "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" }, - "engines": { - "node": ">=12" + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "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" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "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" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/co": { @@ -1437,6 +2840,34 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1444,6 +2875,49 @@ "dev": true, "license": "MIT" }, + "node_modules/conventional-changelog-angular": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.0.tgz", + "integrity": "sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.0.tgz", + "integrity": "sha512-kYFx6gAyjSIMwNtASkI3ZE99U1fuVDJr0yTYgVy+I2QG46zNZfl2her+0+eoviG82c5WQvW1jMt1eOQTeJLodA==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-parser": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz", + "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1451,26 +2925,69 @@ "dev": true, "license": "MIT" }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" }, - "bin": { - "create-jest": "bin/create-jest.js" + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.2.0.tgz", + "integrity": "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "^2.6.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/cosmiconfig/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, "node_modules/cross-spawn": { @@ -1488,6 +3005,19 @@ "node": ">= 8" } }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1507,9 +3037,9 @@ } }, "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1521,6 +3051,13 @@ } } }, + "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", @@ -1541,16 +3078,26 @@ "node": ">=8" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", "dev": true, "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.283", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", @@ -1565,47 +3112,360 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "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" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "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" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { - "is-arrayish": "^0.2.1" + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, "engines": { - "node": ">=6" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esprima": { @@ -1622,6 +3482,59 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "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" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -1646,32 +3559,48 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1679,6 +3608,30 @@ "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/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -1689,6 +3642,19 @@ "bser": "2.1.1" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1716,6 +3682,57 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1738,16 +3755,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1768,6 +3775,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -1791,28 +3811,89 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "deprecated": "This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/git-raw-commits/node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { - "version": "7.2.3", - "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", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "*" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1820,27 +3901,36 @@ "dev": true, "license": "ISC" }, - "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==", + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", "dev": true, "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, "engines": { - "node": ">=8" + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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", - "dependencies": { - "function-bind": "^1.1.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, "node_modules/html-escaper": { @@ -1857,7 +3947,60 @@ "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=10.17.0" + "node": ">=10.17.0" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "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", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "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" } }, "node_modules/import-local": { @@ -1880,6 +4023,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1909,6 +4063,16 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -1916,20 +4080,14 @@ "dev": true, "license": "MIT" }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, "node_modules/is-fullwidth-code-point": { @@ -1952,6 +4110,19 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "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" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1962,6 +4133,29 @@ "node": ">=0.12.0" } }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -2010,9 +4204,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -2038,15 +4232,15 @@ } }, "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "istanbul-lib-coverage": "^3.0.0" }, "engines": { "node": ">=10" @@ -2066,23 +4260,39 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -2094,76 +4304,75 @@ } }, "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", "dev": true, "license": "MIT", "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", + "execa": "^5.1.1", + "jest-util": "30.2.0", "p-limit": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", + "chalk": "^4.1.2", "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "stack-utils": "^2.0.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -2175,215 +4384,211 @@ } }, "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", + "pretty-format": "30.2.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "@types/node": "*", + "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "esbuild-register": { + "optional": true + }, "ts-node": { "optional": true } } }, "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", "dependencies": { - "detect-newline": "^3.0.0" + "detect-newline": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", + "@jest/types": "30.2.0", "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", "walker": "^1.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "optionalDependencies": { - "fsevents": "^2.3.2" + "fsevents": "^2.3.3" } }, "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "stack-utils": "^2.0.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-util": "^29.7.0" + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-pnp-resolver": { @@ -2405,153 +4610,154 @@ } }, "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", + "chalk": "^4.1.2", "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -2561,199 +4767,530 @@ "node": ">=10" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.2.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.2.tgz", + "integrity": "sha512-xKqhC2AeXLwiAHXguxBjuChoTTWFC6Pees0SHPwOpwlvI3BH7ZADFPddAdN3pgo3aiKgPUx/bxE78JfUnxQnlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "micromatch": "^4.0.8", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.2", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "p-locate": "^4.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "environment": "^1.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "get-east-asian-width": "^1.3.1" }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/lru-cache": { @@ -2783,9 +5320,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -2795,6 +5332,13 @@ "node": ">=10" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -2805,6 +5349,19 @@ "tmpl": "1.0.5" } }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -2836,17 +5393,53 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "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": "^1.1.7" + "brace-expansion": "^2.0.2" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "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" } }, "node_modules/ms": { @@ -2856,6 +5449,22 @@ "dev": true, "license": "MIT" }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2863,6 +5472,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -2926,6 +5542,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2981,6 +5615,26 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "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" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -3030,12 +5684,29 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" }, "node_modules/picocolors": { "version": "1.1.1", @@ -3067,32 +5738,71 @@ "node": ">= 6" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { - "find-up": "^4.0.0" + "fast-diff": "^1.1.2" }, "engines": { - "node": ">=8" + "node": ">=6.0.0" } }, "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { @@ -3108,24 +5818,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "node_modules/punycode": { + "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", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, "engines": { - "node": ">= 6" + "node": ">=6" } }, "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", "dev": true, "funding": [ { @@ -3156,25 +5862,14 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, "node_modules/resolve-cwd": { @@ -3200,16 +5895,59 @@ "node": ">=8" } }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3250,13 +5988,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3267,6 +5998,52 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3288,6 +6065,16 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -3308,6 +6095,16 @@ "node": ">=10" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -3322,7 +6119,49 @@ "node": ">=10" } }, + "node_modules/string-length/node_modules/ansi-regex": { + "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" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "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" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", @@ -3337,7 +6176,54 @@ "node": ">=8" } }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "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" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "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" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -3350,6 +6236,16 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "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" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -3390,38 +6286,145 @@ "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "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" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=8" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=12.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/tmpl": { @@ -3444,6 +6447,119 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -3467,6 +6583,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "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", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3474,6 +6618,41 @@ "dev": true, "license": "MIT" }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3505,6 +6684,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -3546,7 +6735,43 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", @@ -3564,6 +6789,64 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "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" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "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" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3572,17 +6855,30 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" + "signal-exit": "^4.0.1" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/y18n": { @@ -3602,6 +6898,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -3631,6 +6943,51 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/ansi-regex": { + "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" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "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" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 78f8acbb..5c0c01b6 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,32 @@ "version": "0.0.0", "private": true, "scripts": { - "test": "jest --runInBand" + "test": "jest --runInBand", + "lint": "eslint src", + "format": "prettier --write .", + "typecheck": "tsc --noEmit", + "check": "prettier --check . && npm run lint", + "prepare": "husky" + }, + "lint-staged": { + "**/*.{js,ts,gs,json,md,yml,yaml}": "prettier --write", + "**/*.{js,ts}": "eslint --fix" }, "devDependencies": { - "jest": "^29.7.0" + "@commitlint/cli": "^20.4.3", + "@commitlint/config-conventional": "^20.4.3", + "@types/google-apps-script": "^2.0.8", + "@types/jest": "^30.0.0", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "husky": "^9.1.7", + "jest": "^30.2.0", + "lint-staged": "^16.3.2", + "prettier": "^3.8.1", + "ts-jest": "^29.4.6", + "typescript": "^5.9.3" } } diff --git a/scripts/check-coverage.js b/scripts/check-coverage.js index 74ccf1b7..87025646 100644 --- a/scripts/check-coverage.js +++ b/scripts/check-coverage.js @@ -7,94 +7,100 @@ * its minimum threshold, or 0 if all pass. */ -const fs = require('fs'); -const path = require('path'); +const fs = require('fs') +const path = require('path') -const coveragePath = path.join(process.cwd(), 'coverage', 'coverage-final.json'); +const coveragePath = path.join(process.cwd(), 'coverage', 'coverage-final.json') if (!fs.existsSync(coveragePath)) { - console.error('❌ Coverage report not found. Run tests with --coverage flag first.'); - process.exit(1); + console.error( + '❌ Coverage report not found. Run tests with --coverage flag first.' + ) + process.exit(1) } -const coverage = JSON.parse(fs.readFileSync(coveragePath, 'utf8')); +const coverage = JSON.parse(fs.readFileSync(coveragePath, 'utf8')) // Calculate totals across all files const totals = { statements: { covered: 0, total: 0 }, branches: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 }, - lines: { covered: 0, total: 0 } -}; + lines: { covered: 0, total: 0 }, +} for (const filePath in coverage) { - const fileCoverage = coverage[filePath]; - + const fileCoverage = coverage[filePath] + // Statements if (fileCoverage.s) { for (const key in fileCoverage.s) { - totals.statements.total++; - if (fileCoverage.s[key] > 0) totals.statements.covered++; + totals.statements.total++ + if (fileCoverage.s[key] > 0) totals.statements.covered++ } } - + // Branches if (fileCoverage.b) { for (const key in fileCoverage.b) { - const branches = fileCoverage.b[key]; + const branches = fileCoverage.b[key] for (let i = 0; i < branches.length; i++) { - totals.branches.total++; - if (branches[i] > 0) totals.branches.covered++; + totals.branches.total++ + if (branches[i] > 0) totals.branches.covered++ } } } - + // Functions if (fileCoverage.f) { for (const key in fileCoverage.f) { - totals.functions.total++; - if (fileCoverage.f[key] > 0) totals.functions.covered++; + totals.functions.total++ + if (fileCoverage.f[key] > 0) totals.functions.covered++ } } - + // Lines if (fileCoverage.l) { for (const line in fileCoverage.l) { - totals.lines.total++; - if (fileCoverage.l[line] > 0) totals.lines.covered++; + totals.lines.total++ + if (fileCoverage.l[line] > 0) totals.lines.covered++ } } } -const metrics = ['statements', 'branches', 'functions', 'lines']; +const metrics = ['statements', 'branches', 'functions', 'lines'] const requiredCoverage = { statements: 95, branches: 85, functions: 95, - lines: 100 -}; + lines: 100, +} -let allPassed = true; +let allPassed = true -console.log('\n📊 Coverage Report:\n'); +console.log('\n📊 Coverage Report:\n') for (const metric of metrics) { - const { covered, total } = totals[metric]; - const pct = total > 0 ? ((covered / total) * 100).toFixed(2) : 100; - const required = requiredCoverage[metric]; - const status = pct >= required ? '✅' : '❌'; - console.log(`${status} ${metric.padEnd(15)}: ${pct}% (${covered}/${total}) [required: ${required}%]`); + const { covered, total } = totals[metric] + const pct = total > 0 ? ((covered / total) * 100).toFixed(2) : 100 + const required = requiredCoverage[metric] + const status = pct >= required ? '✅' : '❌' + console.log( + `${status} ${metric.padEnd(15)}: ${pct}% (${covered}/${total}) [required: ${required}%]` + ) if (pct < required) { - allPassed = false; + allPassed = false } } -console.log(''); +console.log('') if (!allPassed) { - console.error('❌ Coverage check failed: not all metrics meet minimum requirements.'); - process.exit(1); + console.error( + '❌ Coverage check failed: not all metrics meet minimum requirements.' + ) + process.exit(1) } -console.log('✅ All coverage metrics meet requirements.\n'); -process.exit(0); +console.log('✅ All coverage metrics meet requirements.\n') +process.exit(0) diff --git a/src/calendar-to-sheets/README.md b/src/calendar-to-sheets/README.md index 321acc23..d9c7efe8 100644 --- a/src/calendar-to-sheets/README.md +++ b/src/calendar-to-sheets/README.md @@ -3,6 +3,7 @@ Google Apps Script that syncs events from a user's primary Google Calendar into a Google Sheet. Features: + - Writes full event details (id, title, start, end, description, location, attendees) into a sheet row. - Updates existing rows when an event changes (no duplicates). - Removes rows when events are deleted from the calendar. @@ -36,14 +37,17 @@ sequenceDiagram ``` Testing & development + - Unit tests are implemented with Jest. Run `npm test` from the repo root. - Tests are designed to run locally using the repository's `test-utils` mocks. Usage + - The runnable Apps Script entry points live in `code.gs` and configuration values are in `config.gs`. - Configuration now supports multiple calendar->sheet mappings via `SYNC_CONFIGS` in `config.gs` (preferred). Legacy single mapping using `SPREADSHEET_ID`, `SHEET_NAME`, and `CALENDAR_ID` is still supported. - Use the GAS wrapper `syncCalendarToSheetGAS(startIso, endIso)` for a single mapping (legacy behavior) or `syncAllCalendarsToSheetsGAS(startIso, endIso)` to sync all mappings defined in `SYNC_CONFIGS`. Both functions accept optional `startIso`/`endIso` ISO timestamps. - The core, testable logic lives under `src/` (`eventToRow`, `syncCalendarToSheet`, etc.) and is exercised by the included Jest tests. + ## Checkpoint logic (performance optimization) To prevent timeouts with large calendars, the script implements **checkpoint logic**: @@ -66,9 +70,8 @@ To prevent timeouts with large calendars, the script implements **checkpoint log // In Google Apps Script Editor, run this for a full historical sync: // Full historical resync (clears checkpoint and syncs from epoch) -fullResyncCalendarToSheetGAS(0); +fullResyncCalendarToSheetGAS(0) // For a manual one-off window, call the sync function with explicit dates: // syncAllCalendarsToSheetsGAS('2025-01-01', '2025-12-31'); ``` - diff --git a/src/calendar-to-sheets/code.gs b/src/calendar-to-sheets/code.gs index 5fb54a6c..7cf2d188 100644 --- a/src/calendar-to-sheets/code.gs +++ b/src/calendar-to-sheets/code.gs @@ -4,14 +4,13 @@ * Place configuration in `config.gs` using `SYNC_CONFIGS` (preferred) or the legacy * `SPREADSHEET_ID`/`SHEET_NAME`/`CALENDAR_ID` vars for a single mapping. * This file is primarily a wrapper that can run in Google Apps Script. - * + * * Checkpoint logic processes data in chunks to work around timeouts found in personal GAS plans. */ -const CHECKPOINT_PREFIX = 'calendar_to_sheets_last_sync_'; -const DEFAULT_SYNC_WINDOW_MS = 365 * 24 * 60 * 60 * 1000; // 1 year in milliseconds -const TAIL_MERGE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes - +const CHECKPOINT_PREFIX = 'calendar_to_sheets_last_sync_' +const DEFAULT_SYNC_WINDOW_MS = 365 * 24 * 60 * 60 * 1000 // 1 year in milliseconds +const TAIL_MERGE_WINDOW_MS = 10 * 60 * 1000 // 10 minutes function getConfigs() { if (typeof SYNC_CONFIGS !== 'undefined' && Array.isArray(SYNC_CONFIGS)) { @@ -19,27 +18,29 @@ function getConfigs() { if (SYNC_CONFIGS.length === 0) { return [ { - spreadsheetId: typeof SPREADSHEET_ID !== 'undefined' ? SPREADSHEET_ID : null, + spreadsheetId: + typeof SPREADSHEET_ID !== 'undefined' ? SPREADSHEET_ID : null, sheetName: typeof SHEET_NAME !== 'undefined' ? SHEET_NAME : 'Sheet1', - calendarId: typeof CALENDAR_ID !== 'undefined' ? CALENDAR_ID : null - } - ]; + calendarId: typeof CALENDAR_ID !== 'undefined' ? CALENDAR_ID : null, + }, + ] } - return SYNC_CONFIGS; + return SYNC_CONFIGS } // Legacy single-config support return [ { - spreadsheetId: typeof SPREADSHEET_ID !== 'undefined' ? SPREADSHEET_ID : null, + spreadsheetId: + typeof SPREADSHEET_ID !== 'undefined' ? SPREADSHEET_ID : null, sheetName: typeof SHEET_NAME !== 'undefined' ? SHEET_NAME : 'Sheet1', - calendarId: typeof CALENDAR_ID !== 'undefined' ? CALENDAR_ID : null - } - ]; + calendarId: typeof CALENDAR_ID !== 'undefined' ? CALENDAR_ID : null, + }, + ] } function getConfig() { - const cfgs = getConfigs(); - return cfgs[0] || null; + const cfgs = getConfigs() + return cfgs[0] || null } /** @@ -47,7 +48,7 @@ function getConfig() { * Used to store/retrieve last sync timestamp for a calendar. */ function getCheckpointKey(cfg) { - return CHECKPOINT_PREFIX + ((cfg && cfg.calendarId) || 'default'); + return CHECKPOINT_PREFIX + ((cfg && cfg.calendarId) || 'default') } /** @@ -56,49 +57,67 @@ function getCheckpointKey(cfg) { * If the stored checkpoint is invalid (NaN or corrupt), resets to epoch. */ function getLastSyncTime(cfg) { - const key = getCheckpointKey(cfg); - const stored = PropertiesService.getUserProperties().getProperty(key); + const key = getCheckpointKey(cfg) + const stored = PropertiesService.getUserProperties().getProperty(key) // Check if a value exists (null means no property set) if (stored !== null && stored !== undefined) { - const parsedTime = parseInt(stored); + const parsedTime = parseInt(stored) // Validate the parsed timestamp if (isNaN(parsedTime)) { - console.log('[getLastSyncTime] Invalid checkpoint detected (NaN), resetting to epoch'); - return new Date(0); + console.log( + '[getLastSyncTime] Invalid checkpoint detected (NaN), resetting to epoch' + ) + return new Date(0) } - const date = new Date(parsedTime); + const date = new Date(parsedTime) // Check if the date is valid (not Invalid Date) if (isNaN(date.getTime())) { - console.log('[getLastSyncTime] Invalid checkpoint detected (Invalid Date), resetting to epoch'); - return new Date(0); + console.log( + '[getLastSyncTime] Invalid checkpoint detected (Invalid Date), resetting to epoch' + ) + return new Date(0) } - return date; + return date } // Default to epoch if no checkpoint exists - const defaultStart = new Date(0); - console.log('[getLastSyncTime] Defaulting to epoch:', defaultStart.toISOString()); - return defaultStart; + const defaultStart = new Date(0) + console.log( + '[getLastSyncTime] Defaulting to epoch:', + defaultStart.toISOString() + ) + return defaultStart } /** * Save the current sync timestamp to properties storage. */ function saveLastSyncTime(cfg, timestamp) { - const key = getCheckpointKey(cfg); - console.log('[saveLastSyncTime] Saving checkpoint for calendar:', cfg?.calendarId || 'default', 'timestamp:', timestamp.toISOString()); - PropertiesService.getUserProperties().setProperty(key, timestamp.getTime().toString()); - console.log('[saveLastSyncTime] Checkpoint saved with key:', key); + const key = getCheckpointKey(cfg) + console.log( + '[saveLastSyncTime] Saving checkpoint for calendar:', + cfg?.calendarId || 'default', + 'timestamp:', + timestamp.toISOString() + ) + PropertiesService.getUserProperties().setProperty( + key, + timestamp.getTime().toString() + ) + console.log('[saveLastSyncTime] Checkpoint saved with key:', key) } /** * Clear checkpoint for a calendar (useful for full re-sync). */ function clearCheckpoint(cfg) { - const key = getCheckpointKey(cfg); - console.log('[clearCheckpoint] Clearing checkpoint for calendar:', cfg?.calendarId || 'default'); - PropertiesService.getUserProperties().deleteProperty(key); - console.log('[clearCheckpoint] Checkpoint cleared with key:', key); + const key = getCheckpointKey(cfg) + console.log( + '[clearCheckpoint] Clearing checkpoint for calendar:', + cfg?.calendarId || 'default' + ) + PropertiesService.getUserProperties().deleteProperty(key) + console.log('[clearCheckpoint] Checkpoint cleared with key:', key) } /** @@ -109,52 +128,74 @@ function clearCheckpoint(cfg) { */ function sanitizeValue(val) { if (typeof val === 'string' && /^[\x00-\x20]*[=+\-@]/.test(val)) { - return "'" + val; + return "'" + val } - return val; + return val } function eventToRowGAS(event) { - const id = event.getId(); - const title = sanitizeValue(event.getTitle()); - const start = event.getStartTime().toISOString(); - const end = event.getEndTime().toISOString(); - const description = sanitizeValue(event.getDescription() || ''); - const location = sanitizeValue(event.getLocation() || ''); - const attendees = (event.getGuestList() || []).map(g => g.getEmail()).join(','); - return [id, title, start, end, description, location, attendees]; + const id = event.getId() + const title = sanitizeValue(event.getTitle()) + const start = event.getStartTime().toISOString() + const end = event.getEndTime().toISOString() + const description = sanitizeValue(event.getDescription() || '') + const location = sanitizeValue(event.getLocation() || '') + const attendees = sanitizeValue( + (event.getGuestList() || []).map((g) => g.getEmail()).join(',') + ) + return [id, title, start, end, description, location, attendees] } function getOrCreateSheet(ss, sheetName) { - const resolvedName = sheetName || 'Sheet1'; - let sheet = ss.getSheetByName(resolvedName); + const resolvedName = sheetName || 'Sheet1' + let sheet = ss.getSheetByName(resolvedName) if (!sheet && typeof ss.insertSheet === 'function') { - sheet = ss.insertSheet(resolvedName); + sheet = ss.insertSheet(resolvedName) } - return sheet || ss.getSheets()[0]; + return sheet || ss.getSheets()[0] } function _syncCalendarToSheetGAS(cfg, start, end) { - console.log('[_syncCalendarToSheetGAS] Starting sync with config:', { calendarId: cfg?.calendarId, spreadsheetId: cfg?.spreadsheetId, sheetName: cfg?.sheetName }); - console.log('[_syncCalendarToSheetGAS] Date range:', { start, end }); - - const calendar = cfg && cfg.calendarId ? CalendarApp.getCalendarById(cfg.calendarId) : CalendarApp.getDefaultCalendar(); - const ss = cfg && cfg.spreadsheetId ? SpreadsheetApp.openById(cfg.spreadsheetId) : SpreadsheetApp.getActiveSpreadsheet(); - const sheet = getOrCreateSheet(ss, cfg && cfg.sheetName ? cfg.sheetName : 'Sheet1'); - - const events = calendar.getEvents(start, end); - console.log('[_syncCalendarToSheetGAS] Fetched events:', events.length); - const desired = events.map(eventToRowGAS); - const desiredMap = new Map(desired.map(r => [r[0], r])); + console.log('[_syncCalendarToSheetGAS] Starting sync with config:', { + calendarId: cfg?.calendarId, + spreadsheetId: cfg?.spreadsheetId, + sheetName: cfg?.sheetName, + }) + console.log('[_syncCalendarToSheetGAS] Date range:', { start, end }) + + const calendar = + cfg && cfg.calendarId + ? CalendarApp.getCalendarById(cfg.calendarId) + : CalendarApp.getDefaultCalendar() + const ss = + cfg && cfg.spreadsheetId + ? SpreadsheetApp.openById(cfg.spreadsheetId) + : SpreadsheetApp.getActiveSpreadsheet() + const sheet = getOrCreateSheet( + ss, + cfg && cfg.sheetName ? cfg.sheetName : 'Sheet1' + ) + + const events = calendar.getEvents(start, end) + console.log('[_syncCalendarToSheetGAS] Fetched events:', events.length) + const desired = events.map(eventToRowGAS) + const desiredMap = new Map(desired.map((r) => [r[0], r])) // Ensure the header row exists before reading data so the first event // row is never mistaken for a header on a brand-new/empty sheet. if (typeof ensureHeader === 'function') { - ensureHeader(sheet); + ensureHeader(sheet) } - let data = sheet.getDataRange().getValues(); + let data = sheet.getDataRange().getValues() // Ensure header row exists; if sheet is empty or first row is blank, create headers. - if (!data || data.length === 0 || (data.length === 1 && data[0].every(function (cell) { return cell === '' || cell === null; }))) { + if ( + !data || + data.length === 0 || + (data.length === 1 && + data[0].every(function (cell) { + return cell === '' || cell === null + })) + ) { // Header titles chosen to be descriptive; they should align with eventToRowGAS's column order. const headerRow = [ 'Event ID', @@ -167,165 +208,213 @@ function _syncCalendarToSheetGAS(cfg, start, end) { 'Last Updated', 'Location', 'Description', - 'Guests' - ]; - sheet.getRange(1, 1, 1, headerRow.length).setValues([headerRow]); - data = sheet.getDataRange().getValues(); + 'Guests', + ] + sheet.getRange(1, 1, 1, headerRow.length).setValues([headerRow]) + data = sheet.getDataRange().getValues() } - const body = data.slice(1); - console.log('[_syncCalendarToSheetGAS] Existing rows in sheet:', body.length); + const body = data.slice(1) + console.log('[_syncCalendarToSheetGAS] Existing rows in sheet:', body.length) - const existingMap = new Map(); + const existingMap = new Map() for (let i = 0; i < body.length; i++) { - const r = body[i]; - if (!r || !r[0]) continue; - existingMap.set(r[0], { rowIndex: i + 2, values: r }); + const r = body[i] + if (!r || !r[0]) continue + existingMap.set(r[0], { rowIndex: i + 2, values: r }) } // Upsert - let updateCount = 0; - let insertCount = 0; + let updateCount = 0 + let insertCount = 0 for (const [id, row] of desiredMap.entries()) { if (existingMap.has(id)) { - const ex = existingMap.get(id); + const ex = existingMap.get(id) // shallow compare - let equal = true; - for (let i = 0; i < row.length; i++) if (ex.values[i] !== row[i]) { equal = false; break; } + let equal = true + for (let i = 0; i < row.length; i++) + if (ex.values[i] !== row[i]) { + equal = false + break + } if (!equal) { - console.log('[_syncCalendarToSheetGAS] Updating event:', id); - sheet.getRange(ex.rowIndex, 1, 1, row.length).setValues([row]); - updateCount++; + console.log('[_syncCalendarToSheetGAS] Updating event:', id) + sheet.getRange(ex.rowIndex, 1, 1, row.length).setValues([row]) + updateCount++ } } else { - console.log('[_syncCalendarToSheetGAS] Inserting new event:', id); - sheet.appendRow(row); - insertCount++; + console.log('[_syncCalendarToSheetGAS] Inserting new event:', id) + sheet.appendRow(row) + insertCount++ } } - console.log('[_syncCalendarToSheetGAS] Updates:', updateCount, 'Inserts:', insertCount); + console.log( + '[_syncCalendarToSheetGAS] Updates:', + updateCount, + 'Inserts:', + insertCount + ) // Delete removed events that fall within the sync window [start, end] // This prevents wiping historical events outside the current sync range. - const toDelete = []; + const toDelete = [] for (const [id, ex] of existingMap.entries()) { if (!desiredMap.has(id)) { // Only delete if the event's start time falls within the sync window - const eventStart = ex.values[2] ? new Date(ex.values[2]) : null; + const eventStart = ex.values[2] ? new Date(ex.values[2]) : null if (eventStart && eventStart >= start && eventStart <= end) { - toDelete.push(ex.rowIndex); + toDelete.push(ex.rowIndex) } } } - console.log('[_syncCalendarToSheetGAS] Deleting rows:', toDelete.length); - toDelete.sort((a,b) => b - a).forEach(r => sheet.deleteRow(r)); - console.log('[_syncCalendarToSheetGAS] Sync complete'); + console.log('[_syncCalendarToSheetGAS] Deleting rows:', toDelete.length) + toDelete.sort((a, b) => b - a).forEach((r) => sheet.deleteRow(r)) + console.log('[_syncCalendarToSheetGAS] Sync complete') } function syncCalendarToSheetGAS(startIso, endIso) { - const cfg = getConfig(); - const checkpoint = getLastSyncTime(cfg); - const now = new Date(); - let start = startIso ? new Date(startIso) : checkpoint; - const end = endIso ? new Date(endIso) : now; + const cfg = getConfig() + const checkpoint = getLastSyncTime(cfg) + const now = new Date() + let start = startIso ? new Date(startIso) : checkpoint + const end = endIso ? new Date(endIso) : now // Validate: if checkpoint is in the future, reset it if (start > end) { - console.log('[syncCalendarToSheetGAS] Warning: start time is after end time, resetting checkpoint'); - clearCheckpoint(cfg); - start = getLastSyncTime(cfg); + console.log( + '[syncCalendarToSheetGAS] Warning: start time is after end time, resetting checkpoint' + ) + clearCheckpoint(cfg) + start = getLastSyncTime(cfg) } - if (startIso && (now.getTime() - start.getTime()) <= DEFAULT_SYNC_WINDOW_MS) { - start = new Date(start.getTime() - DEFAULT_SYNC_WINDOW_MS); + if (startIso && now.getTime() - start.getTime() <= DEFAULT_SYNC_WINDOW_MS) { + start = new Date(start.getTime() - DEFAULT_SYNC_WINDOW_MS) } - - + // Sync in chunks to prevent timeouts // After each chunk, checkpoint progress so we can resume if interrupted - let currentStart = start; - let iterationCount = 0; - const maxIterations = 100; // Safety limit to prevent infinite loops - + let currentStart = start + let iterationCount = 0 + const maxIterations = 100 // Safety limit to prevent infinite loops + while (currentStart < end && iterationCount < maxIterations) { // Calculate the end of this chunk (SYNC_WINDOW from current start, but not beyond target end) - const chunkEnd = new Date(currentStart.getTime() + DEFAULT_SYNC_WINDOW_MS); - let effectiveEnd = chunkEnd < end ? chunkEnd : end; - if (chunkEnd < end && (end.getTime() - chunkEnd.getTime()) <= TAIL_MERGE_WINDOW_MS) { - effectiveEnd = end; + const chunkEnd = new Date(currentStart.getTime() + DEFAULT_SYNC_WINDOW_MS) + let effectiveEnd = chunkEnd < end ? chunkEnd : end + if ( + chunkEnd < end && + end.getTime() - chunkEnd.getTime() <= TAIL_MERGE_WINDOW_MS + ) { + effectiveEnd = end } - - console.log('[syncCalendarToSheetGAS] Syncing chunk:', { start: currentStart.toISOString(), end: effectiveEnd.toISOString() }); - - _syncCalendarToSheetGAS(cfg, currentStart, effectiveEnd); - + + console.log('[syncCalendarToSheetGAS] Syncing chunk:', { + start: currentStart.toISOString(), + end: effectiveEnd.toISOString(), + }) + + _syncCalendarToSheetGAS(cfg, currentStart, effectiveEnd) + // Checkpoint after each successful chunk - saveLastSyncTime(cfg, effectiveEnd); - console.log('[syncCalendarToSheetGAS] Checkpointed:', effectiveEnd.toISOString()); - + saveLastSyncTime(cfg, effectiveEnd) + console.log( + '[syncCalendarToSheetGAS] Checkpointed:', + effectiveEnd.toISOString() + ) + // Move to next chunk - currentStart = effectiveEnd; - iterationCount++; + currentStart = effectiveEnd + iterationCount++ } - + if (iterationCount >= maxIterations) { - console.log('[syncCalendarToSheetGAS] Warning: reached maximum iteration limit'); + console.log( + '[syncCalendarToSheetGAS] Warning: reached maximum iteration limit' + ) } } function syncAllCalendarsToSheetsGAS(startIso, endIso) { - const cfgs = getConfigs(); + const cfgs = getConfigs() for (let i = 0; i < cfgs.length; i++) { try { - const checkpoint = getLastSyncTime(cfgs[i]); - const now = new Date(); - let start = startIso ? new Date(startIso) : checkpoint; - const end = endIso ? new Date(endIso) : now; - + const checkpoint = getLastSyncTime(cfgs[i]) + const now = new Date() + let start = startIso ? new Date(startIso) : checkpoint + const end = endIso ? new Date(endIso) : now + // Validate: if checkpoint is in the future, reset it if (start > end) { - console.log('[syncAllCalendarsToSheetsGAS] Warning: start time is after end time for calendar', cfgs[i].calendarId, ', resetting checkpoint'); - clearCheckpoint(cfgs[i]); - start = getLastSyncTime(cfgs[i]); + console.log( + '[syncAllCalendarsToSheetsGAS] Warning: start time is after end time for calendar', + cfgs[i].calendarId, + ', resetting checkpoint' + ) + clearCheckpoint(cfgs[i]) + start = getLastSyncTime(cfgs[i]) } - if ((now.getTime() - start.getTime()) <= DEFAULT_SYNC_WINDOW_MS) { - start = new Date(start.getTime() - DEFAULT_SYNC_WINDOW_MS); + if (now.getTime() - start.getTime() <= DEFAULT_SYNC_WINDOW_MS) { + start = new Date(start.getTime() - DEFAULT_SYNC_WINDOW_MS) } - + // Sync in 1-year chunks to prevent timeouts // After each chunk, checkpoint progress so we can resume if interrupted - let currentStart = start; - let iterationCount = 0; - const maxIterations = 100; // Safety limit to prevent infinite loops - + let currentStart = start + let iterationCount = 0 + const maxIterations = 100 // Safety limit to prevent infinite loops + while (currentStart < end && iterationCount < maxIterations) { // Calculate the end of this chunk (1 year from current start, but not beyond target end) - const chunkEnd = new Date(currentStart.getTime() + DEFAULT_SYNC_WINDOW_MS); - let effectiveEnd = chunkEnd < end ? chunkEnd : end; - if (chunkEnd < end && (end.getTime() - chunkEnd.getTime()) <= TAIL_MERGE_WINDOW_MS) { - effectiveEnd = end; + const chunkEnd = new Date( + currentStart.getTime() + DEFAULT_SYNC_WINDOW_MS + ) + let effectiveEnd = chunkEnd < end ? chunkEnd : end + if ( + chunkEnd < end && + end.getTime() - chunkEnd.getTime() <= TAIL_MERGE_WINDOW_MS + ) { + effectiveEnd = end } - - console.log('[syncAllCalendarsToSheetsGAS] Syncing chunk for calendar', cfgs[i].calendarId, ':', { start: currentStart.toISOString(), end: effectiveEnd.toISOString() }); - - _syncCalendarToSheetGAS(cfgs[i], currentStart, effectiveEnd); - + + console.log( + '[syncAllCalendarsToSheetsGAS] Syncing chunk for calendar', + cfgs[i].calendarId, + ':', + { start: currentStart.toISOString(), end: effectiveEnd.toISOString() } + ) + + _syncCalendarToSheetGAS(cfgs[i], currentStart, effectiveEnd) + // Checkpoint after each successful chunk - saveLastSyncTime(cfgs[i], effectiveEnd); - console.log('[syncAllCalendarsToSheetsGAS] Checkpointed calendar', cfgs[i].calendarId, ':', effectiveEnd.toISOString()); - + saveLastSyncTime(cfgs[i], effectiveEnd) + console.log( + '[syncAllCalendarsToSheetsGAS] Checkpointed calendar', + cfgs[i].calendarId, + ':', + effectiveEnd.toISOString() + ) + // Move to next chunk - currentStart = effectiveEnd; - iterationCount++; + currentStart = effectiveEnd + iterationCount++ } - + if (iterationCount >= maxIterations) { - console.log('[syncAllCalendarsToSheetsGAS] Warning: reached maximum iteration limit for calendar', cfgs[i].calendarId); + console.log( + '[syncAllCalendarsToSheetsGAS] Warning: reached maximum iteration limit for calendar', + cfgs[i].calendarId + ) } } catch (e) { // Log and continue with other calendars; do not advance checkpoint on failure if (typeof Logger !== 'undefined' && Logger.log) { - Logger.log('Error syncing calendar "' + ((cfgs[i] && cfgs[i].calendarId) || 'default') + '": ' + e); + Logger.log( + 'Error syncing calendar "' + + ((cfgs[i] && cfgs[i].calendarId) || 'default') + + '": ' + + e + ) } } } @@ -333,57 +422,86 @@ function syncAllCalendarsToSheetsGAS(startIso, endIso) { /** * Full resync: clears the sheet and checkpoint(s), then resyncs calendar(s) from the beginning of time (epoch). - * + * * @param {number|null} configIndex - Index of specific config to resync, or null/undefined to resync all - * + * * Uses chunking logic to process large date ranges in 1-year increments, preventing timeouts. * Checkpoints are saved after each chunk, allowing resumption if interrupted. */ function fullResyncCalendarToSheetGAS(configIndex) { - const cfgs = getConfigs(); - const start = new Date(0); - const end = new Date(); - + const cfgs = getConfigs() + const start = new Date(0) + const end = new Date() + // If configIndex is specified, sync only that config if (configIndex !== null && configIndex !== undefined) { - const cfg = cfgs[configIndex]; + const cfg = cfgs[configIndex] if (cfg) { - clearCheckpoint(cfg); + clearCheckpoint(cfg) // Clear sheet content (except header) before full resync - const ss = cfg && cfg.spreadsheetId ? SpreadsheetApp.openById(cfg.spreadsheetId) : SpreadsheetApp.getActiveSpreadsheet(); - const sheet = getOrCreateSheet(ss, cfg && cfg.sheetName ? cfg.sheetName : 'Sheet1'); - const data = sheet.getDataRange().getValues(); + const ss = + cfg && cfg.spreadsheetId + ? SpreadsheetApp.openById(cfg.spreadsheetId) + : SpreadsheetApp.getActiveSpreadsheet() + const sheet = getOrCreateSheet( + ss, + cfg && cfg.sheetName ? cfg.sheetName : 'Sheet1' + ) + const data = sheet.getDataRange().getValues() if (data.length > 1) { - sheet.deleteRows(2, data.length - 1); + sheet.deleteRows(2, data.length - 1) } - _syncCalendarToSheetGAS(cfg, start, end); - saveLastSyncTime(cfg, new Date()); + _syncCalendarToSheetGAS(cfg, start, end) + saveLastSyncTime(cfg, new Date()) } } else { // Otherwise, sync all configs // First loop: clear checkpoints and sheets for all configs for (let i = 0; i < cfgs.length; i++) { try { - clearCheckpoint(cfgs[i]); + clearCheckpoint(cfgs[i]) // Clear sheet content (except header) before full resync - const ss = cfgs[i] && cfgs[i].spreadsheetId ? SpreadsheetApp.openById(cfgs[i].spreadsheetId) : SpreadsheetApp.getActiveSpreadsheet(); - const sheet = getOrCreateSheet(ss, cfgs[i] && cfgs[i].sheetName ? cfgs[i].sheetName : 'Sheet1'); - const data = sheet.getDataRange().getValues(); + const ss = + cfgs[i] && cfgs[i].spreadsheetId + ? SpreadsheetApp.openById(cfgs[i].spreadsheetId) + : SpreadsheetApp.getActiveSpreadsheet() + const sheet = getOrCreateSheet( + ss, + cfgs[i] && cfgs[i].sheetName ? cfgs[i].sheetName : 'Sheet1' + ) + const data = sheet.getDataRange().getValues() if (data.length > 1) { - sheet.deleteRows(2, data.length - 1); + sheet.deleteRows(2, data.length - 1) } } catch (e) { if (typeof Logger !== 'undefined' && Logger.log) { - Logger.log('Error clearing calendar "' + ((cfgs[i] && cfgs[i].calendarId) || 'default') + '": ' + e); + Logger.log( + 'Error clearing calendar "' + + ((cfgs[i] && cfgs[i].calendarId) || 'default') + + '": ' + + e + ) } } } // Use syncAllCalendarsToSheetsGAS which handles chunking and error handling - syncAllCalendarsToSheetsGAS(start.toISOString(), end.toISOString()); + syncAllCalendarsToSheetsGAS(start.toISOString(), end.toISOString()) } } // Export for testing in Node environments if (typeof module !== 'undefined' && module.exports) { - module.exports = { getConfigs, getConfig, eventToRowGAS, sanitizeValue, syncCalendarToSheetGAS, syncAllCalendarsToSheetsGAS, getLastSyncTime, saveLastSyncTime, clearCheckpoint, getCheckpointKey, fullResyncCalendarToSheetGAS }; + module.exports = { + getConfigs, + getConfig, + eventToRowGAS, + sanitizeValue, + syncCalendarToSheetGAS, + syncAllCalendarsToSheetsGAS, + getLastSyncTime, + saveLastSyncTime, + clearCheckpoint, + getCheckpointKey, + fullResyncCalendarToSheetGAS, + } } diff --git a/src/calendar-to-sheets/config.gs b/src/calendar-to-sheets/config.gs index baf7f9da..c9e236a3 100644 --- a/src/calendar-to-sheets/config.gs +++ b/src/calendar-to-sheets/config.gs @@ -10,6 +10,4 @@ * ]; */ -var SYNC_CONFIGS = [ - { spreadsheetId: '', sheetName: 'Sheet1', calendarId: '' } -]; \ No newline at end of file +var SYNC_CONFIGS = [{ spreadsheetId: '', sheetName: 'Sheet1', calendarId: '' }] diff --git a/src/calendar-to-sheets/src/index.js b/src/calendar-to-sheets/src/index.js index 3315f2a1..1eb60e56 100644 --- a/src/calendar-to-sheets/src/index.js +++ b/src/calendar-to-sheets/src/index.js @@ -9,31 +9,33 @@ function sanitizeValue(val) { // Prevent formula injection by prefixing formula metacharacters with ' // Also catches leading whitespace/control chars followed by formula chars if (typeof val === 'string' && /^[\x00-\x20]*[=+\-@]/.test(val)) { - return "'" + val; + return "'" + val } - return val; + return val } function eventToRow(event) { - const id = event.getId(); - const title = sanitizeValue(event.getTitle()); - const start = event.getStartTime().toISOString(); - const end = event.getEndTime().toISOString(); - const description = sanitizeValue(event.getDescription() || ''); - const location = sanitizeValue(event.getLocation() || ''); - const attendees = (event.getGuestList() || []).map(g => g.getEmail()).join(','); - return [id, title, start, end, description, location, attendees]; + const id = event.getId() + const title = sanitizeValue(event.getTitle()) + const start = event.getStartTime().toISOString() + const end = event.getEndTime().toISOString() + const description = sanitizeValue(event.getDescription() || '') + const location = sanitizeValue(event.getLocation() || '') + const attendees = (event.getGuestList() || []) + .map((g) => g.getEmail()) + .join(',') + return [id, title, start, end, description, location, attendees] } function rowsToMap(rows) { // rows is array of arrays where first col is id - const m = new Map(); + const m = new Map() for (let i = 0; i < rows.length; i++) { - const r = rows[i]; - if (!r || !r[0]) continue; - m.set(r[0], { rowIndex: i + 2, values: r }); // assume header at row 1 + const r = rows[i] + if (!r || !r[0]) continue + m.set(r[0], { rowIndex: i + 2, values: r }) // assume header at row 1 } - return m; + return m } function rowsEqual(a, b) { @@ -41,130 +43,184 @@ function rowsEqual(a, b) { // columns (e.g., user notes) without affecting equality. // If b is shorter than a, b[i] will be undefined and won't match a[i]. for (let i = 0; i < a.length; i++) { - const valA = a[i]; - const valB = b[i]; + const valA = a[i] + const valB = b[i] - if (valA === valB) continue; + if (valA === valB) continue // Handle Date comparison (a is ISO string, b is Date object from sheet) if (typeof valA === 'string' && valB instanceof Date) { - const dateA = new Date(valA); - if (!isNaN(dateA) && dateA.getTime() === valB.getTime()) continue; + const dateA = new Date(valA) + if (!isNaN(dateA) && dateA.getTime() === valB.getTime()) continue } // Handle sanitized formula comparison (a has leading ', b does not) - if (typeof valA === 'string' && valA.startsWith("'") && valA.slice(1) === valB) { - continue; + if ( + typeof valA === 'string' && + valA.startsWith("'") && + valA.slice(1) === valB + ) { + continue } - return false; + return false } - return true; + return true } function ensureHeader(sheet) { // Ensure the sheet has a proper header row. If the sheet is empty or the first row // doesn't look like our expected header, create/replace it. - const expectedHeader = ['id', 'title', 'start', 'end', 'description', 'location', 'attendees']; - - const data = sheet.getDataRange().getValues(); - + const expectedHeader = [ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ] + + const data = sheet.getDataRange().getValues() + // If sheet is completely empty, add header if (!data || data.length === 0) { - sheet.getRange(1, 1, 1, expectedHeader.length).setValues([expectedHeader]); - return; + sheet.getRange(1, 1, 1, expectedHeader.length).setValues([expectedHeader]) + return } - + // Check if first row matches expected header - const firstRow = data[0]; - const isValidHeader = firstRow && - firstRow.length >= expectedHeader.length && - firstRow[0] === 'id' && - firstRow[1] === 'title' && - firstRow[2] === 'start' && - firstRow[3] === 'end'; - + const firstRow = data[0] + const isValidHeader = + firstRow && + firstRow.length >= expectedHeader.length && + firstRow[0] === 'id' && + firstRow[1] === 'title' && + firstRow[2] === 'start' && + firstRow[3] === 'end' + // If first row doesn't look like a header, insert one at the top if (!isValidHeader) { - sheet.insertRowBefore(1); - sheet.getRange(1, 1, 1, expectedHeader.length).setValues([expectedHeader]); + sheet.insertRowBefore(1) + sheet.getRange(1, 1, 1, expectedHeader.length).setValues([expectedHeader]) } } -async function syncCalendarToSheet(calendar, sheet, { start = new Date(0), end = new Date(Date.now() + 365*24*60*60*1000) } = {}) { +async function syncCalendarToSheet( + calendar, + sheet, + { + start = new Date(0), + end = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + } = {} +) { // Ensure header row exists - ensureHeader(sheet); - console.log('[syncCalendarToSheet] Starting sync with date range:', { start, end }); + ensureHeader(sheet) + console.log('[syncCalendarToSheet] Starting sync with date range:', { + start, + end, + }) // Fetch events - const events = calendar.getEvents(start, end); - console.log('[syncCalendarToSheet] Fetched events:', events.length); - const desired = events.map(eventToRow); - const desiredMap = new Map(desired.map(r => [r[0], r])); + const events = calendar.getEvents(start, end) + console.log('[syncCalendarToSheet] Fetched events:', events.length) + const desired = events.map(eventToRow) + const desiredMap = new Map(desired.map((r) => [r[0], r])) // Read existing rows - const data = sheet.getDataRange().getValues(); - const body = data.slice(1); - console.log('[syncCalendarToSheet] Existing rows:', body.length); - const existingMap = rowsToMap(body); + const data = sheet.getDataRange().getValues() + const body = data.slice(1) + console.log('[syncCalendarToSheet] Existing rows:', body.length) + const existingMap = rowsToMap(body) // Upsert - let updateCount = 0; - const rowsToInsert = []; + let updateCount = 0 + const rowsToInsert = [] for (const [id, row] of desiredMap.entries()) { if (existingMap.has(id)) { - const ex = existingMap.get(id); + const ex = existingMap.get(id) if (!rowsEqual(row, ex.values)) { // update - console.log('[syncCalendarToSheet] Updating row for event:', id); - const rowIndex = ex.rowIndex; - sheet.getRange(rowIndex, 1, 1, row.length).setValues([row]); - updateCount++; + console.log('[syncCalendarToSheet] Updating row for event:', id) + const rowIndex = ex.rowIndex + sheet.getRange(rowIndex, 1, 1, row.length).setValues([row]) + updateCount++ } } else { - rowsToInsert.push(row); + rowsToInsert.push(row) } } if (rowsToInsert.length > 0) { - console.log('[syncCalendarToSheet] Inserting new events:', rowsToInsert.length); + console.log( + '[syncCalendarToSheet] Inserting new events:', + rowsToInsert.length + ) if (typeof sheet.getLastRow === 'function') { - sheet.getRange(sheet.getLastRow() + 1, 1, rowsToInsert.length, rowsToInsert[0].length).setValues(rowsToInsert); + sheet + .getRange( + sheet.getLastRow() + 1, + 1, + rowsToInsert.length, + rowsToInsert[0].length + ) + .setValues(rowsToInsert) } else { - rowsToInsert.forEach((row) => sheet.appendRow(row)); + rowsToInsert.forEach((row) => sheet.appendRow(row)) } } - console.log('[syncCalendarToSheet] Updates:', updateCount, 'Inserts:', rowsToInsert.length); + console.log( + '[syncCalendarToSheet] Updates:', + updateCount, + 'Inserts:', + rowsToInsert.length + ) // Delete rows for events that no longer exist, but only if they fall within // the synced time window to avoid deleting rows from events outside [start,end] - const toDelete = []; + const toDelete = [] for (const [id, ex] of existingMap.entries()) { if (!desiredMap.has(id)) { // Only delete if the row has start/end columns and falls within [start,end] // Otherwise, preserve historical rows outside the sync window - const rowStart = ex.values[2]; // start is at index 2 - const rowEnd = ex.values[3]; // end is at index 3 + const rowStart = ex.values[2] // start is at index 2 + const rowEnd = ex.values[3] // end is at index 3 if (rowStart && rowEnd) { - const rowStartTime = new Date(rowStart); + const rowStartTime = new Date(rowStart) // Only delete if row's event time falls within our sync window - if (!isNaN(rowStartTime) && rowStartTime >= start && rowStartTime <= end) { - console.log('[syncCalendarToSheet] Marking event for deletion:', id); - toDelete.push(ex.rowIndex); + if ( + !isNaN(rowStartTime) && + rowStartTime >= start && + rowStartTime <= end + ) { + console.log('[syncCalendarToSheet] Marking event for deletion:', id) + toDelete.push(ex.rowIndex) } else { - console.log('[syncCalendarToSheet] Preserving event outside sync window:', id); + console.log( + '[syncCalendarToSheet] Preserving event outside sync window:', + id + ) } } else { // If no valid date columns, don't delete (preserve historical data) - console.log('[syncCalendarToSheet] Preserving event with invalid dates:', id); + console.log( + '[syncCalendarToSheet] Preserving event with invalid dates:', + id + ) } } } // delete from bottom to top - console.log('[syncCalendarToSheet] Deleting rows:', toDelete.length); - toDelete.sort((a,b) => b - a).forEach(r => sheet.deleteRow(r)); - console.log('[syncCalendarToSheet] Sync complete'); + console.log('[syncCalendarToSheet] Deleting rows:', toDelete.length) + toDelete.sort((a, b) => b - a).forEach((r) => sheet.deleteRow(r)) + console.log('[syncCalendarToSheet] Sync complete') } -module.exports = { eventToRow, syncCalendarToSheet, rowsEqual, rowsToMap, ensureHeader }; +module.exports = { + eventToRow, + syncCalendarToSheet, + rowsEqual, + rowsToMap, + ensureHeader, +} diff --git a/src/calendar-to-sheets/tests/index.test.js b/src/calendar-to-sheets/tests/index.test.js index 4c89a9ac..2a5ae07f 100644 --- a/src/calendar-to-sheets/tests/index.test.js +++ b/src/calendar-to-sheets/tests/index.test.js @@ -1,429 +1,606 @@ -const { installGlobals, resetAll, createCalendarEvent } = require('../../../test-utils/mocks'); -const { eventToRow, syncCalendarToSheet, rowsEqual, rowsToMap } = require('../src/index'); +const { + installGlobals, + resetAll, + createCalendarEvent, +} = require('../../../test-utils/mocks') +const { + eventToRow, + syncCalendarToSheet, + rowsEqual, + rowsToMap, +} = require('../src/index') beforeEach(() => { - installGlobals(global); + installGlobals(global) // prepare sheet header - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); -}); - -afterEach(() => resetAll(global)); + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) +}) + +afterEach(() => resetAll(global)) test('eventToRow includes attendees and dates', () => { - const evt = createCalendarEvent({ id: 'e1', title: 'Meeting', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'desc', location: 'HQ', attendees: ['a@example.com','b@example.com'] }); - const row = eventToRow(evt); - expect(row[0]).toBe('e1'); - expect(row[1]).toBe('Meeting'); - expect(row[2]).toBe(new Date('2026-02-02T10:00:00Z').toISOString()); - expect(row[6]).toBe('a@example.com,b@example.com'); -}); + const evt = createCalendarEvent({ + id: 'e1', + title: 'Meeting', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'desc', + location: 'HQ', + attendees: ['a@example.com', 'b@example.com'], + }) + const row = eventToRow(evt) + expect(row[0]).toBe('e1') + expect(row[1]).toBe('Meeting') + expect(row[2]).toBe(new Date('2026-02-02T10:00:00Z').toISOString()) + expect(row[6]).toBe('a@example.com,b@example.com') +}) test('syncCalendarToSheet adds, updates, and deletes rows correctly', async () => { - const calendar = CalendarApp.getDefaultCalendar(); - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); + const calendar = CalendarApp.getDefaultCalendar() + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') // Add events - const evt1 = createCalendarEvent({ id: 'e1', title: 'Meeting A', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'd1', location: 'L1', attendees: ['a@example.com'] }); - const evt2 = createCalendarEvent({ id: 'e2', title: 'Meeting B', start: new Date('2026-02-02T12:00:00Z'), end: new Date('2026-02-02T13:00:00Z'), description: 'd2', location: 'L2', attendees: [] }); - calendar.__addEvent(evt1); - calendar.__addEvent(evt2); - - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-03') }); - - const rows = sheet.__getRows(); - expect(rows.length).toBe(2); - expect(rows[0][0]).toBe('e1'); - expect(rows[1][0]).toBe('e2'); + const evt1 = createCalendarEvent({ + id: 'e1', + title: 'Meeting A', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd1', + location: 'L1', + attendees: ['a@example.com'], + }) + const evt2 = createCalendarEvent({ + id: 'e2', + title: 'Meeting B', + start: new Date('2026-02-02T12:00:00Z'), + end: new Date('2026-02-02T13:00:00Z'), + description: 'd2', + location: 'L2', + attendees: [], + }) + calendar.__addEvent(evt1) + calendar.__addEvent(evt2) + + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-03'), + }) + + const rows = sheet.__getRows() + expect(rows.length).toBe(2) + expect(rows[0][0]).toBe('e1') + expect(rows[1][0]).toBe('e2') // Update evt1 title and attendees - const evt1b = createCalendarEvent({ id: 'e1', title: 'Meeting A updated', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'd1', location: 'L1', attendees: ['a@example.com','c@example.com'] }); - calendar.__reset(); - calendar.__addEvent(evt1b); - calendar.__addEvent(evt2); - - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-03') }); - - const rows2 = sheet.__getRows(); - expect(rows2.length).toBe(2); - const e1row = rows2.find(r => r[0] === 'e1'); - expect(e1row[1]).toBe('Meeting A updated'); - expect(e1row[6]).toBe('a@example.com,c@example.com'); + const evt1b = createCalendarEvent({ + id: 'e1', + title: 'Meeting A updated', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd1', + location: 'L1', + attendees: ['a@example.com', 'c@example.com'], + }) + calendar.__reset() + calendar.__addEvent(evt1b) + calendar.__addEvent(evt2) + + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-03'), + }) + + const rows2 = sheet.__getRows() + expect(rows2.length).toBe(2) + const e1row = rows2.find((r) => r[0] === 'e1') + expect(e1row[1]).toBe('Meeting A updated') + expect(e1row[6]).toBe('a@example.com,c@example.com') // Remove evt2 - calendar.__reset(); - calendar.__addEvent(evt1b); - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-03') }); - const rows3 = sheet.__getRows(); - expect(rows3.length).toBe(1); - expect(rows3[0][0]).toBe('e1'); -}); + calendar.__reset() + calendar.__addEvent(evt1b) + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-03'), + }) + const rows3 = sheet.__getRows() + expect(rows3.length).toBe(1) + expect(rows3[0][0]).toBe('e1') +}) test('rowsToMap builds correct mapping and rowsEqual works', () => { - const rows = [['e1','a'], ['e2','b']]; - const m = rowsToMap(rows); - expect(m.has('e1')).toBe(true); - expect(m.get('e2').rowIndex).toBe(3); // header row considered - expect(rowsEqual(['a','b'], ['a','b'])).toBe(true); - expect(rowsEqual(['a','b'], ['a','c'])).toBe(false); + const rows = [ + ['e1', 'a'], + ['e2', 'b'], + ] + const m = rowsToMap(rows) + expect(m.has('e1')).toBe(true) + expect(m.get('e2').rowIndex).toBe(3) // header row considered + expect(rowsEqual(['a', 'b'], ['a', 'b'])).toBe(true) + expect(rowsEqual(['a', 'b'], ['a', 'c'])).toBe(false) // extra trailing columns in b are ignored - expect(rowsEqual(['a','b'], ['a','b','extra','columns'])).toBe(true); - expect(rowsEqual(['a','b'], ['a','c','extra','columns'])).toBe(false); -}); + expect(rowsEqual(['a', 'b'], ['a', 'b', 'extra', 'columns'])).toBe(true) + expect(rowsEqual(['a', 'b'], ['a', 'c', 'extra', 'columns'])).toBe(false) +}) test('rowsEqual handles Date objects and escaped strings', () => { - const isoString = '2026-02-02T10:00:00.000Z'; - const dateObj = new Date(isoString); + const isoString = '2026-02-02T10:00:00.000Z' + const dateObj = new Date(isoString) // Date comparison (ISO string from event vs Date object from sheet) - expect(rowsEqual([isoString], [dateObj])).toBe(true); - expect(rowsEqual([isoString], [new Date('2026-02-02T11:00:00.000Z')])).toBe(false); + expect(rowsEqual([isoString], [dateObj])).toBe(true) + expect(rowsEqual([isoString], [new Date('2026-02-02T11:00:00.000Z')])).toBe( + false + ) // Escaped string comparison (Sanitized string vs Raw string from sheet) - expect(rowsEqual(["'=SUM(1,2)"], ["=SUM(1,2)"])).toBe(true); - expect(rowsEqual(["'=SUM(1,2)"], ["=SUM(3,4)"])).toBe(false); - expect(rowsEqual(['id', isoString, "'=CMD"], ['id', dateObj, "=CMD"])).toBe(true); -}); + expect(rowsEqual(["'=SUM(1,2)"], ['=SUM(1,2)'])).toBe(true) + expect(rowsEqual(["'=SUM(1,2)"], ['=SUM(3,4)'])).toBe(false) + expect(rowsEqual(['id', isoString, "'=CMD"], ['id', dateObj, '=CMD'])).toBe( + true + ) +}) test('eventToRow handles missing optional fields', () => { - const evt = createCalendarEvent({ id: 'e3', title: 'No extras', start: new Date('2026-02-03T10:00:00Z'), end: new Date('2026-02-03T11:00:00Z') }); - const row = eventToRow(evt); - expect(row[4]).toBe(''); // description - expect(row[5]).toBe(''); // location - expect(row[6]).toBe(''); // attendees -}); + const evt = createCalendarEvent({ + id: 'e3', + title: 'No extras', + start: new Date('2026-02-03T10:00:00Z'), + end: new Date('2026-02-03T11:00:00Z'), + }) + const row = eventToRow(evt) + expect(row[4]).toBe('') // description + expect(row[5]).toBe('') // location + expect(row[6]).toBe('') // attendees +}) test('syncCalendarToSheet skips update when rows are equal', async () => { - const calendar = CalendarApp.getDefaultCalendar(); - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - - const evt = createCalendarEvent({ id: 'e4', title: 'Stable meeting', start: new Date('2026-02-04T10:00:00Z'), end: new Date('2026-02-04T11:00:00Z'), description: 'x', location: 'L', attendees: [] }); - calendar.__addEvent(evt); - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-06') }); - const before = JSON.stringify(sheet.__getRows()); + const calendar = CalendarApp.getDefaultCalendar() + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + + const evt = createCalendarEvent({ + id: 'e4', + title: 'Stable meeting', + start: new Date('2026-02-04T10:00:00Z'), + end: new Date('2026-02-04T11:00:00Z'), + description: 'x', + location: 'L', + attendees: [], + }) + calendar.__addEvent(evt) + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-06'), + }) + const before = JSON.stringify(sheet.__getRows()) // No changes - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-06') }); - const after = JSON.stringify(sheet.__getRows()); - expect(before).toBe(after); -}); + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-06'), + }) + const after = JSON.stringify(sheet.__getRows()) + expect(before).toBe(after) +}) test('syncCalendarToSheet ignores extra user columns when comparing rows', async () => { - const calendar = CalendarApp.getDefaultCalendar(); - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); + const calendar = CalendarApp.getDefaultCalendar() + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + + const evt = createCalendarEvent({ + id: 'e_extra', + title: 'Meeting with notes', + start: new Date('2026-02-04T10:00:00Z'), + end: new Date('2026-02-04T11:00:00Z'), + description: 'desc', + location: 'L', + attendees: ['a@example.com'], + }) + calendar.__addEvent(evt) - const evt = createCalendarEvent({ id: 'e_extra', title: 'Meeting with notes', start: new Date('2026-02-04T10:00:00Z'), end: new Date('2026-02-04T11:00:00Z'), description: 'desc', location: 'L', attendees: ['a@example.com'] }); - calendar.__addEvent(evt); - // First sync - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-06') }); - + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-06'), + }) + // Simulate user adding extra columns (notes) to the row - const rows = sheet.__getRows(); - const targetRow = rows.find(r => r[0] === 'e_extra'); - targetRow.push('User note 1', 'User note 2', 'Extra data'); - + const rows = sheet.__getRows() + const targetRow = rows.find((r) => r[0] === 'e_extra') + targetRow.push('User note 1', 'User note 2', 'Extra data') + // Second sync - should NOT update the row because script-owned columns are identical - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-06') }); - - const rowsAfter = sheet.__getRows(); - const rowAfter = rowsAfter.find(r => r[0] === 'e_extra'); - + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-06'), + }) + + const rowsAfter = sheet.__getRows() + const rowAfter = rowsAfter.find((r) => r[0] === 'e_extra') + // User notes should still be there (row was not rewritten) - expect(rowAfter.length).toBeGreaterThan(7); // more than the 7 script columns - expect(rowAfter[7]).toBe('User note 1'); - expect(rowAfter[8]).toBe('User note 2'); - expect(rowAfter[9]).toBe('Extra data'); -}); + expect(rowAfter.length).toBeGreaterThan(7) // more than the 7 script columns + expect(rowAfter[7]).toBe('User note 1') + expect(rowAfter[8]).toBe('User note 2') + expect(rowAfter[9]).toBe('Extra data') +}) test('rowsToMap skips empty rows and sync uses default date range', async () => { - const calendar = CalendarApp.getDefaultCalendar(); - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); + const calendar = CalendarApp.getDefaultCalendar() + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') // Put some invalid rows in sheet data - sheet.__getRows().push([null]); - sheet.__getRows().push([]); + sheet.__getRows().push([null]) + sheet.__getRows().push([]) - const m = rowsToMap(sheet.__getRows()); - expect(m.has(null)).toBe(false); + const m = rowsToMap(sheet.__getRows()) + expect(m.has(null)).toBe(false) // Add an event and call sync without start/end to use defaults - const evt = createCalendarEvent({ id: 'e5', title: 'Default range', start: new Date(), end: new Date(Date.now()+3600000) }); - calendar.__addEvent(evt); - await syncCalendarToSheet(calendar, sheet); - const rows = sheet.__getRows(); - expect(rows.find(r => r[0] === 'e5')).toBeTruthy(); -}); + const evt = createCalendarEvent({ + id: 'e5', + title: 'Default range', + start: new Date(), + end: new Date(Date.now() + 3600000), + }) + calendar.__addEvent(evt) + await syncCalendarToSheet(calendar, sheet) + const rows = sheet.__getRows() + expect(rows.find((r) => r[0] === 'e5')).toBeTruthy() +}) test('eventToRow handles null guest list and sync handles empty data array', async () => { // eventToRow with null guest list - const evt = { getId: () => 'enul', getTitle: () => 'NoGuests', getStartTime: () => new Date('2026-02-05T10:00:00Z'), getEndTime: () => new Date('2026-02-05T11:00:00Z'), getDescription: () => null, getLocation: () => null, getGuestList: () => null }; - const row = eventToRow(evt); - expect(row[6]).toBe(''); + const evt = { + getId: () => 'enul', + getTitle: () => 'NoGuests', + getStartTime: () => new Date('2026-02-05T10:00:00Z'), + getEndTime: () => new Date('2026-02-05T11:00:00Z'), + getDescription: () => null, + getLocation: () => null, + getGuestList: () => null, + } + const row = eventToRow(evt) + expect(row[6]).toBe('') // sync with a sheet that returns empty getValues array - const calendar = CalendarApp.getDefaultCalendar(); - calendar.__reset(); - calendar.__addEvent(createCalendarEvent({ id: 'enul', title: 'NoGuests', start: new Date(), end: new Date(Date.now()+1000) })); + const calendar = CalendarApp.getDefaultCalendar() + calendar.__reset() + calendar.__addEvent( + createCalendarEvent({ + id: 'enul', + title: 'NoGuests', + start: new Date(), + end: new Date(Date.now() + 1000), + }) + ) const sheet = { getDataRange: () => ({ getValues: () => [] }), - appendRow: (r) => { sheet._rows = sheet._rows || []; sheet._rows.push(r); }, + appendRow: (r) => { + sheet._rows = sheet._rows || [] + sheet._rows.push(r) + }, getRange: () => ({ setValues: () => {} }), deleteRow: () => {}, - __getRows: () => sheet._rows || [] - }; + __getRows: () => sheet._rows || [], + } - await syncCalendarToSheet(calendar, sheet); - expect(sheet.__getRows().find(r => r[0] === 'enul')).toBeTruthy(); -}); + await syncCalendarToSheet(calendar, sheet) + expect(sheet.__getRows().find((r) => r[0] === 'enul')).toBeTruthy() +}) test('syncCalendarToSheet deletes multiple rows and calls sort comparator', async () => { - const calendar = CalendarApp.getDefaultCalendar(); - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); + const calendar = CalendarApp.getDefaultCalendar() + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') // pre-populate sheet with two rows that have dates within the sync window - const start = new Date('2026-02-01'); - const end = new Date('2026-02-03'); - sheet.__getRows().push(['x1', 'A', start.toISOString(), end.toISOString()]); - sheet.__getRows().push(['x2', 'B', start.toISOString(), end.toISOString()]); + const start = new Date('2026-02-01') + const end = new Date('2026-02-03') + sheet.__getRows().push(['x1', 'A', start.toISOString(), end.toISOString()]) + sheet.__getRows().push(['x2', 'B', start.toISOString(), end.toISOString()]) // ensure calendar is empty - calendar.__reset(); + calendar.__reset() - await syncCalendarToSheet(calendar, sheet, { start, end }); + await syncCalendarToSheet(calendar, sheet, { start, end }) - expect(sheet.__getRows().length).toBe(0); -}); + expect(sheet.__getRows().length).toBe(0) +}) // Ensure the GAS wrapper can sync multiple configs in SYNC_CONFIGS test('syncAllCalendarsToSheetsGAS syncs multiple configs to multiple sheets', async () => { - const code = require('../code.gs'); + const code = require('../code.gs') // Use two configs pointing at the same spreadsheet but different sheets global.SYNC_CONFIGS = [ { spreadsheetId: 'ss1', sheetName: 'SheetA', calendarId: '' }, - { spreadsheetId: 'ss1', sheetName: 'SheetB', calendarId: '' } - ]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheetA = ss.getSheetByName('SheetA'); - const sheetB = ss.getSheetByName('SheetB'); - sheetA.__setHeader(['id','title','start','end','description','location','attendees']); - sheetB.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ id: 'em', title: 'MultiEvent', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'd', location: 'L', attendees: [] }); - calendar.__addEvent(evt); - - await code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03'); - - expect(sheetA.__getRows().find(r => r[0] === 'em')).toBeTruthy(); - expect(sheetB.__getRows().find(r => r[0] === 'em')).toBeTruthy(); - - delete global.SYNC_CONFIGS; -}); + { spreadsheetId: 'ss1', sheetName: 'SheetB', calendarId: '' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheetA = ss.getSheetByName('SheetA') + const sheetB = ss.getSheetByName('SheetB') + sheetA.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + sheetB.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'em', + title: 'MultiEvent', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd', + location: 'L', + attendees: [], + }) + calendar.__addEvent(evt) + + await code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03') + + expect(sheetA.__getRows().find((r) => r[0] === 'em')).toBeTruthy() + expect(sheetB.__getRows().find((r) => r[0] === 'em')).toBeTruthy() + + delete global.SYNC_CONFIGS +}) // Test checkpoint logic for avoiding reprocessing old events describe('Checkpoint logic (GAS only)', () => { beforeEach(() => { - installGlobals(global); - }); + installGlobals(global) + }) - afterEach(() => resetAll(global)); + afterEach(() => resetAll(global)) test('getCheckpointKey returns consistent key for config', () => { - const code = require('../code.gs'); - const cfg = { calendarId: 'cal123' }; - const key = code.getCheckpointKey(cfg); - expect(key).toBe('calendar_to_sheets_last_sync_cal123'); - }); + const code = require('../code.gs') + const cfg = { calendarId: 'cal123' } + const key = code.getCheckpointKey(cfg) + expect(key).toBe('calendar_to_sheets_last_sync_cal123') + }) test('getLastSyncTime defaults to epoch on first run', () => { - const code = require('../code.gs'); - const cfg = { calendarId: 'new_cal' }; - const lastSync = code.getLastSyncTime(cfg); + const code = require('../code.gs') + const cfg = { calendarId: 'new_cal' } + const lastSync = code.getLastSyncTime(cfg) // Should be epoch - expect(lastSync.getTime()).toBe(0); - expect(lastSync.toISOString()).toBe('1970-01-01T00:00:00.000Z'); - }); + expect(lastSync.getTime()).toBe(0) + expect(lastSync.toISOString()).toBe('1970-01-01T00:00:00.000Z') + }) test('saveLastSyncTime and getLastSyncTime persist checkpoint', () => { - const code = require('../code.gs'); - const cfg = { calendarId: 'cal456' }; - const testTime = new Date('2026-01-15T10:00:00Z'); - - code.saveLastSyncTime(cfg, testTime); - const retrieved = code.getLastSyncTime(cfg); - - expect(retrieved.getTime()).toBe(testTime.getTime()); - }); + const code = require('../code.gs') + const cfg = { calendarId: 'cal456' } + const testTime = new Date('2026-01-15T10:00:00Z') + + code.saveLastSyncTime(cfg, testTime) + const retrieved = code.getLastSyncTime(cfg) + + expect(retrieved.getTime()).toBe(testTime.getTime()) + }) test('clearCheckpoint removes saved sync time', () => { - const code = require('../code.gs'); - const cfg = { calendarId: 'cal789' }; - const testTime = new Date('2026-01-15T10:00:00Z'); - - code.saveLastSyncTime(cfg, testTime); - code.clearCheckpoint(cfg); - - const retrieved = code.getLastSyncTime(cfg); + const code = require('../code.gs') + const cfg = { calendarId: 'cal789' } + const testTime = new Date('2026-01-15T10:00:00Z') + + code.saveLastSyncTime(cfg, testTime) + code.clearCheckpoint(cfg) + + const retrieved = code.getLastSyncTime(cfg) // Should be reset to epoch - expect(retrieved.getTime()).toBe(0); - expect(retrieved.toISOString()).toBe('1970-01-01T00:00:00.000Z'); - }); + expect(retrieved.getTime()).toBe(0) + expect(retrieved.toISOString()).toBe('1970-01-01T00:00:00.000Z') + }) test('getLastSyncTime resets invalid checkpoint to epoch', () => { - const code = require('../code.gs'); - const cfg = { calendarId: 'cal_invalid' }; - + const code = require('../code.gs') + const cfg = { calendarId: 'cal_invalid' } + // Simulate various corrupted checkpoint values - const key = code.getCheckpointKey(cfg); - + const key = code.getCheckpointKey(cfg) + // Test with NaN-producing values - const invalidValues = ['NaN', 'null', 'undefined', 'invalid', '', 'abc123']; - + const invalidValues = ['NaN', 'null', 'undefined', 'invalid', '', 'abc123'] + for (const invalidValue of invalidValues) { - PropertiesService.getUserProperties().setProperty(key, invalidValue); - const retrieved = code.getLastSyncTime(cfg); - + PropertiesService.getUserProperties().setProperty(key, invalidValue) + const retrieved = code.getLastSyncTime(cfg) + // Should reset to epoch (beginning of time) - expect(retrieved.getTime()).toBe(0); - expect(retrieved.toISOString()).toBe('1970-01-01T00:00:00.000Z'); + expect(retrieved.getTime()).toBe(0) + expect(retrieved.toISOString()).toBe('1970-01-01T00:00:00.000Z') } - + // Test with a very large invalid number that creates Invalid Date - PropertiesService.getUserProperties().setProperty(key, '999999999999999999'); - const retrieved2 = code.getLastSyncTime(cfg); - expect(retrieved2.getTime()).toBe(0); - }); + PropertiesService.getUserProperties().setProperty(key, '999999999999999999') + const retrieved2 = code.getLastSyncTime(cfg) + expect(retrieved2.getTime()).toBe(0) + }) test('syncCalendarToSheetGAS syncs in 1-year chunks with checkpoints', () => { - const code = require('../code.gs'); - - delete global.SYNC_CONFIGS; - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; - - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - + const code = require('../code.gs') + + delete global.SYNC_CONFIGS + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + // Add events across multiple years - const evt2024 = createCalendarEvent({ - id: 'e_2024', - title: 'Event 2024', - start: new Date('2024-06-01T10:00:00Z'), - end: new Date('2024-06-01T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - const evt2025 = createCalendarEvent({ - id: 'e_2025', - title: 'Event 2025', - start: new Date('2025-06-01T10:00:00Z'), - end: new Date('2025-06-01T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - const evt2026 = createCalendarEvent({ - id: 'e_2026', - title: 'Event 2026', - start: new Date('2026-01-15T10:00:00Z'), - end: new Date('2026-01-15T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - - calendar.__addEvent(evt2024); - calendar.__addEvent(evt2025); - calendar.__addEvent(evt2026); - + const evt2024 = createCalendarEvent({ + id: 'e_2024', + title: 'Event 2024', + start: new Date('2024-06-01T10:00:00Z'), + end: new Date('2024-06-01T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + const evt2025 = createCalendarEvent({ + id: 'e_2025', + title: 'Event 2025', + start: new Date('2025-06-01T10:00:00Z'), + end: new Date('2025-06-01T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + const evt2026 = createCalendarEvent({ + id: 'e_2026', + title: 'Event 2026', + start: new Date('2026-01-15T10:00:00Z'), + end: new Date('2026-01-15T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + + calendar.__addEvent(evt2024) + calendar.__addEvent(evt2025) + calendar.__addEvent(evt2026) + // Sync from 2024-01-01 to 2026-02-01 (over 2 years) - code.syncCalendarToSheetGAS('2024-01-01', '2026-02-01'); - + code.syncCalendarToSheetGAS('2024-01-01', '2026-02-01') + // All events should be synced - const rows = sheet.__getRows(); - expect(rows.length).toBe(3); - expect(rows.find(r => r[0] === 'e_2024')).toBeTruthy(); - expect(rows.find(r => r[0] === 'e_2025')).toBeTruthy(); - expect(rows.find(r => r[0] === 'e_2026')).toBeTruthy(); - + const rows = sheet.__getRows() + expect(rows.length).toBe(3) + expect(rows.find((r) => r[0] === 'e_2024')).toBeTruthy() + expect(rows.find((r) => r[0] === 'e_2025')).toBeTruthy() + expect(rows.find((r) => r[0] === 'e_2026')).toBeTruthy() + // Checkpoint should be at the end date - const cfg = code.getConfig(); - const lastSync = code.getLastSyncTime(cfg); - expect(lastSync.toISOString()).toBe('2026-02-01T00:00:00.000Z'); - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + const cfg = code.getConfig() + const lastSync = code.getLastSyncTime(cfg) + expect(lastSync.toISOString()).toBe('2026-02-01T00:00:00.000Z') + + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) test('syncCalendarToSheetGAS saves checkpoint after successful sync', () => { - const code = require('../code.gs'); - + const code = require('../code.gs') + // Clear any existing config - delete global.SYNC_CONFIGS; - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; - - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ id: 'e1', title: 'Test', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'd', location: 'L', attendees: [] }); - calendar.__addEvent(evt); - - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - - const cfg = code.getConfig(); - const lastSync = code.getLastSyncTime(cfg); - // Checkpoint should be saved as the end date parameter - expect(lastSync.toISOString()).toBe('2026-02-03T00:00:00.000Z'); - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; - }); + delete global.SYNC_CONFIGS + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e1', + title: 'Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd', + location: 'L', + attendees: [], + }) + calendar.__addEvent(evt) - test('syncCalendarToSheetGAS expands recent start by one window', () => { - const code = require('../code.gs'); + code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-02-07T00:00:00Z')); + const cfg = code.getConfig() + const lastSync = code.getLastSyncTime(cfg) + // Checkpoint should be saved as the end date parameter + expect(lastSync.toISOString()).toBe('2026-02-03T00:00:00.000Z') - try { - delete global.SYNC_CONFIGS; - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID + }) - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; + test('syncCalendarToSheetGAS expands recent start by one window', () => { + const code = require('../code.gs') - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-02-07T00:00:00Z')) - const calendar = CalendarApp.getDefaultCalendar(); + try { + delete global.SYNC_CONFIGS + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() const evt = createCalendarEvent({ id: 'e_recent_window', title: 'Recent Window Event', @@ -431,830 +608,1091 @@ describe('Checkpoint logic (GAS only)', () => { end: new Date('2025-06-01T11:00:00Z'), description: 'd', location: 'L', - attendees: [] - }); - calendar.__addEvent(evt); + attendees: [], + }) + calendar.__addEvent(evt) - code.syncCalendarToSheetGAS('2026-01-15', '2026-02-01'); + code.syncCalendarToSheetGAS('2026-01-15', '2026-02-01') - expect(sheet.__getRows().find(r => r[0] === 'e_recent_window')).toBeTruthy(); + expect( + sheet.__getRows().find((r) => r[0] === 'e_recent_window') + ).toBeTruthy() } finally { - jest.useRealTimers(); - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; + jest.useRealTimers() + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID } - }); + }) test('syncCalendarToSheetGAS resets checkpoint when start is in the future', () => { - const code = require('../code.gs'); - + const code = require('../code.gs') + // Clear any existing config - delete global.SYNC_CONFIGS; - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; - - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ id: 'e_future', title: 'Test', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'd', location: 'L', attendees: [] }); - calendar.__addEvent(evt); - - const cfg = code.getConfig(); + delete global.SYNC_CONFIGS + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e_future', + title: 'Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd', + location: 'L', + attendees: [], + }) + calendar.__addEvent(evt) + + const cfg = code.getConfig() // Set checkpoint to a future date (simulating old bug where end dates were saved) - const futureDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); // 1 year in future - code.saveLastSyncTime(cfg, futureDate); + const futureDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year in future + code.saveLastSyncTime(cfg, futureDate) // Call without date parameters to trigger checkpoint validation - code.syncCalendarToSheetGAS(); + code.syncCalendarToSheetGAS() // Verify checkpoint was reset to reasonable past date - const lastSync = code.getLastSyncTime(cfg); - const now = Date.now(); - expect(lastSync.getTime()).toBeLessThanOrEqual(now); - expect(lastSync.getTime()).toBeGreaterThan(now - 10000); // Should be very recent (just reset) - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; - }); + const lastSync = code.getLastSyncTime(cfg) + const now = Date.now() + expect(lastSync.getTime()).toBeLessThanOrEqual(now) + expect(lastSync.getTime()).toBeGreaterThan(now - 10000) // Should be very recent (just reset) + + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID + }) test('syncAllCalendarsToSheetsGAS handles errors and continues with other calendars', () => { - const code = require('../code.gs'); - + const code = require('../code.gs') + global.SYNC_CONFIGS = [ { spreadsheetId: 'ss1', sheetName: 'SheetA', calendarId: 'cal1' }, - { spreadsheetId: 'ss1', sheetName: 'SheetB', calendarId: 'cal2' } - ]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheetA = ss.getSheetByName('SheetA'); - const sheetB = ss.getSheetByName('SheetB'); - sheetA.__setHeader(['id','title','start','end','description','location','attendees']); - sheetB.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ id: 'e2', title: 'Test2', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'd', location: 'L', attendees: [] }); - calendar.__addEvent(evt); + { spreadsheetId: 'ss1', sheetName: 'SheetB', calendarId: 'cal2' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheetA = ss.getSheetByName('SheetA') + const sheetB = ss.getSheetByName('SheetB') + sheetA.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + sheetB.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e2', + title: 'Test2', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd', + location: 'L', + attendees: [], + }) + calendar.__addEvent(evt) // Mock Logger for error handling - global.Logger = { log: jest.fn() }; + global.Logger = { log: jest.fn() } - code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03'); + code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03') // Both sheets should have the event - expect(sheetA.__getRows().find(r => r[0] === 'e2')).toBeTruthy(); - expect(sheetB.__getRows().find(r => r[0] === 'e2')).toBeTruthy(); + expect(sheetA.__getRows().find((r) => r[0] === 'e2')).toBeTruthy() + expect(sheetB.__getRows().find((r) => r[0] === 'e2')).toBeTruthy() - delete global.SYNC_CONFIGS; - delete global.Logger; - }); + delete global.SYNC_CONFIGS + delete global.Logger + }) test('syncAllCalendarsToSheetsGAS resets checkpoint when start is in the future', () => { - const code = require('../code.gs'); - - global.SYNC_CONFIGS = [ - { spreadsheetId: 'ss1', sheetName: 'SheetA', calendarId: 'cal1' } - ]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheetA = ss.getSheetByName('SheetA'); - sheetA.__setHeader(['id','title','start','end','description','location','attendees']); + const code = require('../code.gs') - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ id: 'e_future_multi', title: 'Test', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'd', location: 'L', attendees: [] }); - calendar.__addEvent(evt); + global.SYNC_CONFIGS = [ + { spreadsheetId: 'ss1', sheetName: 'SheetA', calendarId: 'cal1' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheetA = ss.getSheetByName('SheetA') + sheetA.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e_future_multi', + title: 'Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd', + location: 'L', + attendees: [], + }) + calendar.__addEvent(evt) // Set checkpoint to a future date (simulating old bug where end dates were saved) - const futureDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); // 1 year in future - code.saveLastSyncTime(global.SYNC_CONFIGS[0], futureDate); + const futureDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year in future + code.saveLastSyncTime(global.SYNC_CONFIGS[0], futureDate) // Call without date parameters to trigger checkpoint validation - code.syncAllCalendarsToSheetsGAS(); + code.syncAllCalendarsToSheetsGAS() // Verify checkpoint was reset to reasonable past date - const lastSync = code.getLastSyncTime(global.SYNC_CONFIGS[0]); - const now = Date.now(); - expect(lastSync.getTime()).toBeLessThanOrEqual(now); - expect(lastSync.getTime()).toBeGreaterThan(now - 10000); // Should be very recent (just reset) - + const lastSync = code.getLastSyncTime(global.SYNC_CONFIGS[0]) + const now = Date.now() + expect(lastSync.getTime()).toBeLessThanOrEqual(now) + expect(lastSync.getTime()).toBeGreaterThan(now - 10000) // Should be very recent (just reset) + // Verify event was synced - expect(sheetA.__getRows().find(r => r[0] === 'e_future_multi')).toBeTruthy(); + expect( + sheetA.__getRows().find((r) => r[0] === 'e_future_multi') + ).toBeTruthy() - delete global.SYNC_CONFIGS; - }); + delete global.SYNC_CONFIGS + }) test('syncAllCalendarsToSheetsGAS without dates uses checkpoints', async () => { - const code = require('../code.gs'); - + const code = require('../code.gs') + global.SYNC_CONFIGS = [ - { spreadsheetId: 'ss1', sheetName: 'SheetA', calendarId: '' } - ]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheetA = ss.getSheetByName('SheetA'); - sheetA.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ - id: 'e_checkpoint', - title: 'CheckpointTest', - start: new Date(), - end: new Date(Date.now() + 3600000) - }); - calendar.__addEvent(evt); + { spreadsheetId: 'ss1', sheetName: 'SheetA', calendarId: '' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheetA = ss.getSheetByName('SheetA') + sheetA.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e_checkpoint', + title: 'CheckpointTest', + start: new Date(), + end: new Date(Date.now() + 3600000), + }) + calendar.__addEvent(evt) // Call without dates to trigger checkpoint logic (line 138) - await code.syncAllCalendarsToSheetsGAS(); + await code.syncAllCalendarsToSheetsGAS() // Sheet should have the event - expect(sheetA.__getRows().find(r => r[0] === 'e_checkpoint')).toBeTruthy(); + expect(sheetA.__getRows().find((r) => r[0] === 'e_checkpoint')).toBeTruthy() - delete global.SYNC_CONFIGS; - }); + delete global.SYNC_CONFIGS + }) test('fullResyncCalendarToSheetGAS clears checkpoint and syncs', () => { - const code = require('../code.gs'); - - global.SYNC_CONFIGS = [{ spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: 'cal1' }]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); + const code = require('../code.gs') - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ id: 'e3', title: 'Test3', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'd', location: 'L', attendees: [] }); - calendar.__addEvent(evt); + global.SYNC_CONFIGS = [ + { spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: 'cal1' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e3', + title: 'Test3', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd', + location: 'L', + attendees: [], + }) + calendar.__addEvent(evt) // Set a checkpoint first - const cfg = code.getConfigs()[0]; - code.saveLastSyncTime(cfg, new Date('2025-01-01')); + const cfg = code.getConfigs()[0] + code.saveLastSyncTime(cfg, new Date('2025-01-01')) // Full resync - code.fullResyncCalendarToSheetGAS(0); + code.fullResyncCalendarToSheetGAS(0) // Should have synced and updated checkpoint - const lastSync = code.getLastSyncTime(cfg); - expect(lastSync.getTime()).toBeGreaterThan(new Date('2026-01-01').getTime()); + const lastSync = code.getLastSyncTime(cfg) + expect(lastSync.getTime()).toBeGreaterThan(new Date('2026-01-01').getTime()) - delete global.SYNC_CONFIGS; - }); + delete global.SYNC_CONFIGS + }) test('fullResyncCalendarToSheetGAS deletes rows for a specific config', () => { - const code = require('../code.gs'); + const code = require('../code.gs') - global.SYNC_CONFIGS = [{ spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - sheet.__getRows().push(['old1'], ['old2']); - sheet.deleteRows = jest.fn(); - - code.fullResyncCalendarToSheetGAS(0); - - expect(sheet.deleteRows).toHaveBeenCalledWith(2, 2); - - delete global.SYNC_CONFIGS; - }); + global.SYNC_CONFIGS = [ + { spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + sheet.__getRows().push(['old1'], ['old2']) + sheet.deleteRows = jest.fn() + + code.fullResyncCalendarToSheetGAS(0) + + expect(sheet.deleteRows).toHaveBeenCalledWith(2, 2) + + delete global.SYNC_CONFIGS + }) test('fullResyncCalendarToSheetGAS deletes rows for all configs', () => { - const code = require('../code.gs'); - - global.SYNC_CONFIGS = [{ spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - sheet.__getRows().push(['old1'], ['old2']); - sheet.deleteRows = jest.fn(); + const code = require('../code.gs') - code.fullResyncCalendarToSheetGAS(); - - expect(sheet.deleteRows).toHaveBeenCalledWith(2, 2); - - delete global.SYNC_CONFIGS; - }); + global.SYNC_CONFIGS = [ + { spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + sheet.__getRows().push(['old1'], ['old2']) + sheet.deleteRows = jest.fn() + + code.fullResyncCalendarToSheetGAS() + + expect(sheet.deleteRows).toHaveBeenCalledWith(2, 2) + + delete global.SYNC_CONFIGS + }) test('fullResyncCalendarToSheetGAS logs errors when clearing configs', () => { - const code = require('../code.gs'); + const code = require('../code.gs') global.SYNC_CONFIGS = [ - { spreadsheetId: 'invalid_ss', sheetName: 'BadSheet', calendarId: 'bad_cal' } - ]; - - const originalOpenById = SpreadsheetApp.openById; + { + spreadsheetId: 'invalid_ss', + sheetName: 'BadSheet', + calendarId: 'bad_cal', + }, + ] + + const originalOpenById = SpreadsheetApp.openById SpreadsheetApp.openById = jest.fn(() => { - throw new Error('Spreadsheet not found'); - }); + throw new Error('Spreadsheet not found') + }) - global.Logger = { log: jest.fn() }; + global.Logger = { log: jest.fn() } expect(() => { - code.fullResyncCalendarToSheetGAS(); - }).not.toThrow(); + code.fullResyncCalendarToSheetGAS() + }).not.toThrow() - expect(global.Logger.log).toHaveBeenCalled(); + expect(global.Logger.log).toHaveBeenCalled() - SpreadsheetApp.openById = originalOpenById; - delete global.SYNC_CONFIGS; - delete global.Logger; - }); + SpreadsheetApp.openById = originalOpenById + delete global.SYNC_CONFIGS + delete global.Logger + }) test('tail merge avoids a tiny trailing chunk', () => { - const code = require('../code.gs'); + const code = require('../code.gs') + + delete global.SYNC_CONFIGS + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const props = PropertiesService.getUserProperties() + const originalGetUserProperties = PropertiesService.getUserProperties + PropertiesService.getUserProperties = () => props + const setPropSpy = jest.spyOn(props, 'setProperty') + + const startIso = '2025-01-01T00:00:00.000Z' + const endIso = new Date( + new Date(startIso).getTime() + 365 * 24 * 60 * 60 * 1000 + 5 * 60 * 1000 + ).toISOString() + + code.syncCalendarToSheetGAS(startIso, endIso) + + expect(setPropSpy).toHaveBeenCalledTimes(1) + + setPropSpy.mockRestore() + PropertiesService.getUserProperties = originalGetUserProperties + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) - delete global.SYNC_CONFIGS; - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; + test('tail merge avoids a tiny trailing chunk for multi-config sync', () => { + const code = require('../code.gs') - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; + global.SYNC_CONFIGS = [ + { spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const props = PropertiesService.getUserProperties() + const originalGetUserProperties = PropertiesService.getUserProperties + PropertiesService.getUserProperties = () => props + const setPropSpy = jest.spyOn(props, 'setProperty') + + const startIso = '2025-01-01T00:00:00.000Z' + const endIso = new Date( + new Date(startIso).getTime() + 365 * 24 * 60 * 60 * 1000 + 5 * 60 * 1000 + ).toISOString() + + code.syncAllCalendarsToSheetsGAS(startIso, endIso) + + expect(setPropSpy).toHaveBeenCalledTimes(1) + + setPropSpy.mockRestore() + PropertiesService.getUserProperties = originalGetUserProperties + delete global.SYNC_CONFIGS + }) - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); + test('syncCalendarToSheetGAS logs warning when max iterations reached', () => { + const code = require('../code.gs') - const props = PropertiesService.getUserProperties(); - const originalGetUserProperties = PropertiesService.getUserProperties; - PropertiesService.getUserProperties = () => props; - const setPropSpy = jest.spyOn(props, 'setProperty'); + delete global.SYNC_CONFIGS + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID - const startIso = '2025-01-01T00:00:00.000Z'; - const endIso = new Date(new Date(startIso).getTime() + (365 * 24 * 60 * 60 * 1000) + (5 * 60 * 1000)).toISOString(); + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' - code.syncCalendarToSheetGAS(startIso, endIso); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) - expect(setPropSpy).toHaveBeenCalledTimes(1); + const start = new Date('1900-01-01T00:00:00.000Z') + const end = new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000 * 101) - setPropSpy.mockRestore(); - PropertiesService.getUserProperties = originalGetUserProperties; - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + code.syncCalendarToSheetGAS(start.toISOString(), end.toISOString()) - test('tail merge avoids a tiny trailing chunk for multi-config sync', () => { - const code = require('../code.gs'); + expect(logSpy).toHaveBeenCalledWith( + '[syncCalendarToSheetGAS] Warning: reached maximum iteration limit' + ) - global.SYNC_CONFIGS = [{ spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }]; + logSpy.mockRestore() + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); + test('syncAllCalendarsToSheetsGAS logs warning when max iterations reached', () => { + const code = require('../code.gs') - const props = PropertiesService.getUserProperties(); - const originalGetUserProperties = PropertiesService.getUserProperties; - PropertiesService.getUserProperties = () => props; - const setPropSpy = jest.spyOn(props, 'setProperty'); + global.SYNC_CONFIGS = [ + { spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }, + ] - const startIso = '2025-01-01T00:00:00.000Z'; - const endIso = new Date(new Date(startIso).getTime() + (365 * 24 * 60 * 60 * 1000) + (5 * 60 * 1000)).toISOString(); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) - code.syncAllCalendarsToSheetsGAS(startIso, endIso); + const start = new Date('1900-01-01T00:00:00.000Z') + const end = new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000 * 101) - expect(setPropSpy).toHaveBeenCalledTimes(1); + code.syncAllCalendarsToSheetsGAS(start.toISOString(), end.toISOString()) - setPropSpy.mockRestore(); - PropertiesService.getUserProperties = originalGetUserProperties; - delete global.SYNC_CONFIGS; - }); + expect(logSpy).toHaveBeenCalledWith( + '[syncAllCalendarsToSheetsGAS] Warning: reached maximum iteration limit for calendar', + '' + ) - test('syncCalendarToSheetGAS logs warning when max iterations reached', () => { - const code = require('../code.gs'); + logSpy.mockRestore() + delete global.SYNC_CONFIGS + }) - delete global.SYNC_CONFIGS; - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; + test('getConfigs returns legacy single config when SYNC_CONFIGS not defined', () => { + // Clear existing configs + delete global.SYNC_CONFIGS + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; + global.SPREADSHEET_ID = 'legacy_ss' + global.SHEET_NAME = 'LegacySheet' + global.CALENDAR_ID = 'legacy_cal' - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + // Clear require cache to force re-evaluation + delete require.cache[require.resolve('../code.gs')] + const freshCode = require('../code.gs') - const start = new Date('1900-01-01T00:00:00.000Z'); - const end = new Date(start.getTime() + (365 * 24 * 60 * 60 * 1000) * 101); + const configs = freshCode.getConfigs() - code.syncCalendarToSheetGAS(start.toISOString(), end.toISOString()); + expect(configs.length).toBe(1) + expect(configs[0].spreadsheetId).toBe('legacy_ss') + expect(configs[0].sheetName).toBe('LegacySheet') + expect(configs[0].calendarId).toBe('legacy_cal') - expect(logSpy).toHaveBeenCalledWith('[syncCalendarToSheetGAS] Warning: reached maximum iteration limit'); + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID + }) - logSpy.mockRestore(); - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + test('getConfigs returns defaults when legacy vars are undefined', () => { + // Clear all configs + delete global.SYNC_CONFIGS + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.CALENDAR_ID - test('syncAllCalendarsToSheetsGAS logs warning when max iterations reached', () => { - const code = require('../code.gs'); + delete require.cache[require.resolve('../code.gs')] + const freshCode = require('../code.gs') - global.SYNC_CONFIGS = [{ spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }]; + const configs = freshCode.getConfigs() - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + expect(configs.length).toBe(1) + expect(configs[0].spreadsheetId).toBe(null) + expect(configs[0].sheetName).toBe('Sheet1') + expect(configs[0].calendarId).toBe(null) + }) - const start = new Date('1900-01-01T00:00:00.000Z'); - const end = new Date(start.getTime() + (365 * 24 * 60 * 60 * 1000) * 101); + test('getConfigs handles non-array SYNC_CONFIGS', () => { + delete global.SYNC_CONFIGS + global.SYNC_CONFIGS = 'not_an_array' - code.syncAllCalendarsToSheetsGAS(start.toISOString(), end.toISOString()); + delete require.cache[require.resolve('../code.gs')] + const freshCode = require('../code.gs') - expect(logSpy).toHaveBeenCalledWith('[syncAllCalendarsToSheetsGAS] Warning: reached maximum iteration limit for calendar', ''); + const configs = freshCode.getConfigs() - logSpy.mockRestore(); - delete global.SYNC_CONFIGS; - }); + // Should fall back to legacy mode + expect(configs.length).toBe(1) + expect(configs[0].sheetName).toBe('Sheet1') - test('getConfigs returns legacy single config when SYNC_CONFIGS not defined', () => { - // Clear existing configs - delete global.SYNC_CONFIGS; - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; - - global.SPREADSHEET_ID = 'legacy_ss'; - global.SHEET_NAME = 'LegacySheet'; - global.CALENDAR_ID = 'legacy_cal'; + delete global.SYNC_CONFIGS + }) - // Clear require cache to force re-evaluation - delete require.cache[require.resolve('../code.gs')]; - const freshCode = require('../code.gs'); - - const configs = freshCode.getConfigs(); - - expect(configs.length).toBe(1); - expect(configs[0].spreadsheetId).toBe('legacy_ss'); - expect(configs[0].sheetName).toBe('LegacySheet'); - expect(configs[0].calendarId).toBe('legacy_cal'); - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; - }); + test('getConfigs handles empty array SYNC_CONFIGS', () => { + delete global.SYNC_CONFIGS + global.SYNC_CONFIGS = [] - test('getConfigs returns defaults when legacy vars are undefined', () => { - // Clear all configs - delete global.SYNC_CONFIGS; - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.CALENDAR_ID; - - delete require.cache[require.resolve('../code.gs')]; - const freshCode = require('../code.gs'); - - const configs = freshCode.getConfigs(); - - expect(configs.length).toBe(1); - expect(configs[0].spreadsheetId).toBe(null); - expect(configs[0].sheetName).toBe('Sheet1'); - expect(configs[0].calendarId).toBe(null); - }); + delete require.cache[require.resolve('../code.gs')] + const freshCode = require('../code.gs') - test('getConfigs handles non-array SYNC_CONFIGS', () => { - delete global.SYNC_CONFIGS; - global.SYNC_CONFIGS = 'not_an_array'; - - delete require.cache[require.resolve('../code.gs')]; - const freshCode = require('../code.gs'); - - const configs = freshCode.getConfigs(); - - // Should fall back to legacy mode - expect(configs.length).toBe(1); - expect(configs[0].sheetName).toBe('Sheet1'); - - delete global.SYNC_CONFIGS; - }); + const configs = freshCode.getConfigs() - test('getConfigs handles empty array SYNC_CONFIGS', () => { - delete global.SYNC_CONFIGS; - global.SYNC_CONFIGS = []; - - delete require.cache[require.resolve('../code.gs')]; - const freshCode = require('../code.gs'); - - const configs = freshCode.getConfigs(); - // Should fall back to legacy mode when array is empty - expect(configs.length).toBe(1); - expect(configs[0].sheetName).toBe('Sheet1'); - expect(configs[0].spreadsheetId).toBe(null); - expect(configs[0].calendarId).toBe(null); - - delete global.SYNC_CONFIGS; - }); + expect(configs.length).toBe(1) + expect(configs[0].sheetName).toBe('Sheet1') + expect(configs[0].spreadsheetId).toBe(null) + expect(configs[0].calendarId).toBe(null) + + delete global.SYNC_CONFIGS + }) test('syncCalendarToSheetGAS handles empty SYNC_CONFIGS gracefully', () => { - const code = require('../code.gs'); - + const code = require('../code.gs') + // Set empty SYNC_CONFIGS - delete global.SYNC_CONFIGS; - global.SYNC_CONFIGS = []; - + delete global.SYNC_CONFIGS + global.SYNC_CONFIGS = [] + // This should not throw even with empty SYNC_CONFIGS - const ss = SpreadsheetApp.getActiveSpreadsheet(); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ - id: 'e_empty_cfg', - title: 'Empty Config Test', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - calendar.__addEvent(evt); + const ss = SpreadsheetApp.getActiveSpreadsheet() + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e_empty_cfg', + title: 'Empty Config Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + calendar.__addEvent(evt) // Should not throw expect(() => { - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - }).not.toThrow(); + code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') + }).not.toThrow() // Should still sync to the default spreadsheet/sheet - expect(sheet.__getRows().find(r => r[0] === 'e_empty_cfg')).toBeTruthy(); - - delete global.SYNC_CONFIGS; - }); + expect(sheet.__getRows().find((r) => r[0] === 'e_empty_cfg')).toBeTruthy() + + delete global.SYNC_CONFIGS + }) test('syncAllCalendarsToSheetsGAS handles empty SYNC_CONFIGS gracefully', () => { - const code = require('../code.gs'); - + const code = require('../code.gs') + // Set empty SYNC_CONFIGS - delete global.SYNC_CONFIGS; - global.SYNC_CONFIGS = []; - - const ss = SpreadsheetApp.getActiveSpreadsheet(); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ - id: 'e_empty_all', - title: 'Empty All Test', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - calendar.__addEvent(evt); + delete global.SYNC_CONFIGS + global.SYNC_CONFIGS = [] + + const ss = SpreadsheetApp.getActiveSpreadsheet() + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e_empty_all', + title: 'Empty All Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + calendar.__addEvent(evt) // Should not throw expect(() => { - code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03'); - }).not.toThrow(); + code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03') + }).not.toThrow() // Should still sync to the default spreadsheet/sheet - expect(sheet.__getRows().find(r => r[0] === 'e_empty_all')).toBeTruthy(); - - delete global.SYNC_CONFIGS; - }); + expect(sheet.__getRows().find((r) => r[0] === 'e_empty_all')).toBeTruthy() + + delete global.SYNC_CONFIGS + }) test('getCheckpointKey handles default calendar', () => { - const code = require('../code.gs'); - const cfg = { calendarId: null }; - const key = code.getCheckpointKey(cfg); - expect(key).toBe('calendar_to_sheets_last_sync_default'); - }); + const code = require('../code.gs') + const cfg = { calendarId: null } + const key = code.getCheckpointKey(cfg) + expect(key).toBe('calendar_to_sheets_last_sync_default') + }) test('_syncCalendarToSheetGAS skips update when row values are identical', () => { - const code = require('../code.gs'); - - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ - id: 'e_same', - title: 'Same Event', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: 'desc', - location: 'loc', - attendees: ['a@example.com'] - }); - calendar.__addEvent(evt); + const code = require('../code.gs') + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e_same', + title: 'Same Event', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'desc', + location: 'loc', + attendees: ['a@example.com'], + }) + calendar.__addEvent(evt) // First sync - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - const rowsBefore = sheet.__getRows(); - + code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') + const rowsBefore = sheet.__getRows() + // Second sync with same data (should not update) - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - const rowsAfter = sheet.__getRows(); - - expect(rowsAfter).toEqual(rowsBefore); - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') + const rowsAfter = sheet.__getRows() + + expect(rowsAfter).toEqual(rowsBefore) + + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) test('_syncCalendarToSheetGAS deletes rows for removed events', () => { - const code = require('../code.gs'); - - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt1 = createCalendarEvent({ id: 'e_del1', title: 'Event 1', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: '', location: '', attendees: [] }); - const evt2 = createCalendarEvent({ id: 'e_del2', title: 'Event 2', start: new Date('2026-02-02T12:00:00Z'), end: new Date('2026-02-02T13:00:00Z'), description: '', location: '', attendees: [] }); - calendar.__addEvent(evt1); - calendar.__addEvent(evt2); + const code = require('../code.gs') + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt1 = createCalendarEvent({ + id: 'e_del1', + title: 'Event 1', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + const evt2 = createCalendarEvent({ + id: 'e_del2', + title: 'Event 2', + start: new Date('2026-02-02T12:00:00Z'), + end: new Date('2026-02-02T13:00:00Z'), + description: '', + location: '', + attendees: [], + }) + calendar.__addEvent(evt1) + calendar.__addEvent(evt2) // First sync with both events - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - expect(sheet.__getRows().length).toBe(2); - - // Remove one event and sync again - calendar.__reset(); - calendar.__addEvent(evt1); - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - - const rows = sheet.__getRows(); - expect(rows.length).toBe(1); - expect(rows[0][0]).toBe('e_del1'); - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') + expect(sheet.__getRows().length).toBe(2) - test('_syncCalendarToSheetGAS skips empty rows and deletes removed events', () => { - const code = require('../code.gs'); - - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; + // Remove one event and sync again + calendar.__reset() + calendar.__addEvent(evt1) + code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); + const rows = sheet.__getRows() + expect(rows.length).toBe(1) + expect(rows[0][0]).toBe('e_del1') - sheet.__getRows().push([]); + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) - const calendar = CalendarApp.getDefaultCalendar(); - const evt1 = createCalendarEvent({ id: 'e_skip', title: 'Skip Test', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z') }); - calendar.__addEvent(evt1); + test('_syncCalendarToSheetGAS skips empty rows and deletes removed events', () => { + const code = require('../code.gs') + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + sheet.__getRows().push([]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt1 = createCalendarEvent({ + id: 'e_skip', + title: 'Skip Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + }) + calendar.__addEvent(evt1) - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); + code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') - calendar.__reset(); - const deleteRowSpy = jest.spyOn(sheet, 'deleteRow'); - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - expect(deleteRowSpy).toHaveBeenCalled(); + calendar.__reset() + const deleteRowSpy = jest.spyOn(sheet, 'deleteRow') + code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') + expect(deleteRowSpy).toHaveBeenCalled() - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) test('syncAllCalendarsToSheetsGAS logs errors when Logger is available', () => { - const code = require('../code.gs'); - + const code = require('../code.gs') + // Create a mock that will throw an error - const originalOpenById = SpreadsheetApp.openById; + const originalOpenById = SpreadsheetApp.openById SpreadsheetApp.openById = jest.fn(() => { - throw new Error('Spreadsheet not found'); - }); - + throw new Error('Spreadsheet not found') + }) + global.SYNC_CONFIGS = [ - { spreadsheetId: 'invalid_ss', sheetName: 'BadSheet', calendarId: 'bad_cal' } - ]; - - global.Logger = { log: jest.fn() }; - + { + spreadsheetId: 'invalid_ss', + sheetName: 'BadSheet', + calendarId: 'bad_cal', + }, + ] + + global.Logger = { log: jest.fn() } + // This should trigger an error but not throw - code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03'); - + code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03') + // Logger should have been called with error - expect(global.Logger.log).toHaveBeenCalled(); - expect(global.Logger.log.mock.calls[0][0]).toContain('Error syncing calendar'); - + expect(global.Logger.log).toHaveBeenCalled() + expect(global.Logger.log.mock.calls[0][0]).toContain( + 'Error syncing calendar' + ) + // Restore - SpreadsheetApp.openById = originalOpenById; - delete global.SYNC_CONFIGS; - delete global.Logger; - }); + SpreadsheetApp.openById = originalOpenById + delete global.SYNC_CONFIGS + delete global.Logger + }) test('_syncCalendarToSheetGAS uses getActiveSpreadsheet when spreadsheetId is null', () => { - const code = require('../code.gs'); - - global.SYNC_CONFIGS = [{ spreadsheetId: null, sheetName: 'Sheet1', calendarId: null }]; - - const ss = SpreadsheetApp.getActiveSpreadsheet(); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ id: 'e_active', title: 'Active SS Test', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: '', location: '', attendees: [] }); - calendar.__addEvent(evt); - - code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03'); - - expect(sheet.__getRows().find(r => r[0] === 'e_active')).toBeTruthy(); - - delete global.SYNC_CONFIGS; - }); + const code = require('../code.gs') + + global.SYNC_CONFIGS = [ + { spreadsheetId: null, sheetName: 'Sheet1', calendarId: null }, + ] + + const ss = SpreadsheetApp.getActiveSpreadsheet() + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e_active', + title: 'Active SS Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + calendar.__addEvent(evt) + + code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03') + + expect(sheet.__getRows().find((r) => r[0] === 'e_active')).toBeTruthy() + + delete global.SYNC_CONFIGS + }) test('_syncCalendarToSheetGAS creates named sheet when missing', () => { - const code = require('../code.gs'); - - global.SYNC_CONFIGS = [{ spreadsheetId: 'ss1', sheetName: 'NonExistent', calendarId: null }]; - - const ss = SpreadsheetApp.openById('ss1'); + const code = require('../code.gs') + + global.SYNC_CONFIGS = [ + { spreadsheetId: 'ss1', sheetName: 'NonExistent', calendarId: null }, + ] + + const ss = SpreadsheetApp.openById('ss1') // Mock getSheetByName to return null for NonExistent sheet to force creation - const originalGetSheetByName = ss.getSheetByName.bind(ss); - const originalInsertSheet = ss.insertSheet.bind(ss); - const insertSheetSpy = jest.fn((name) => originalInsertSheet(name)); - ss.getSheetByName = (name) => (name === 'NonExistent' ? null : originalGetSheetByName(name)); - ss.insertSheet = insertSheetSpy; - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ id: 'e_fallback', title: 'Fallback Test', start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: '', location: '', attendees: [] }); - calendar.__addEvent(evt); - - code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03'); - - expect(insertSheetSpy).toHaveBeenCalledWith('NonExistent'); - const createdSheet = insertSheetSpy.mock.results[0].value; - expect(createdSheet.__getRows().find(r => r[0] === 'e_fallback')).toBeTruthy(); - - ss.getSheetByName = originalGetSheetByName; - ss.insertSheet = originalInsertSheet; - delete global.SYNC_CONFIGS; - }); + const originalGetSheetByName = ss.getSheetByName.bind(ss) + const originalInsertSheet = ss.insertSheet.bind(ss) + const insertSheetSpy = jest.fn((name) => originalInsertSheet(name)) + ss.getSheetByName = (name) => + name === 'NonExistent' ? null : originalGetSheetByName(name) + ss.insertSheet = insertSheetSpy + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e_fallback', + title: 'Fallback Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + calendar.__addEvent(evt) + + code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03') + + expect(insertSheetSpy).toHaveBeenCalledWith('NonExistent') + const createdSheet = insertSheetSpy.mock.results[0].value + expect( + createdSheet.__getRows().find((r) => r[0] === 'e_fallback') + ).toBeTruthy() + + ss.getSheetByName = originalGetSheetByName + ss.insertSheet = originalInsertSheet + delete global.SYNC_CONFIGS + }) test('eventToRowGAS handles missing description and location', () => { - const code = require('../code.gs'); - const evt = createCalendarEvent({ - id: 'e_minimal', - title: 'Minimal Event', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: null, - location: null, - attendees: null - }); - - const row = code.eventToRowGAS(evt); - - expect(row[4]).toBe(''); // description - expect(row[5]).toBe(''); // location - expect(row[6]).toBe(''); // attendees - }); + const code = require('../code.gs') + const evt = createCalendarEvent({ + id: 'e_minimal', + title: 'Minimal Event', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: null, + location: null, + attendees: null, + }) + + const row = code.eventToRowGAS(evt) + + expect(row[4]).toBe('') // description + expect(row[5]).toBe('') // location + expect(row[6]).toBe('') // attendees + }) test('incremental sync preserves historical events outside sync window', () => { - const code = require('../code.gs'); - - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - + const code = require('../code.gs') + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + // Create events in different time windows - const oldEvent = createCalendarEvent({ - id: 'e_old', - title: 'Old Event', - start: new Date('2025-01-15T10:00:00Z'), - end: new Date('2025-01-15T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - - const recentEvent = createCalendarEvent({ - id: 'e_recent', - title: 'Recent Event', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - + const oldEvent = createCalendarEvent({ + id: 'e_old', + title: 'Old Event', + start: new Date('2025-01-15T10:00:00Z'), + end: new Date('2025-01-15T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + + const recentEvent = createCalendarEvent({ + id: 'e_recent', + title: 'Recent Event', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + // Add both events and do a full sync - calendar.__addEvent(oldEvent); - calendar.__addEvent(recentEvent); - code.syncCalendarToSheetGAS('2025-01-01', '2026-03-01'); - - expect(sheet.__getRows().length).toBe(2); - + calendar.__addEvent(oldEvent) + calendar.__addEvent(recentEvent) + code.syncCalendarToSheetGAS('2025-01-01', '2026-03-01') + + expect(sheet.__getRows().length).toBe(2) + // Now simulate an incremental sync from Feb 1 onwards // The old event (Jan 2025) is NOT deleted from calendar, but it's outside the sync window - calendar.__reset(); - calendar.__addEvent(oldEvent); // still exists in calendar - calendar.__addEvent(recentEvent); // still exists in calendar - + calendar.__reset() + calendar.__addEvent(oldEvent) // still exists in calendar + calendar.__addEvent(recentEvent) // still exists in calendar + // Sync only Feb-March window - code.syncCalendarToSheetGAS('2026-02-01', '2026-03-01'); - + code.syncCalendarToSheetGAS('2026-02-01', '2026-03-01') + // Both events should still be in the sheet - const rows = sheet.__getRows(); - expect(rows.length).toBe(2); - expect(rows.find(r => r[0] === 'e_old')).toBeTruthy(); - expect(rows.find(r => r[0] === 'e_recent')).toBeTruthy(); - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + const rows = sheet.__getRows() + expect(rows.length).toBe(2) + expect(rows.find((r) => r[0] === 'e_old')).toBeTruthy() + expect(rows.find((r) => r[0] === 'e_recent')).toBeTruthy() + + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) test('incremental sync deletes events within sync window but preserves those outside', () => { - const code = require('../code.gs'); - - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - + const code = require('../code.gs') + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + // Create events in different time windows - const oldEvent = createCalendarEvent({ - id: 'e_old', - title: 'Old Event', - start: new Date('2025-01-15T10:00:00Z'), - end: new Date('2025-01-15T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - - const recentEvent1 = createCalendarEvent({ - id: 'e_recent1', - title: 'Recent Event 1', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - - const recentEvent2 = createCalendarEvent({ - id: 'e_recent2', - title: 'Recent Event 2', - start: new Date('2026-02-03T10:00:00Z'), - end: new Date('2026-02-03T11:00:00Z'), - description: '', - location: '', - attendees: [] - }); - + const oldEvent = createCalendarEvent({ + id: 'e_old', + title: 'Old Event', + start: new Date('2025-01-15T10:00:00Z'), + end: new Date('2025-01-15T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + + const recentEvent1 = createCalendarEvent({ + id: 'e_recent1', + title: 'Recent Event 1', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + + const recentEvent2 = createCalendarEvent({ + id: 'e_recent2', + title: 'Recent Event 2', + start: new Date('2026-02-03T10:00:00Z'), + end: new Date('2026-02-03T11:00:00Z'), + description: '', + location: '', + attendees: [], + }) + // Add all events and do a full sync - calendar.__addEvent(oldEvent); - calendar.__addEvent(recentEvent1); - calendar.__addEvent(recentEvent2); - code.syncCalendarToSheetGAS('2025-01-01', '2026-03-01'); - - expect(sheet.__getRows().length).toBe(3); - + calendar.__addEvent(oldEvent) + calendar.__addEvent(recentEvent1) + calendar.__addEvent(recentEvent2) + code.syncCalendarToSheetGAS('2025-01-01', '2026-03-01') + + expect(sheet.__getRows().length).toBe(3) + // Now delete recentEvent2 from calendar and do incremental sync // The old event should remain, recentEvent1 should remain, recentEvent2 should be deleted - calendar.__reset(); - calendar.__addEvent(oldEvent); - calendar.__addEvent(recentEvent1); + calendar.__reset() + calendar.__addEvent(oldEvent) + calendar.__addEvent(recentEvent1) // recentEvent2 is deleted from calendar - + // Sync only Feb-March window - code.syncCalendarToSheetGAS('2026-02-01', '2026-03-01'); - + code.syncCalendarToSheetGAS('2026-02-01', '2026-03-01') + // Old event should be preserved (outside window), recentEvent1 kept, recentEvent2 deleted - const rows = sheet.__getRows(); - expect(rows.length).toBe(2); - expect(rows.find(r => r[0] === 'e_old')).toBeTruthy(); - expect(rows.find(r => r[0] === 'e_recent1')).toBeTruthy(); - expect(rows.find(r => r[0] === 'e_recent2')).toBeUndefined(); - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + const rows = sheet.__getRows() + expect(rows.length).toBe(2) + expect(rows.find((r) => r[0] === 'e_old')).toBeTruthy() + expect(rows.find((r) => r[0] === 'e_recent1')).toBeTruthy() + expect(rows.find((r) => r[0] === 'e_recent2')).toBeUndefined() + + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) test('incremental sync sanitizes formula injection in title, description, and location', () => { - const code = require('../code.gs'); - - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); + const code = require('../code.gs') + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() const evt = createCalendarEvent({ id: 'e_safe', title: '=MALICIOUS()', @@ -1262,467 +1700,579 @@ describe('Checkpoint logic (GAS only)', () => { end: new Date('2026-02-02T11:00:00Z'), description: '@IMPORTDATA("http://evil.com")', location: '+DANGEROUS', - attendees: [] - }); - calendar.__addEvent(evt); - - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - - const rows = sheet.__getRows(); - expect(rows.length).toBe(1); - expect(rows[0][1]).toBe("'=MALICIOUS()"); // title sanitized - expect(rows[0][4]).toBe("'@IMPORTDATA(\"http://evil.com\")"); // description sanitized - expect(rows[0][5]).toBe("'+DANGEROUS"); // location sanitized - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); -}); + attendees: [], + }) + calendar.__addEvent(evt) + + code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') + + const rows = sheet.__getRows() + expect(rows.length).toBe(1) + expect(rows[0][1]).toBe("'=MALICIOUS()") // title sanitized + expect(rows[0][4]).toBe('\'@IMPORTDATA("http://evil.com")') // description sanitized + expect(rows[0][5]).toBe("'+DANGEROUS") // location sanitized + + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) +}) // Test ensureHeader function describe('ensureHeader', () => { - const { ensureHeader } = require('../src/index'); + const { ensureHeader } = require('../src/index') test('ensureHeader creates header when sheet is completely empty', () => { - const mockSetValues = jest.fn(); + const mockSetValues = jest.fn() const sheet = { getDataRange: () => ({ getValues: () => [] }), appendRow: jest.fn(), insertRowBefore: jest.fn(), - getRange: jest.fn(() => ({ setValues: mockSetValues })) - }; + getRange: jest.fn(() => ({ setValues: mockSetValues })), + } + + ensureHeader(sheet) - ensureHeader(sheet); - - expect(sheet.getRange).toHaveBeenCalledWith(1, 1, 1, 7); - expect(mockSetValues).toHaveBeenCalledWith([['id', 'title', 'start', 'end', 'description', 'location', 'attendees']]); - expect(sheet.insertRowBefore).not.toHaveBeenCalled(); - }); + expect(sheet.getRange).toHaveBeenCalledWith(1, 1, 1, 7) + expect(mockSetValues).toHaveBeenCalledWith([ + ['id', 'title', 'start', 'end', 'description', 'location', 'attendees'], + ]) + expect(sheet.insertRowBefore).not.toHaveBeenCalled() + }) test('ensureHeader does nothing when valid header already exists', () => { const sheet = { - getDataRange: () => ({ getValues: () => [['id', 'title', 'start', 'end', 'description', 'location', 'attendees']] }), + getDataRange: () => ({ + getValues: () => [ + [ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ], + ], + }), appendRow: jest.fn(), insertRowBefore: jest.fn(), - getRange: jest.fn(() => ({ setValues: jest.fn() })) - }; + getRange: jest.fn(() => ({ setValues: jest.fn() })), + } - ensureHeader(sheet); - - expect(sheet.appendRow).not.toHaveBeenCalled(); - expect(sheet.insertRowBefore).not.toHaveBeenCalled(); - }); + ensureHeader(sheet) + + expect(sheet.appendRow).not.toHaveBeenCalled() + expect(sheet.insertRowBefore).not.toHaveBeenCalled() + }) test('ensureHeader inserts header when first row is data not header', () => { - const mockSetValues = jest.fn(); + const mockSetValues = jest.fn() const sheet = { - getDataRange: () => ({ getValues: () => [['e1', 'Meeting', '2026-02-02T10:00:00Z', '2026-02-02T11:00:00Z', 'desc', 'loc', 'attendees']] }), + getDataRange: () => ({ + getValues: () => [ + [ + 'e1', + 'Meeting', + '2026-02-02T10:00:00Z', + '2026-02-02T11:00:00Z', + 'desc', + 'loc', + 'attendees', + ], + ], + }), appendRow: jest.fn(), insertRowBefore: jest.fn(), - getRange: jest.fn(() => ({ setValues: mockSetValues })) - }; + getRange: jest.fn(() => ({ setValues: mockSetValues })), + } - ensureHeader(sheet); - - expect(sheet.insertRowBefore).toHaveBeenCalledWith(1); - expect(sheet.getRange).toHaveBeenCalledWith(1, 1, 1, 7); - expect(mockSetValues).toHaveBeenCalledWith([['id', 'title', 'start', 'end', 'description', 'location', 'attendees']]); - }); + ensureHeader(sheet) + + expect(sheet.insertRowBefore).toHaveBeenCalledWith(1) + expect(sheet.getRange).toHaveBeenCalledWith(1, 1, 1, 7) + expect(mockSetValues).toHaveBeenCalledWith([ + ['id', 'title', 'start', 'end', 'description', 'location', 'attendees'], + ]) + }) test('sheet mock: insertRowBefore+setValues preserves data when adding header to sheet with existing rows', () => { // This test verifies the fix for the bug where inserting a header into a sheet // with existing data rows would overwrite the first data row - const ss = SpreadsheetApp.openById('test-ss'); - const sheet = ss.getSheetByName('TestSheet'); - + const ss = SpreadsheetApp.openById('test-ss') + const sheet = ss.getSheetByName('TestSheet') + // Add data rows without a header - sheet.appendRow(['data1-col1', 'data1-col2', 'data1-col3']); - sheet.appendRow(['data2-col1', 'data2-col2', 'data2-col3']); - + sheet.appendRow(['data1-col1', 'data1-col2', 'data1-col3']) + sheet.appendRow(['data2-col1', 'data2-col2', 'data2-col3']) + // Verify initial state: 2 data rows, no header - let dataRange = sheet.getDataRange().getValues(); + let dataRange = sheet.getDataRange().getValues() expect(dataRange).toEqual([ ['data1-col1', 'data1-col2', 'data1-col3'], - ['data2-col1', 'data2-col2', 'data2-col3'] - ]); - expect(sheet.getLastRow()).toBe(2); - + ['data2-col1', 'data2-col2', 'data2-col3'], + ]) + expect(sheet.getLastRow()).toBe(2) + // Now insert a header (this is what ensureHeader does) - sheet.insertRowBefore(1); - sheet.getRange(1, 1, 1, 3).setValues([['Header1', 'Header2', 'Header3']]); - + sheet.insertRowBefore(1) + sheet.getRange(1, 1, 1, 3).setValues([['Header1', 'Header2', 'Header3']]) + // Verify the header was added and data was preserved - dataRange = sheet.getDataRange().getValues(); + dataRange = sheet.getDataRange().getValues() expect(dataRange).toEqual([ ['Header1', 'Header2', 'Header3'], ['data1-col1', 'data1-col2', 'data1-col3'], - ['data2-col1', 'data2-col2', 'data2-col3'] - ]); - expect(sheet.getLastRow()).toBe(3); - }); -}); + ['data2-col1', 'data2-col2', 'data2-col3'], + ]) + expect(sheet.getLastRow()).toBe(3) + }) +}) // Test syncCalendarToSheet with empty sheet (no header) test('syncCalendarToSheet works correctly when sheet starts empty with no header', async () => { - const calendar = CalendarApp.getDefaultCalendar(); - + const calendar = CalendarApp.getDefaultCalendar() + // Create a fresh sheet with no header set - const ss = SpreadsheetApp.openById('ss_empty'); - const sheet = ss.getSheetByName('EmptySheet'); + const ss = SpreadsheetApp.openById('ss_empty') + const sheet = ss.getSheetByName('EmptySheet') // Intentionally NOT calling __setHeader - const evt1 = createCalendarEvent({ - id: 'e_empty1', - title: 'First Event', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: 'd1', - location: 'L1', - attendees: ['a@example.com'] - }); - - const evt2 = createCalendarEvent({ - id: 'e_empty2', - title: 'Second Event', - start: new Date('2026-02-03T10:00:00Z'), - end: new Date('2026-02-03T11:00:00Z'), - description: 'd2', - location: 'L2', - attendees: [] - }); - - calendar.__addEvent(evt1); - calendar.__addEvent(evt2); + const evt1 = createCalendarEvent({ + id: 'e_empty1', + title: 'First Event', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd1', + location: 'L1', + attendees: ['a@example.com'], + }) + + const evt2 = createCalendarEvent({ + id: 'e_empty2', + title: 'Second Event', + start: new Date('2026-02-03T10:00:00Z'), + end: new Date('2026-02-03T11:00:00Z'), + description: 'd2', + location: 'L2', + attendees: [], + }) + + calendar.__addEvent(evt1) + calendar.__addEvent(evt2) // Sync should create header and add events - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-05') }); + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-05'), + }) + + const rows = sheet.__getRows() + expect(rows.length).toBe(2) + expect(rows[0][0]).toBe('e_empty1') + expect(rows[1][0]).toBe('e_empty2') - const rows = sheet.__getRows(); - expect(rows.length).toBe(2); - expect(rows[0][0]).toBe('e_empty1'); - expect(rows[1][0]).toBe('e_empty2'); - // Second sync should update correctly without duplicating - const evt1Updated = createCalendarEvent({ - id: 'e_empty1', - title: 'First Event Updated', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: 'd1', - location: 'L1', - attendees: ['a@example.com'] - }); - - calendar.__reset(); - calendar.__addEvent(evt1Updated); - calendar.__addEvent(evt2); - - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-05') }); - - const rows2 = sheet.__getRows(); - expect(rows2.length).toBe(2); - const e1row = rows2.find(r => r[0] === 'e_empty1'); - expect(e1row[1]).toBe('First Event Updated'); -}); + const evt1Updated = createCalendarEvent({ + id: 'e_empty1', + title: 'First Event Updated', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'd1', + location: 'L1', + attendees: ['a@example.com'], + }) + + calendar.__reset() + calendar.__addEvent(evt1Updated) + calendar.__addEvent(evt2) + + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-05'), + }) + + const rows2 = sheet.__getRows() + expect(rows2.length).toBe(2) + const e1row = rows2.find((r) => r[0] === 'e_empty1') + expect(e1row[1]).toBe('First Event Updated') +}) // Test formula injection sanitization test('eventToRow sanitizes values starting with formula metacharacters', () => { - const evt = createCalendarEvent({ - id: 'e_formula', - title: '=SUM(A1:A10)', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: '+ALERT()', - location: '-cmd', - attendees: [] - }); - - const row = eventToRow(evt); - - expect(row[1]).toBe("'=SUM(A1:A10)"); // title sanitized - expect(row[4]).toBe("'+ALERT()"); // description sanitized - expect(row[5]).toBe("'-cmd"); // location sanitized -}); + const evt = createCalendarEvent({ + id: 'e_formula', + title: '=SUM(A1:A10)', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: '+ALERT()', + location: '-cmd', + attendees: [], + }) + + const row = eventToRow(evt) + + expect(row[1]).toBe("'=SUM(A1:A10)") // title sanitized + expect(row[4]).toBe("'+ALERT()") // description sanitized + expect(row[5]).toBe("'-cmd") // location sanitized +}) // Test formula injection sanitization with leading whitespace/control characters test('eventToRow sanitizes values with leading whitespace/control chars followed by formula metacharacters', () => { - const evt = createCalendarEvent({ - id: 'e_whitespace_formula', - title: ' =IMPORTDATA("http://evil.com")', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: '\t@IMPORTDATA("http://evil.com")', - location: '\n+DANGEROUS', - attendees: [] - }); - - const row = eventToRow(evt); - - expect(row[1]).toBe("' =IMPORTDATA(\"http://evil.com\")"); // title with leading space sanitized - expect(row[4]).toBe("'\t@IMPORTDATA(\"http://evil.com\")"); // description with leading tab sanitized - expect(row[5]).toBe("'\n+DANGEROUS"); // location with leading newline sanitized -}); + const evt = createCalendarEvent({ + id: 'e_whitespace_formula', + title: ' =IMPORTDATA("http://evil.com")', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: '\t@IMPORTDATA("http://evil.com")', + location: '\n+DANGEROUS', + attendees: [], + }) + + const row = eventToRow(evt) + + expect(row[1]).toBe('\' =IMPORTDATA("http://evil.com")') // title with leading space sanitized + expect(row[4]).toBe('\'\t@IMPORTDATA("http://evil.com")') // description with leading tab sanitized + expect(row[5]).toBe("'\n+DANGEROUS") // location with leading newline sanitized +}) // Test that normal values with dangerous chars in middle are not sanitized test('eventToRow does not sanitize values with formula chars not at effective start', () => { - const evt = createCalendarEvent({ - id: 'e_safe', - title: 'Meeting @3pm', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: 'Cost is $100+tax', - location: 'Room 5-A', - attendees: [] - }); - - const row = eventToRow(evt); - - expect(row[1]).toBe('Meeting @3pm'); // not sanitized - expect(row[4]).toBe('Cost is $100+tax'); // not sanitized - expect(row[5]).toBe('Room 5-A'); // not sanitized -}); + const evt = createCalendarEvent({ + id: 'e_safe', + title: 'Meeting @3pm', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + description: 'Cost is $100+tax', + location: 'Room 5-A', + attendees: [], + }) + + const row = eventToRow(evt) + + expect(row[1]).toBe('Meeting @3pm') // not sanitized + expect(row[4]).toBe('Cost is $100+tax') // not sanitized + expect(row[5]).toBe('Room 5-A') // not sanitized +}) // Test historical data preservation (no valid dates) test('syncCalendarToSheet preserves rows without valid date columns', async () => { - const calendar = CalendarApp.getDefaultCalendar(); - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); + const calendar = CalendarApp.getDefaultCalendar() + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') // Add a row with missing date columns (historical data) - sheet.__getRows().push(['old_event', 'Old Event', null, null, 'desc', 'loc', '']); + sheet + .__getRows() + .push(['old_event', 'Old Event', null, null, 'desc', 'loc', '']) // Sync with empty calendar - calendar.__reset(); - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-03') }); + calendar.__reset() + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-03'), + }) // Row should still exist because it has no valid dates - const rows = sheet.__getRows(); - expect(rows.find(r => r[0] === 'old_event')).toBeTruthy(); -}); + const rows = sheet.__getRows() + expect(rows.find((r) => r[0] === 'old_event')).toBeTruthy() +}) // Test historical data preservation (dates outside sync window) test('syncCalendarToSheet preserves rows with dates outside sync window', async () => { - const calendar = CalendarApp.getDefaultCalendar(); - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); + const calendar = CalendarApp.getDefaultCalendar() + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') // Add a row with dates outside the sync window - const oldDate = new Date('2025-01-01T10:00:00Z'); - sheet.__getRows().push(['old_event', 'Old Event', oldDate.toISOString(), oldDate.toISOString(), 'desc', 'loc', '']); + const oldDate = new Date('2025-01-01T10:00:00Z') + sheet + .__getRows() + .push([ + 'old_event', + 'Old Event', + oldDate.toISOString(), + oldDate.toISOString(), + 'desc', + 'loc', + '', + ]) // Sync with empty calendar for Feb 2026 window - calendar.__reset(); - await syncCalendarToSheet(calendar, sheet, { start: new Date('2026-02-01'), end: new Date('2026-02-03') }); + calendar.__reset() + await syncCalendarToSheet(calendar, sheet, { + start: new Date('2026-02-01'), + end: new Date('2026-02-03'), + }) // Row should still exist because its dates are outside the sync window - const rows = sheet.__getRows(); - expect(rows.find(r => r[0] === 'old_event')).toBeTruthy(); -}); + const rows = sheet.__getRows() + expect(rows.find((r) => r[0] === 'old_event')).toBeTruthy() +}) // Test rowsEqual ignores extra columns in second argument test('rowsEqual ignores extra columns in second argument', () => { // Extra columns in b are always ignored - expect(rowsEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(true); - expect(rowsEqual(['a', 'b'], ['a', 'b', ''])).toBe(true); - expect(rowsEqual(['a', 'b'], ['a', 'b', null])).toBe(true); + expect(rowsEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(true) + expect(rowsEqual(['a', 'b'], ['a', 'b', ''])).toBe(true) + expect(rowsEqual(['a', 'b'], ['a', 'b', null])).toBe(true) // Works consistently when a has extra columns too - expect(rowsEqual(['a', 'b', 'c'], ['a', 'b', 'c', 'd'])).toBe(true); + expect(rowsEqual(['a', 'b', 'c'], ['a', 'b', 'c', 'd'])).toBe(true) // But if a is longer than b, it should fail (comparing against undefined) - expect(rowsEqual(['a', 'b', 'c'], ['a', 'b'])).toBe(false); -}); + expect(rowsEqual(['a', 'b', 'c'], ['a', 'b'])).toBe(false) +}) // Test code.gs functions for coverage describe('GAS wrapper functions', () => { beforeEach(() => { - installGlobals(global); - }); + installGlobals(global) + }) - afterEach(() => resetAll(global)); + afterEach(() => resetAll(global)) test('syncCalendarToSheetGAS uses checkpoint and saves new checkpoint', async () => { - const code = require('../code.gs'); - global.SYNC_CONFIGS = [{ calendarId: '', spreadsheetId: 'ss1', sheetName: 'Sheet1' }]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ - id: 'e1', - title: 'Test', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z') - }); - calendar.__addEvent(evt); - + const code = require('../code.gs') + global.SYNC_CONFIGS = [ + { calendarId: '', spreadsheetId: 'ss1', sheetName: 'Sheet1' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e1', + title: 'Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + }) + calendar.__addEvent(evt) + // Call without dates to use checkpoint logic - await code.syncCalendarToSheetGAS(); - - expect(sheet.__getRows().find(r => r[0] === 'e1')).toBeTruthy(); - - delete global.SYNC_CONFIGS; - }); + await code.syncCalendarToSheetGAS() + + expect(sheet.__getRows().find((r) => r[0] === 'e1')).toBeTruthy() + + delete global.SYNC_CONFIGS + }) test('syncCalendarToSheetGAS with explicit dates and updates existing row', async () => { - const code = require('../code.gs'); - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - + const code = require('../code.gs') + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + // Add initial event - const calendar = CalendarApp.getDefaultCalendar(); - const evt1 = createCalendarEvent({ - id: 'e1', - title: 'Initial', - start: new Date('2026-02-02T10:00:00Z'), + const calendar = CalendarApp.getDefaultCalendar() + const evt1 = createCalendarEvent({ + id: 'e1', + title: 'Initial', + start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'desc1', location: 'loc1', - attendees: [] - }); - calendar.__addEvent(evt1); - - await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - - expect(sheet.__getRows()[0][1]).toBe('Initial'); - + attendees: [], + }) + calendar.__addEvent(evt1) + + await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') + + expect(sheet.__getRows()[0][1]).toBe('Initial') + // Update event title - calendar.__reset(); - const evt2 = createCalendarEvent({ - id: 'e1', - title: 'Updated', - start: new Date('2026-02-02T10:00:00Z'), + calendar.__reset() + const evt2 = createCalendarEvent({ + id: 'e1', + title: 'Updated', + start: new Date('2026-02-02T10:00:00Z'), end: new Date('2026-02-02T11:00:00Z'), description: 'desc1', location: 'loc1', - attendees: [] - }); - calendar.__addEvent(evt2); - - await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - - expect(sheet.__getRows()[0][1]).toBe('Updated'); - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + attendees: [], + }) + calendar.__addEvent(evt2) - test('syncCalendarToSheetGAS calls ensureHeader when available', async () => { - const code = require('../code.gs'); + await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - global.ensureHeader = jest.fn(); + expect(sheet.__getRows()[0][1]).toBe('Updated') - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) - const calendar = CalendarApp.getDefaultCalendar(); + test('syncCalendarToSheetGAS calls ensureHeader when available', async () => { + const code = require('../code.gs') + + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + global.ensureHeader = jest.fn() + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() const evt = createCalendarEvent({ id: 'e_header', title: 'Header Test', start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z') - }); - calendar.__addEvent(evt); + end: new Date('2026-02-02T11:00:00Z'), + }) + calendar.__addEvent(evt) - await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); + await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') - expect(global.ensureHeader).toHaveBeenCalledWith(sheet); + expect(global.ensureHeader).toHaveBeenCalledWith(sheet) - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - delete global.ensureHeader; - }); + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + delete global.ensureHeader + }) test('syncCalendarToSheetGAS handles event deletion', async () => { - const code = require('../code.gs'); - global.SPREADSHEET_ID = 'ss1'; - global.SHEET_NAME = 'Sheet1'; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt1 = createCalendarEvent({ - id: 'e1', - title: 'ToBeDeleted', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z') - }); - calendar.__addEvent(evt1); - - await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - expect(sheet.__getRows().length).toBe(1); - + const code = require('../code.gs') + global.SPREADSHEET_ID = 'ss1' + global.SHEET_NAME = 'Sheet1' + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt1 = createCalendarEvent({ + id: 'e1', + title: 'ToBeDeleted', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + }) + calendar.__addEvent(evt1) + + await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') + expect(sheet.__getRows().length).toBe(1) + // Remove event from calendar - calendar.__reset(); - - await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03'); - expect(sheet.__getRows().length).toBe(0); - - delete global.SPREADSHEET_ID; - delete global.SHEET_NAME; - }); + calendar.__reset() + + await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') + expect(sheet.__getRows().length).toBe(0) + + delete global.SPREADSHEET_ID + delete global.SHEET_NAME + }) test('syncAllCalendarsToSheetsGAS handles errors and continues', async () => { - const code = require('../code.gs'); + const code = require('../code.gs') global.SYNC_CONFIGS = [ { calendarId: 'bad_calendar', spreadsheetId: 'ss1', sheetName: 'Sheet1' }, - { calendarId: '', spreadsheetId: 'ss1', sheetName: 'Sheet2' } - ]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet1 = ss.getSheetByName('Sheet1'); - const sheet2 = ss.getSheetByName('Sheet2'); - sheet1.__setHeader(['id','title','start','end','description','location','attendees']); - sheet2.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ - id: 'e1', - title: 'Test', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z') - }); - calendar.__addEvent(evt); - + { calendarId: '', spreadsheetId: 'ss1', sheetName: 'Sheet2' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheet1 = ss.getSheetByName('Sheet1') + const sheet2 = ss.getSheetByName('Sheet2') + sheet1.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + sheet2.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e1', + title: 'Test', + start: new Date('2026-02-02T10:00:00Z'), + end: new Date('2026-02-02T11:00:00Z'), + }) + calendar.__addEvent(evt) + // Should not throw, should continue to second config - await code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03'); - + await code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03') + // Second config should succeed - expect(sheet2.__getRows().find(r => r[0] === 'e1')).toBeTruthy(); - - delete global.SYNC_CONFIGS; - }); + expect(sheet2.__getRows().find((r) => r[0] === 'e1')).toBeTruthy() + + delete global.SYNC_CONFIGS + }) test('fullResyncCalendarToSheetGAS clears checkpoint and resyncs', async () => { - const code = require('../code.gs'); - global.SYNC_CONFIGS = [{ calendarId: '', spreadsheetId: 'ss1', sheetName: 'Sheet1' }]; - - const ss = SpreadsheetApp.openById('ss1'); - const sheet = ss.getSheetByName('Sheet1'); - sheet.__setHeader(['id','title','start','end','description','location','attendees']); - - const calendar = CalendarApp.getDefaultCalendar(); - const evt = createCalendarEvent({ - id: 'e1', - title: 'Test', - start: new Date(), - end: new Date(Date.now() + 3600000) - }); - calendar.__addEvent(evt); - - await code.fullResyncCalendarToSheetGAS(0); - - expect(sheet.__getRows().find(r => r[0] === 'e1')).toBeTruthy(); - - delete global.SYNC_CONFIGS; - }); -}); + const code = require('../code.gs') + global.SYNC_CONFIGS = [ + { calendarId: '', spreadsheetId: 'ss1', sheetName: 'Sheet1' }, + ] + + const ss = SpreadsheetApp.openById('ss1') + const sheet = ss.getSheetByName('Sheet1') + sheet.__setHeader([ + 'id', + 'title', + 'start', + 'end', + 'description', + 'location', + 'attendees', + ]) + + const calendar = CalendarApp.getDefaultCalendar() + const evt = createCalendarEvent({ + id: 'e1', + title: 'Test', + start: new Date(), + end: new Date(Date.now() + 3600000), + }) + calendar.__addEvent(evt) + + await code.fullResyncCalendarToSheetGAS(0) + + expect(sheet.__getRows().find((r) => r[0] === 'e1')).toBeTruthy() + + delete global.SYNC_CONFIGS + }) +}) diff --git a/src/gas-utils.js b/src/gas-utils.js index 6f55226d..7ed21247 100644 --- a/src/gas-utils.js +++ b/src/gas-utils.js @@ -1,63 +1,66 @@ -const crypto = require('crypto'); +const crypto = require('crypto') /** * getCleanBody - logic ported from GAS for local testing */ function getCleanBody(text) { - if (!text) return ''; + if (!text) return '' const headerPatterns = [ /^\s*On\s+.+\s+wrote:/m, /^\s*From:\s+.+\s+Sent:\s+/m, /^\s*_{10,}/m, /^\s*From:\s+.+<.+@.+>/m, - /confidentiality notice/im - ]; + /confidentiality notice/im, + ] - let splitIndex = -1; + let splitIndex = -1 headerPatterns.forEach((pattern) => { - const match = text.match(pattern); + const match = text.match(pattern) if (match) { // Prefer splitting at the start of the line containing the match - const lineStart = (text.lastIndexOf('\n', match.index) === -1) ? 0 : text.lastIndexOf('\n', match.index) + 1; + const lineStart = + text.lastIndexOf('\n', match.index) === -1 + ? 0 + : text.lastIndexOf('\n', match.index) + 1 if (splitIndex === -1 || lineStart < splitIndex) { - splitIndex = lineStart; + splitIndex = lineStart } } - }); + }) - const workingText = splitIndex !== -1 ? text.substring(0, splitIndex) : text; - const lines = workingText.split('\n'); + const workingText = splitIndex !== -1 ? text.substring(0, splitIndex) : text + const lines = workingText.split('\n') const cleanLines = lines.filter((line) => { - const trimmed = line.trim(); - return !(trimmed.startsWith('>') || trimmed.startsWith('<')); - }); + const trimmed = line.trim() + return !(trimmed.startsWith('>') || trimmed.startsWith('<')) + }) // Join lines and normalize line breaks: replace 2+ consecutive line breaks with 1 // This prevents excessive blank lines in Google Docs where each \n creates a paragraph break - let result = cleanLines.join('\n').trim(); - result = result.replace(/\n{2,}/g, '\n'); - - return result; + let result = cleanLines.join('\n').trim() + result = result.replace(/\n{2,}/g, '\n') + + return result } /** * getFileHash - compute MD5 hex digest of a "blob" (Buffer or object with getBytes()) */ function getFileHash(blob) { - let bytes; + let bytes if (Buffer.isBuffer(blob)) { - bytes = blob; + bytes = blob } else if (blob && typeof blob.getBytes === 'function') { - bytes = Buffer.from(blob.getBytes()); + bytes = Buffer.from(blob.getBytes()) } else if (blob && blob.bytes) { - bytes = Buffer.from(blob.bytes); + bytes = Buffer.from(blob.bytes) } else { - throw new Error('Unsupported blob type'); + throw new Error('Unsupported blob type') } - const hash = crypto.createHash('md5').update(bytes).digest('hex'); - return hash; + const hash = crypto.createHash('md5').update(bytes).digest('hex') + return hash } -module.exports = { getCleanBody, getFileHash }; +module.exports = { getCleanBody, getFileHash } diff --git a/src/gmail-to-drive-by-labels/README.md b/src/gmail-to-drive-by-labels/README.md index 64a56cfc..85e453ee 100644 --- a/src/gmail-to-drive-by-labels/README.md +++ b/src/gmail-to-drive-by-labels/README.md @@ -3,31 +3,38 @@ A robust Google Apps Script designed to automate the archiving of Gmail threads. It prepends email body text to the top of a Google Doc (newest content first) and intelligently saves attachments to a specific Google Drive folder based on Gmail labels. ## Example Use-Cases + ### 1. Collect and store all documents related to an ongoing topic into Google Drive which can act as a source of grounding for a Notebook system. Use Gmail Filters to automatically label incoming emails and have them feed into your Notebook RAG. ## Features **Automated Archiving:** -* Scans for emails with a specific "Trigger Label" and processes them automatically. -* Processing is performed on a per-item basis allowing resumption after timeouts + +- Scans for emails with a specific "Trigger Label" and processes them automatically. +- Processing is performed on a per-item basis allowing resumption after timeouts **Clean Output:** -* Strips quoted replies (e.g., "On [Date]... wrote:"). -* Removes "Confidentiality Notice" legal footers. -* Removes lines starting with `>` or `<`. -* Normalizes excessive line breaks to prevent blank lines in documents. -**Content-Based De-duplication:** -* Uses MD5 hashing (digital fingerprinting) to detect if a file is an exact duplicate of one already in the folder, even if the filename is different. +- Strips quoted replies (e.g., "On [Date]... wrote:"). +- Removes "Confidentiality Notice" legal footers. +- Removes lines starting with `>` or `<`. +- Normalizes excessive line breaks to prevent blank lines in documents. + +**Content-Based De-duplication:** + +- Uses MD5 hashing (digital fingerprinting) to detect if a file is an exact duplicate of one already in the folder, even if the filename is different. + +**Safe Renaming:** -**Safe Renaming:** -* If a file has the same name but *different* content, it automatically appends a timestamp to the filename to prevent overwriting data. +- If a file has the same name but _different_ content, it automatically appends a timestamp to the filename to prevent overwriting data. -**Robust Processing:** -* Includes error handling and delays to prevent Google Docs "Unexpected Error" crashes during high-volume loops. +**Robust Processing:** + +- Includes error handling and delays to prevent Google Docs "Unexpected Error" crashes during high-volume loops. **Label Management:** -* Automatically removes the trigger label and applies an "Archived" label after processing. + +- Automatically removes the trigger label and applies an "Archived" label after processing. ## Setup Instructions @@ -36,28 +43,24 @@ A robust Google Apps Script designed to automate the archiving of Gmail threads. 1. Open [Google Apps Script](https://script.google.com/). 2. Create a new project. 3. Create two files in the editor: -* `Code.gs`: Paste the main logic code. -* `Config.gs`: Paste the configuration code. - +- `Code.gs`: Paste the main logic code. +- `Config.gs`: Paste the configuration code. ### 2. Prepare Destination Files -* **Google Doc:** Create a new Google Doc (or use an existing one) to act as the log for email text. -* **Google Drive Folder:** Create a folder where attachments will be saved. +- **Google Doc:** Create a new Google Doc (or use an existing one) to act as the log for email text. +- **Google Drive Folder:** Create a folder where attachments will be saved. ### 3. Get Your IDs You will need to extract IDs from your browser URL bar: -* **Doc ID:** The string between `/d/` and `/edit` in the Doc URL. -* *Example:* `https://docs.google.com/document/d/`**`1vN7xdaLW0ZDWUjgP2yJ5ETb9t3ZlDT10s9IxNOt7yXA`**`/edit` - - -* **Folder ID:** The string at the end of the Folder URL. -* *Example:* `https://drive.google.com/drive/folders/`**`10s9IxNOt7yXA_Example_Folder_ID`** - +- **Doc ID:** The string between `/d/` and `/edit` in the Doc URL. +- _Example:_ `https://docs.google.com/document/d/`**`1vN7xdaLW0ZDWUjgP2yJ5ETb9t3ZlDT10s9IxNOt7yXA`**`/edit` +- **Folder ID:** The string at the end of the Folder URL. +- _Example:_ `https://drive.google.com/drive/folders/`**`10s9IxNOt7yXA_Example_Folder_ID`** ## Configuration (`Config.gs`) @@ -69,25 +72,24 @@ function getProcessConfig() { { // The label that triggers the script // NOTE: For nested labels, use the full path: "Parent/Child" - triggerLabel: "Projects/toby-mcaa", - + triggerLabel: 'Projects/toby-mcaa', + // The label applied after successful processing - processedLabel: "Projects/toby-mcaa-archived", - + processedLabel: 'Projects/toby-mcaa-archived', + // The Google Doc ID found in step 3 - docId: "YOUR_GOOGLE_DOC_ID_HERE", - + docId: 'YOUR_GOOGLE_DOC_ID_HERE', + // The Drive Folder ID found in step 3 - folderId: "YOUR_DRIVE_FOLDER_ID_HERE", - + folderId: 'YOUR_DRIVE_FOLDER_ID_HERE', + // Optional: Number of threads to process per batch during rebuild // Default is 250 if not specified. Increase for faster rebuilds, // decrease if experiencing timeouts. - batchSize: 250 - } - ]; + batchSize: 250, + }, + ] } - ``` ## Usage @@ -105,8 +107,8 @@ If you've updated the cleaning logic (e.g., `getCleanBody` function) or want to 1. Select `rebuildAllDocs` from the function dropdown in the Apps Script toolbar. 2. Click **Run** - this will: - * Clear all configured Google Docs - * Move all processed/archived emails back to their trigger labels + - Clear all configured Google Docs + - Move all processed/archived emails back to their trigger labels 3. Then run `storeEmailsAndAttachments` to reprocess all emails with the updated logic. **Note:** The rebuild process moves (not copies) emails back to trigger labels, ensuring all emails are reprocessed exactly once with the latest logic while maintaining incremental processing to avoid script timeouts. @@ -126,21 +128,22 @@ To run this script automatically (e.g., every hour): The script uses regex patterns to clean the email body. It specifically looks for and removes: -* **Headers:** `On [Date], [Name] wrote:` (Gmail), `From: ... Sent:` (Outlook). -* **Footers:** Any line containing "Confidentiality Notice" (case-insensitive) and everything following it. -* **Quote characters:** Any line starting with `>` or `<`. +- **Headers:** `On [Date], [Name] wrote:` (Gmail), `From: ... Sent:` (Outlook). +- **Footers:** Any line containing "Confidentiality Notice" (case-insensitive) and everything following it. +- **Quote characters:** Any line starting with `>` or `<`. ### Line Break Normalization To prevent excessive blank lines in the generated documents, the script normalizes line breaks: -* **Behavior:** All consecutive newlines (2 or more) are replaced with a single newline. -* **Rationale:** When `insertParagraph()` inserts text into Google Docs, each `\n` character creates a paragraph break. Multiple consecutive newlines would create excessive blank lines, making documents unnecessarily long and harder to read. -* **Impact:** Email signatures and formatted content appear compact without blank lines while preserving all content. +- **Behavior:** All consecutive newlines (2 or more) are replaced with a single newline. +- **Rationale:** When `insertParagraph()` inserts text into Google Docs, each `\n` character creates a paragraph break. Multiple consecutive newlines would create excessive blank lines, making documents unnecessarily long and harder to read. +- **Impact:** Email signatures and formatted content appear compact without blank lines while preserving all content. **Example:** Before normalization: + ``` Thank you! @@ -154,6 +157,7 @@ Acme Corp ``` After normalization (as it appears in the document): + ``` Thank you! John Doe @@ -164,4 +168,5 @@ Acme Corp This ensures documents remain readable and compact, especially when processing emails with formatted signatures or multiple paragraph breaks. ## License + MIT diff --git a/src/gmail-to-drive-by-labels/code.gs b/src/gmail-to-drive-by-labels/code.gs index f7a23a2b..1a562bdd 100644 --- a/src/gmail-to-drive-by-labels/code.gs +++ b/src/gmail-to-drive-by-labels/code.gs @@ -3,15 +3,26 @@ * Reads settings from Config.gs */ function storeEmailsAndAttachments() { - console.log('[storeEmailsAndAttachments] Starting email processing'); - var PROCESS_CONFIG = getProcessConfig(); - console.log('[storeEmailsAndAttachments] Processing', PROCESS_CONFIG.length, 'configurations'); + console.log('[storeEmailsAndAttachments] Starting email processing') + var PROCESS_CONFIG = getProcessConfig() + console.log( + '[storeEmailsAndAttachments] Processing', + PROCESS_CONFIG.length, + 'configurations' + ) PROCESS_CONFIG.forEach((config, index) => { - console.log('[storeEmailsAndAttachments] Processing config', index + 1, 'of', PROCESS_CONFIG.length, ':', config.triggerLabel); - processLabelGroup(config); - }); - console.log('[storeEmailsAndAttachments] Completed all processing'); + console.log( + '[storeEmailsAndAttachments] Processing config', + index + 1, + 'of', + PROCESS_CONFIG.length, + ':', + config.triggerLabel + ) + processLabelGroup(config) + }) + console.log('[storeEmailsAndAttachments] Completed all processing') } /** @@ -20,33 +31,48 @@ function storeEmailsAndAttachments() { * 1. Clears the configured Google Doc * 2. Moves all processed/archived emails back to the trigger label * 3. Allows storeEmailsAndAttachments() to reprocess them - * + * * For large label sets, this function uses batching to avoid timeouts. * If interrupted, run again to continue from where it left off. - * + * * Run this when you've updated getCleanBody() or other processing logic * and want to regenerate the documents with the new logic. */ function rebuildAllDocs() { - console.log('[rebuildAllDocs] Starting rebuild process'); - var PROCESS_CONFIG = getProcessConfig(); - console.log('[rebuildAllDocs] Rebuilding', PROCESS_CONFIG.length, 'configurations'); - - var completed = true; + console.log('[rebuildAllDocs] Starting rebuild process') + var PROCESS_CONFIG = getProcessConfig() + console.log( + '[rebuildAllDocs] Rebuilding', + PROCESS_CONFIG.length, + 'configurations' + ) + + var completed = true for (var i = 0; i < PROCESS_CONFIG.length; i++) { - var config = PROCESS_CONFIG[i]; - console.log('[rebuildAllDocs] Rebuilding config', i + 1, 'of', PROCESS_CONFIG.length, ':', config.triggerLabel); - var configCompleted = rebuildDoc(config); + var config = PROCESS_CONFIG[i] + console.log( + '[rebuildAllDocs] Rebuilding config', + i + 1, + 'of', + PROCESS_CONFIG.length, + ':', + config.triggerLabel + ) + var configCompleted = rebuildDoc(config) if (!configCompleted) { - console.log('[rebuildAllDocs] Paused due to time constraints. Run rebuildAllDocs() again to continue.'); - completed = false; - break; + console.log( + '[rebuildAllDocs] Paused due to time constraints. Run rebuildAllDocs() again to continue.' + ) + completed = false + break } } - + if (completed) { - console.log('[rebuildAllDocs] Rebuild preparation complete.'); - console.log('[rebuildAllDocs] Now run storeEmailsAndAttachments() to reprocess all emails.'); + console.log('[rebuildAllDocs] Rebuild preparation complete.') + console.log( + '[rebuildAllDocs] Now run storeEmailsAndAttachments() to reprocess all emails.' + ) } } @@ -56,120 +82,153 @@ function rebuildAllDocs() { * Returns true if completed, false if needs to continue in another execution. */ function rebuildDoc(config) { - var MAX_EXECUTION_TIME = 4 * 60 * 1000; // 4 minutes (leaving 2 min buffer for 6 min limit) - var BATCH_SIZE = config.batchSize || 250; // Process threads in batches (default: 250) - var startTime = new Date().getTime(); - - console.log('[rebuildDoc] Starting rebuild for:', config.triggerLabel); - - var triggerLabelName = config.triggerLabel; - var processedLabelName = config.processedLabel; - var stateKey = 'rebuild_state_' + triggerLabelName.replace(/[^a-zA-Z0-9]/g, '_'); - + var MAX_EXECUTION_TIME = 4 * 60 * 1000 // 4 minutes (leaving 2 min buffer for 6 min limit) + var BATCH_SIZE = config.batchSize || 250 // Process threads in batches (default: 250) + var startTime = new Date().getTime() + + console.log('[rebuildDoc] Starting rebuild for:', config.triggerLabel) + + var triggerLabelName = config.triggerLabel + var processedLabelName = config.processedLabel + var stateKey = + 'rebuild_state_' + triggerLabelName.replace(/[^a-zA-Z0-9]/g, '_') + // 1. Validate and get labels - console.log('[rebuildDoc] Looking up labels'); - var triggerLabel = GmailApp.getUserLabelByName(triggerLabelName); - var processedLabel = GmailApp.getUserLabelByName(processedLabelName); - + console.log('[rebuildDoc] Looking up labels') + var triggerLabel = GmailApp.getUserLabelByName(triggerLabelName) + var processedLabel = GmailApp.getUserLabelByName(processedLabelName) + if (!triggerLabel) { - console.error('[rebuildDoc] Trigger label not found:', triggerLabelName); - Logger.log("Trigger label not found: " + triggerLabelName); - return true; // Nothing to do, consider complete + console.error('[rebuildDoc] Trigger label not found:', triggerLabelName) + Logger.log('Trigger label not found: ' + triggerLabelName) + return true // Nothing to do, consider complete } - + if (!processedLabel) { - console.log('[rebuildDoc] Processed label not found:', processedLabelName, '- nothing to unarchive'); + console.log( + '[rebuildDoc] Processed label not found:', + processedLabelName, + '- nothing to unarchive' + ) } - + // 2. Check if we need to clear the document (only on first run) - var properties = PropertiesService.getUserProperties(); - var rebuildState = properties.getProperty(stateKey); - var state = rebuildState ? JSON.parse(rebuildState) : { phase: 'clear_doc' }; - + var properties = PropertiesService.getUserProperties() + var rebuildState = properties.getProperty(stateKey) + var state = rebuildState ? JSON.parse(rebuildState) : { phase: 'clear_doc' } + if (state.phase === 'clear_doc') { - console.log('[rebuildDoc] Clearing document:', config.docId); + console.log('[rebuildDoc] Clearing document:', config.docId) try { - var doc = DocumentApp.openById(config.docId); - var body = doc.getBody(); - + var doc = DocumentApp.openById(config.docId) + var body = doc.getBody() + // Clear all content from the document body in a single operation - body.setText(''); - console.log('[rebuildDoc] Document cleared'); - + body.setText('') + console.log('[rebuildDoc] Document cleared') + // Move to next phase - state.phase = 'move_emails'; - properties.setProperty(stateKey, JSON.stringify(state)); + state.phase = 'move_emails' + properties.setProperty(stateKey, JSON.stringify(state)) } catch (e) { - console.error('[rebuildDoc] Error clearing document:', e.message); - Logger.log("Error clearing document: " + e.message); - properties.deleteProperty(stateKey); - return true; // Error, consider done to avoid infinite loop + console.error('[rebuildDoc] Error clearing document:', e.message) + Logger.log('Error clearing document: ' + e.message) + properties.deleteProperty(stateKey) + return true // Error, consider done to avoid infinite loop } } - + // 3. Move emails from processed label back to trigger label (batched) if (state.phase === 'move_emails' && processedLabel) { - console.log('[rebuildDoc] Moving processed emails back to trigger label'); - - var allThreads = processedLabel.getThreads(); - var totalThreads = allThreads.length; - console.log('[rebuildDoc] Found', totalThreads, 'processed threads remaining'); - + console.log('[rebuildDoc] Moving processed emails back to trigger label') + + var allThreads = processedLabel.getThreads() + var totalThreads = allThreads.length + console.log( + '[rebuildDoc] Found', + totalThreads, + 'processed threads remaining' + ) + if (totalThreads === 0) { // No threads to process, we're done - properties.deleteProperty(stateKey); - console.log('[rebuildDoc] No threads to move'); - console.log('[rebuildDoc] Rebuild complete for:', config.triggerLabel); - console.log('[rebuildDoc] Run storeEmailsAndAttachments() to reprocess these emails'); - return true; + properties.deleteProperty(stateKey) + console.log('[rebuildDoc] No threads to move') + console.log('[rebuildDoc] Rebuild complete for:', config.triggerLabel) + console.log( + '[rebuildDoc] Run storeEmailsAndAttachments() to reprocess these emails' + ) + return true } - + // Process threads in batches, always starting from index 0 // (since we're removing threads as we go, the array shrinks) - var threadsToProcess = Math.min(BATCH_SIZE, totalThreads); - var threadsProcessed = 0; - + var threadsToProcess = Math.min(BATCH_SIZE, totalThreads) + var threadsProcessed = 0 + for (var i = 0; i < threadsToProcess; i++) { // Check if we're approaching time limit - var elapsed = new Date().getTime() - startTime; + var elapsed = new Date().getTime() - startTime if (elapsed > MAX_EXECUTION_TIME) { - console.log('[rebuildDoc] Approaching time limit, saving progress. Processed', threadsProcessed, 'threads this run'); - properties.setProperty(stateKey, JSON.stringify(state)); - return false; // Not completed, need another run + console.log( + '[rebuildDoc] Approaching time limit, saving progress. Processed', + threadsProcessed, + 'threads this run' + ) + properties.setProperty(stateKey, JSON.stringify(state)) + return false // Not completed, need another run } - - var thread = allThreads[i]; - triggerLabel.addToThread(thread); - processedLabel.removeFromThread(thread); - threadsProcessed++; - + + var thread = allThreads[i] + triggerLabel.addToThread(thread) + processedLabel.removeFromThread(thread) + threadsProcessed++ + if ((i + 1) % 10 === 0 || i === threadsToProcess - 1) { - console.log('[rebuildDoc] Moved', i + 1, 'of', threadsToProcess, 'threads in this batch'); + console.log( + '[rebuildDoc] Moved', + i + 1, + 'of', + threadsToProcess, + 'threads in this batch' + ) } } - - console.log('[rebuildDoc] Processed', threadsProcessed, 'threads in this batch,', totalThreads - threadsToProcess, 'remaining'); - + + console.log( + '[rebuildDoc] Processed', + threadsProcessed, + 'threads in this batch,', + totalThreads - threadsToProcess, + 'remaining' + ) + if (threadsToProcess >= totalThreads) { // All threads processed - properties.deleteProperty(stateKey); - console.log('[rebuildDoc] Moved all threads back to trigger label'); - console.log('[rebuildDoc] Rebuild complete for:', config.triggerLabel); - console.log('[rebuildDoc] Run storeEmailsAndAttachments() to reprocess these emails'); - return true; + properties.deleteProperty(stateKey) + console.log('[rebuildDoc] Moved all threads back to trigger label') + console.log('[rebuildDoc] Rebuild complete for:', config.triggerLabel) + console.log( + '[rebuildDoc] Run storeEmailsAndAttachments() to reprocess these emails' + ) + return true } else { // More threads to process - properties.setProperty(stateKey, JSON.stringify(state)); - console.log('[rebuildDoc] Batch complete. Run rebuildAllDocs() again to continue.'); - return false; + properties.setProperty(stateKey, JSON.stringify(state)) + console.log( + '[rebuildDoc] Batch complete. Run rebuildAllDocs() again to continue.' + ) + return false } } - + // If we got here with no processed label, we're done - properties.deleteProperty(stateKey); - console.log('[rebuildDoc] Rebuild complete for:', config.triggerLabel); - console.log('[rebuildDoc] Run storeEmailsAndAttachments() to reprocess these emails'); - return true; + properties.deleteProperty(stateKey) + console.log('[rebuildDoc] Rebuild complete for:', config.triggerLabel) + console.log( + '[rebuildDoc] Run storeEmailsAndAttachments() to reprocess these emails' + ) + return true } /** @@ -177,235 +236,307 @@ function rebuildDoc(config) { * Processes a single configuration group (Label -> Doc + Folder). */ function processLabelGroup(config) { - console.log('[processLabelGroup] Starting processing for:', config.triggerLabel); - var triggerLabelName = config.triggerLabel; - var processedLabelName = config.processedLabel; + console.log( + '[processLabelGroup] Starting processing for:', + config.triggerLabel + ) + var triggerLabelName = config.triggerLabel + var processedLabelName = config.processedLabel // 1. Validate Labels - console.log('[processLabelGroup] Looking up trigger label:', triggerLabelName); - var triggerLabel = GmailApp.getUserLabelByName(triggerLabelName); - var processedLabel = GmailApp.getUserLabelByName(processedLabelName); - + console.log('[processLabelGroup] Looking up trigger label:', triggerLabelName) + var triggerLabel = GmailApp.getUserLabelByName(triggerLabelName) + var processedLabel = GmailApp.getUserLabelByName(processedLabelName) + // Create processed label if it doesn't exist if (!processedLabel) { - console.log('[processLabelGroup] Processed label not found, creating:', processedLabelName); + console.log( + '[processLabelGroup] Processed label not found, creating:', + processedLabelName + ) try { - processedLabel = GmailApp.createLabel(processedLabelName); - console.log('[processLabelGroup] Created label:', processedLabelName); - } catch(e) { - Logger.log("Could not create label: " + processedLabelName); - console.error('[processLabelGroup] Error creating label:', e.message); + processedLabel = GmailApp.createLabel(processedLabelName) + console.log('[processLabelGroup] Created label:', processedLabelName) + } catch (e) { + Logger.log('Could not create label: ' + processedLabelName) + console.error('[processLabelGroup] Error creating label:', e.message) } } if (!triggerLabel) { - Logger.log("Trigger label not found: " + triggerLabelName); - console.error('[processLabelGroup] Trigger label not found:', triggerLabelName); - return; + Logger.log('Trigger label not found: ' + triggerLabelName) + console.error( + '[processLabelGroup] Trigger label not found:', + triggerLabelName + ) + return } // 2. Get Threads - console.log('[processLabelGroup] Retrieving threads for label:', triggerLabelName); - var threads = triggerLabel.getThreads(); + console.log( + '[processLabelGroup] Retrieving threads for label:', + triggerLabelName + ) + var threads = triggerLabel.getThreads() if (!threads || threads.length === 0) { - Logger.log("No emails found for: " + triggerLabelName); - console.log('[processLabelGroup] No threads found for label:', triggerLabelName); - return; + Logger.log('No emails found for: ' + triggerLabelName) + console.log( + '[processLabelGroup] No threads found for label:', + triggerLabelName + ) + return } - console.log('[processLabelGroup] Found', threads.length, 'threads to process'); + console.log('[processLabelGroup] Found', threads.length, 'threads to process') // 3. Open Destination Doc and Folder - console.log('[processLabelGroup] Opening doc:', config.docId, 'and folder:', config.folderId); + console.log( + '[processLabelGroup] Opening doc:', + config.docId, + 'and folder:', + config.folderId + ) try { - var doc = DocumentApp.openById(config.docId); - var body = doc.getBody(); - var folder = DriveApp.getFolderById(config.folderId); - console.log('[processLabelGroup] Successfully opened doc and folder'); + var doc = DocumentApp.openById(config.docId) + var body = doc.getBody() + var folder = DriveApp.getFolderById(config.folderId) + console.log('[processLabelGroup] Successfully opened doc and folder') } catch (e) { - Logger.log("Error opening Doc or Folder. Check IDs in Config.gs. Error: " + e.message); - console.error('[processLabelGroup] Error opening Doc/Folder:', e.message); - return; + Logger.log( + 'Error opening Doc or Folder. Check IDs in Config.gs. Error: ' + e.message + ) + console.error('[processLabelGroup] Error opening Doc/Folder:', e.message) + return } // 4. Process Emails - var totalMessages = 0; + var totalMessages = 0 threads.forEach((thread, threadIndex) => { - var messages = thread.getMessages(); - console.log('[processLabelGroup] Thread', threadIndex + 1, 'has', messages.length, 'messages'); - + var messages = thread.getMessages() + console.log( + '[processLabelGroup] Thread', + threadIndex + 1, + 'has', + messages.length, + 'messages' + ) + // Sort messages by date (oldest first) so when we prepend (insert at index 0), // the newest messages end up at the top of the document - messages.sort(function(a, b) { - return a.getDate().getTime() - b.getDate().getTime(); - }); - + messages.sort(function (a, b) { + return a.getDate().getTime() - b.getDate().getTime() + }) + messages.forEach((message, msgIndex) => { - totalMessages++; - var subject = message.getSubject(); - var rawContent = message.getPlainBody(); - + totalMessages++ + var subject = message.getSubject() + var rawContent = message.getPlainBody() + // Clean Content (removes replies, quote lines, and legal footers) - var cleanContent = getCleanBody(rawContent); - console.log('[processLabelGroup] Cleaned content length:', cleanContent.length, 'chars (from', rawContent.length, ')'); - - var timestamp = message.getDate(); - - Logger.log("Processing: " + subject); - console.log('[processLabelGroup] Processing message', msgIndex + 1, ':', subject); + var cleanContent = getCleanBody(rawContent) + console.log( + '[processLabelGroup] Cleaned content length:', + cleanContent.length, + 'chars (from', + rawContent.length, + ')' + ) + + var timestamp = message.getDate() + + Logger.log('Processing: ' + subject) + console.log( + '[processLabelGroup] Processing message', + msgIndex + 1, + ':', + subject + ) // --- A. Prepend Text to Doc (insert at top, newest first) --- // Note: currentIndex starts at 0 for each message, so each new message // is inserted at the top of the document, pushing previous content down. // This ensures the most recent emails appear first. - var currentIndex = 0; - - var subjectText = "Subject: " + (subject ? subject : "(No Subject)"); - var headingPara = body.insertParagraph(currentIndex++, subjectText); - + var currentIndex = 0 + + var subjectText = 'Subject: ' + (subject ? subject : '(No Subject)') + var headingPara = body.insertParagraph(currentIndex++, subjectText) + // Try to set heading, fallback to bold if Doc is busy try { - headingPara.setHeading(DocumentApp.ParagraphHeading.HEADING_3); + headingPara.setHeading(DocumentApp.ParagraphHeading.HEADING_3) } catch (e) { - var style = {}; - style[DocumentApp.Attribute.BOLD] = true; - headingPara.setAttributes(style); + var style = {} + style[DocumentApp.Attribute.BOLD] = true + headingPara.setAttributes(style) } - - body.insertParagraph(currentIndex++, "Date: " + timestamp); - body.insertParagraph(currentIndex++, cleanContent); + + body.insertParagraph(currentIndex++, 'Date: ' + timestamp) + body.insertParagraph(currentIndex++, cleanContent) // --- B. Save Attachments (CONTENT-BASED DEDUPLICATION) --- - var attachments = message.getAttachments(); - console.log('[processLabelGroup] Found', attachments.length, 'attachments'); + var attachments = message.getAttachments() + console.log( + '[processLabelGroup] Found', + attachments.length, + 'attachments' + ) if (attachments.length > 0) { - body.insertParagraph(currentIndex++, "[Attachments]:"); - + body.insertParagraph(currentIndex++, '[Attachments]:') + attachments.forEach((att, attIndex) => { - console.log('[processLabelGroup] Processing attachment', attIndex + 1, 'of', attachments.length, ':', att.getName()); - var fileName = att.getName(); - var newFileBlob = att.copyBlob(); - var isDuplicate = false; - + console.log( + '[processLabelGroup] Processing attachment', + attIndex + 1, + 'of', + attachments.length, + ':', + att.getName() + ) + var fileName = att.getName() + var newFileBlob = att.copyBlob() + var isDuplicate = false + // 1. Get all files in folder with this name - var existingFiles = folder.getFilesByName(fileName); - console.log('[processLabelGroup] Checking for existing files named:', fileName); - - var existingCount = 0; + var existingFiles = folder.getFilesByName(fileName) + console.log( + '[processLabelGroup] Checking for existing files named:', + fileName + ) + + var existingCount = 0 while (existingFiles.hasNext()) { - existingCount++; - var existingFile = existingFiles.next(); - console.log('[processLabelGroup] Comparing with existing file', existingCount); - + existingCount++ + var existingFile = existingFiles.next() + console.log( + '[processLabelGroup] Comparing with existing file', + existingCount + ) + // 2. Fast Fail: Compare sizes first if (existingFile.getSize() === newFileBlob.getBytes().length) { - console.log('[processLabelGroup] Size match, checking hash'); - + console.log('[processLabelGroup] Size match, checking hash') + // 3. Deep Check: Compare MD5 Hashes (The "Fingerprint") - var existingHash = getFileHash(existingFile.getBlob()); - var newHash = getFileHash(newFileBlob); - + var existingHash = getFileHash(existingFile.getBlob()) + var newHash = getFileHash(newFileBlob) + if (existingHash === newHash) { - console.log('[processLabelGroup] Hash match - duplicate detected'); - isDuplicate = true; - break; // Stop checking, we found the twin + console.log( + '[processLabelGroup] Hash match - duplicate detected' + ) + isDuplicate = true + break // Stop checking, we found the twin } else { - console.log('[processLabelGroup] Hash mismatch - different content'); + console.log( + '[processLabelGroup] Hash mismatch - different content' + ) } } else { - console.log('[processLabelGroup] Size mismatch - different file'); + console.log('[processLabelGroup] Size mismatch - different file') } } - + if (isDuplicate) { - Logger.log("Skipping exact duplicate: " + fileName); - console.log('[processLabelGroup] Skipping duplicate:', fileName); - body.insertParagraph(currentIndex++, "- [DUPLICATE SKIPPED] " + fileName); + Logger.log('Skipping exact duplicate: ' + fileName) + console.log('[processLabelGroup] Skipping duplicate:', fileName) + body.insertParagraph( + currentIndex++, + '- [DUPLICATE SKIPPED] ' + fileName + ) } else { - // It's a new file (or a file with same name but different content) - - // If name exists but content is different, rename to avoid overwrite - if (folder.getFilesByName(fileName).hasNext()) { - console.log('[processLabelGroup] Name conflict detected, adding timestamp'); - var timeTag = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "_HHmmss"); - // Insert timestamp before the file extension - var newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1'); - // Fallback if regex fails (files without extension) - if(newName === fileName) newName += timeTag; - - newFileBlob.setName(newName); - fileName = newName; // Update for log - console.log('[processLabelGroup] Renamed to:', fileName); - } - - console.log('[processLabelGroup] Saving new file:', fileName); - var file = folder.createFile(newFileBlob); - body.insertParagraph(currentIndex++, "- " + file.getName()); - console.log('[processLabelGroup] File saved successfully'); + // It's a new file (or a file with same name but different content) + + // If name exists but content is different, rename to avoid overwrite + if (folder.getFilesByName(fileName).hasNext()) { + console.log( + '[processLabelGroup] Name conflict detected, adding timestamp' + ) + var timeTag = Utilities.formatDate( + new Date(), + Session.getScriptTimeZone(), + '_HHmmss' + ) + // Insert timestamp before the file extension + var newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1') + // Fallback if regex fails (files without extension) + if (newName === fileName) newName += timeTag + + newFileBlob.setName(newName) + fileName = newName // Update for log + console.log('[processLabelGroup] Renamed to:', fileName) + } + + console.log('[processLabelGroup] Saving new file:', fileName) + var file = folder.createFile(newFileBlob) + body.insertParagraph(currentIndex++, '- ' + file.getName()) + console.log('[processLabelGroup] File saved successfully') } - }); + }) } - body.insertParagraph(currentIndex++, "------------------------------"); - + body.insertParagraph(currentIndex++, '------------------------------') + // Pause briefly to allow Google Doc to save (prevents crash) - Utilities.sleep(500); - }); + Utilities.sleep(500) + }) // Add a clear separator between threads (after all messages in a thread are processed) if (messages.length > 0) { - body.insertParagraph(0, "=============================="); + body.insertParagraph(0, '==============================') } // 5. Cleanup Labels - console.log('[processLabelGroup] Updating labels for thread'); - triggerLabel.removeFromThread(thread); - if(processedLabel) processedLabel.addToThread(thread); - }); - console.log('[processLabelGroup] Processed', totalMessages, 'total messages'); - console.log('[processLabelGroup] Completed processing for:', config.triggerLabel); + console.log('[processLabelGroup] Updating labels for thread') + triggerLabel.removeFromThread(thread) + if (processedLabel) processedLabel.addToThread(thread) + }) + console.log('[processLabelGroup] Processed', totalMessages, 'total messages') + console.log( + '[processLabelGroup] Completed processing for:', + config.triggerLabel + ) } /** * Helper function to remove quoted replies, specific line patterns, and footers. */ function getCleanBody(text) { - if (!text) return ""; + if (!text) return '' // 1. FIRST PASS: Cut off at headers or footers var headerPatterns = [ - /^\s*On\s+.+\s+wrote:/m, // Gmail Reply Header - /^\s*From:\s+.+\s+Sent:\s+/m, // Outlook Reply Header - /^\s*_{10,}/m, // Underscore Separators - /^\s*From:\s+.+<.+@.+>/m, // Generic Header - /confidentiality notice/im // Legal Footer (Case Insensitive) - ]; + /^\s*On\s+.+\s+wrote:/m, // Gmail Reply Header + /^\s*From:\s+.+\s+Sent:\s+/m, // Outlook Reply Header + /^\s*_{10,}/m, // Underscore Separators + /^\s*From:\s+.+<.+@.+>/m, // Generic Header + /confidentiality notice/im, // Legal Footer (Case Insensitive) + ] - var splitIndex = -1; + var splitIndex = -1 - headerPatterns.forEach(pattern => { - var match = text.match(pattern); + headerPatterns.forEach((pattern) => { + var match = text.match(pattern) if (match) { if (splitIndex === -1 || match.index < splitIndex) { - splitIndex = match.index; + splitIndex = match.index } } - }); + }) - var workingText = (splitIndex !== -1) ? text.substring(0, splitIndex) : text; + var workingText = splitIndex !== -1 ? text.substring(0, splitIndex) : text // 2. SECOND PASS: Line Sweeper (Removes lines starting with > or <) - var lines = workingText.split('\n'); - var cleanLines = lines.filter(function(line) { - var trimmed = line.trim(); + var lines = workingText.split('\n') + var cleanLines = lines.filter(function (line) { + var trimmed = line.trim() // Returns FALSE (removes line) if it starts with > or < - return !(trimmed.startsWith(">") || trimmed.startsWith("<")); - }); + return !(trimmed.startsWith('>') || trimmed.startsWith('<')) + }) // 3. THIRD PASS: Normalize line breaks (convert 2+ consecutive to 1) // This prevents excessive blank lines in Google Docs where each \n creates a paragraph break - var result = cleanLines.join('\n').trim(); - result = result.replace(/\n{2,}/g, '\n'); - - return result; + var result = cleanLines.join('\n').trim() + result = result.replace(/\n{2,}/g, '\n') + + return result } /** @@ -413,13 +544,23 @@ function getCleanBody(text) { * Returns a string representing the binary content. */ function getFileHash(blob) { - var digest = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, blob.getBytes()); - return digest.map(function(byte) { - return ('0' + (byte & 0xFF).toString(16)).slice(-2); - }).join(''); + var digest = Utilities.computeDigest( + Utilities.DigestAlgorithm.MD5, + blob.getBytes() + ) + return digest + .map(function (byte) { + return ('0' + (byte & 0xff).toString(16)).slice(-2) + }) + .join('') } // Export functions for testing (Node.js only) if (typeof module !== 'undefined' && module.exports) { - module.exports = { rebuildDoc, rebuildAllDocs, processLabelGroup, storeEmailsAndAttachments }; + module.exports = { + rebuildDoc, + rebuildAllDocs, + processLabelGroup, + storeEmailsAndAttachments, + } } diff --git a/src/gmail-to-drive-by-labels/config.gs b/src/gmail-to-drive-by-labels/config.gs index cfb5e657..aa00076e 100644 --- a/src/gmail-to-drive-by-labels/config.gs +++ b/src/gmail-to-drive-by-labels/config.gs @@ -5,18 +5,18 @@ function getProcessConfig() { return [ { - triggerLabel: "label`", - processedLabel: "label-archived", - docId: "GUID", // Text goes here - folderId: "GUID", // Attachments go here - batchSize: 250 // Optional: Number of threads to process per batch during rebuild (default: 250) + triggerLabel: 'label`', + processedLabel: 'label-archived', + docId: 'GUID', // Text goes here + folderId: 'GUID', // Attachments go here + batchSize: 250, // Optional: Number of threads to process per batch during rebuild (default: 250) }, { - triggerLabel: "nested-label/label`", - processedLabel: "nested-label/label-archived", - docId: "GUID", // Text goes here - folderId: "GUID" // Attachments go here + triggerLabel: 'nested-label/label`', + processedLabel: 'nested-label/label-archived', + docId: 'GUID', // Text goes here + folderId: 'GUID', // Attachments go here // batchSize: 250 // Optional: can be omitted to use default - } - ]; + }, + ] } diff --git a/src/gmail-to-drive-by-labels/src/index.js b/src/gmail-to-drive-by-labels/src/index.js index 6aa610c5..9854f363 100644 --- a/src/gmail-to-drive-by-labels/src/index.js +++ b/src/gmail-to-drive-by-labels/src/index.js @@ -1,16 +1,16 @@ /** * Gmail to Drive processing utilities. - * + * * Designed to be testable outside of Google Apps Script by accepting * objects that match the minimal interfaces used. */ -const { getCleanBody, getFileHash } = require('../../gas-utils'); +const { getCleanBody, getFileHash } = require('../../gas-utils') /** * Process a single message and prepend its content to the document body. * Returns the number of paragraphs inserted. - * + * * @param {Object} message - Gmail message object * @param {Object} body - Document body object * @param {Object} folder - Drive folder object @@ -18,137 +18,163 @@ const { getCleanBody, getFileHash } = require('../../gas-utils'); * @returns {number} Number of paragraphs inserted */ function processMessageToDoc(message, body, folder, options = {}) { - const { DocumentApp, Utilities, Logger, Session } = options; - - const subject = message.getSubject(); - const rawContent = message.getPlainBody(); - const cleanContent = getCleanBody(rawContent); - const timestamp = message.getDate(); - + const { DocumentApp, Utilities, Logger, Session } = options + + const subject = message.getSubject() + const rawContent = message.getPlainBody() + const cleanContent = getCleanBody(rawContent) + const timestamp = message.getDate() + if (Logger) { - Logger.log("Processing: " + subject); + Logger.log('Processing: ' + subject) } - console.log('[processMessageToDoc] Processing message:', subject); - - let currentIndex = 0; - + console.log('[processMessageToDoc] Processing message:', subject) + + let currentIndex = 0 + // Insert subject - const subjectText = "Subject: " + (subject ? subject : "(No Subject)"); - const headingPara = body.insertParagraph(currentIndex++, subjectText); - + const subjectText = 'Subject: ' + (subject ? subject : '(No Subject)') + const headingPara = body.insertParagraph(currentIndex++, subjectText) + // Try to set heading style (GAS only) if (DocumentApp) { try { - headingPara.setHeading(DocumentApp.ParagraphHeading.HEADING_3); + headingPara.setHeading(DocumentApp.ParagraphHeading.HEADING_3) } catch (e) { - const style = {}; - style[DocumentApp.Attribute.BOLD] = true; - headingPara.setAttributes(style); + const style = {} + style[DocumentApp.Attribute.BOLD] = true + headingPara.setAttributes(style) } } - + // Insert date and content - body.insertParagraph(currentIndex++, "Date: " + timestamp); - body.insertParagraph(currentIndex++, cleanContent); - + body.insertParagraph(currentIndex++, 'Date: ' + timestamp) + body.insertParagraph(currentIndex++, cleanContent) + // Process attachments - const attachments = message.getAttachments(); - console.log('[processMessageToDoc] Found', attachments.length, 'attachments'); - + const attachments = message.getAttachments() + console.log('[processMessageToDoc] Found', attachments.length, 'attachments') + if (attachments.length > 0) { - body.insertParagraph(currentIndex++, "[Attachments]:"); - + body.insertParagraph(currentIndex++, '[Attachments]:') + attachments.forEach((att, attIndex) => { - console.log('[processMessageToDoc] Processing attachment', attIndex + 1, 'of', attachments.length, ':', att.getName()); - - let fileName = att.getName(); + console.log( + '[processMessageToDoc] Processing attachment', + attIndex + 1, + 'of', + attachments.length, + ':', + att.getName() + ) + + let fileName = att.getName() // In GAS environment, copyBlob() creates a copy; in test environment, att itself is the blob - const newFileBlob = att.copyBlob ? att.copyBlob() : att; - let isDuplicate = false; - + const newFileBlob = att.copyBlob ? att.copyBlob() : att + let isDuplicate = false + // Check for duplicates by content hash - const existingFiles = folder.getFilesByName(fileName); - console.log('[processMessageToDoc] Checking for existing files named:', fileName); - - let existingCount = 0; + const existingFiles = folder.getFilesByName(fileName) + console.log( + '[processMessageToDoc] Checking for existing files named:', + fileName + ) + + let existingCount = 0 while (existingFiles.hasNext()) { - existingCount++; - const existingFile = existingFiles.next(); - console.log('[processMessageToDoc] Comparing with existing file', existingCount); - + existingCount++ + const existingFile = existingFiles.next() + console.log( + '[processMessageToDoc] Comparing with existing file', + existingCount + ) + // Compare sizes first (fast fail) // Try getBytes() for GAS blobs, bytes property for test mocks, empty buffer as fallback - const newFileBytes = newFileBlob.getBytes ? newFileBlob.getBytes() : newFileBlob.bytes || Buffer.from(''); + const newFileBytes = newFileBlob.getBytes + ? newFileBlob.getBytes() + : newFileBlob.bytes || Buffer.from('') if (existingFile.getSize() === newFileBytes.length) { - console.log('[processMessageToDoc] Size match, checking hash'); - + console.log('[processMessageToDoc] Size match, checking hash') + // Deep check: compare MD5 hashes - const existingHash = getFileHash(existingFile.getBlob()); - const newHash = getFileHash(newFileBlob); - + const existingHash = getFileHash(existingFile.getBlob()) + const newHash = getFileHash(newFileBlob) + if (existingHash === newHash) { - console.log('[processMessageToDoc] Hash match - duplicate detected'); - isDuplicate = true; - break; + console.log('[processMessageToDoc] Hash match - duplicate detected') + isDuplicate = true + break } else { - console.log('[processMessageToDoc] Hash mismatch - different content'); + console.log( + '[processMessageToDoc] Hash mismatch - different content' + ) } } else { - console.log('[processMessageToDoc] Size mismatch - different file'); + console.log('[processMessageToDoc] Size mismatch - different file') } } - + if (isDuplicate) { if (Logger) { - Logger.log("Skipping exact duplicate: " + fileName); + Logger.log('Skipping exact duplicate: ' + fileName) } - console.log('[processMessageToDoc] Skipping duplicate:', fileName); - body.insertParagraph(currentIndex++, "- [DUPLICATE SKIPPED] " + fileName); + console.log('[processMessageToDoc] Skipping duplicate:', fileName) + body.insertParagraph( + currentIndex++, + '- [DUPLICATE SKIPPED] ' + fileName + ) } else { // Handle name conflicts (same name, different content) if (folder.getFilesByName(fileName).hasNext()) { - console.log('[processMessageToDoc] Name conflict detected, adding timestamp'); - + console.log( + '[processMessageToDoc] Name conflict detected, adding timestamp' + ) + if (Utilities && Session) { - const timeTag = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "_HHmmss"); - const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1'); - fileName = (newName === fileName) ? fileName + timeTag : newName; + const timeTag = Utilities.formatDate( + new Date(), + Session.getScriptTimeZone(), + '_HHmmss' + ) + const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1') + fileName = newName === fileName ? fileName + timeTag : newName } else { // Test environment - simple timestamp - const timeTag = '_' + Date.now(); - const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1'); - fileName = (newName === fileName) ? fileName + timeTag : newName; + const timeTag = '_' + Date.now() + const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1') + fileName = newName === fileName ? fileName + timeTag : newName } - + if (newFileBlob.setName) { - newFileBlob.setName(fileName); + newFileBlob.setName(fileName) } - console.log('[processMessageToDoc] Renamed to:', fileName); + console.log('[processMessageToDoc] Renamed to:', fileName) } - - console.log('[processMessageToDoc] Saving new file:', fileName); - const file = folder.createFile(newFileBlob); - body.insertParagraph(currentIndex++, "- " + file.getName()); - console.log('[processMessageToDoc] File saved successfully'); + + console.log('[processMessageToDoc] Saving new file:', fileName) + const file = folder.createFile(newFileBlob) + body.insertParagraph(currentIndex++, '- ' + file.getName()) + console.log('[processMessageToDoc] File saved successfully') } - }); + }) } - + // Insert separator - body.insertParagraph(currentIndex++, "------------------------------"); - + body.insertParagraph(currentIndex++, '------------------------------') + // Pause in GAS environment to prevent crashes if (Utilities) { - Utilities.sleep(500); + Utilities.sleep(500) } - - return currentIndex; + + return currentIndex } /** * Process multiple messages from a thread, prepending them to the document. * Messages are sorted by date (oldest first) so newest appear at top. - * + * * @param {Array} messages - Array of Gmail message objects * @param {Object} body - Document body object * @param {Object} folder - Drive folder object @@ -158,21 +184,26 @@ function processMessageToDoc(message, body, folder, options = {}) { function processMessagesToDoc(messages, body, folder, options = {}) { // Sort messages by date (oldest first) so when we prepend (insert at index 0), // the newest messages end up at the top of the document - const sortedMessages = messages.slice().sort(function(a, b) { - return a.getDate().getTime() - b.getDate().getTime(); - }); - + const sortedMessages = messages.slice().sort(function (a, b) { + return a.getDate().getTime() - b.getDate().getTime() + }) + sortedMessages.forEach((message, msgIndex) => { - console.log('[processMessagesToDoc] Processing message', msgIndex + 1, 'of', sortedMessages.length); - processMessageToDoc(message, body, folder, options); - }); - + console.log( + '[processMessagesToDoc] Processing message', + msgIndex + 1, + 'of', + sortedMessages.length + ) + processMessageToDoc(message, body, folder, options) + }) + // Add a clear separator between threads (after all messages in a thread are processed) if (sortedMessages.length > 0) { - body.insertParagraph(0, "=============================="); + body.insertParagraph(0, '==============================') } - - return sortedMessages.length; + + return sortedMessages.length } -module.exports = { processMessageToDoc, processMessagesToDoc }; +module.exports = { processMessageToDoc, processMessagesToDoc } diff --git a/src/gmail-to-drive-by-labels/tests/code.test.js b/src/gmail-to-drive-by-labels/tests/code.test.js index b9047145..20f8023c 100644 --- a/src/gmail-to-drive-by-labels/tests/code.test.js +++ b/src/gmail-to-drive-by-labels/tests/code.test.js @@ -1,4 +1,4 @@ -const { createMessage, createBlob } = require('../../../test-utils/mocks'); +const { createMessage, createBlob } = require('../../../test-utils/mocks') // Mock getProcessConfig global.getProcessConfig = jest.fn(() => [ @@ -6,52 +6,52 @@ global.getProcessConfig = jest.fn(() => [ triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - } -]); + folderId: 'test-folder', + }, +]) // Load code.gs functions -const { storeEmailsAndAttachments, processLabelGroup } = require('../code.gs'); +const { storeEmailsAndAttachments, processLabelGroup } = require('../code.gs') describe('storeEmailsAndAttachments', () => { beforeEach(() => { - global.__mocks.docs.__reset(); - global.__mocks.gmail.__reset(); - global.__mocks.drive.__reset(); - global.PropertiesService.__reset(); - jest.clearAllMocks(); - }); + global.__mocks.docs.__reset() + global.__mocks.gmail.__reset() + global.__mocks.drive.__reset() + global.PropertiesService.__reset() + jest.clearAllMocks() + }) test('processes all configurations', () => { // Setup: Create labels and add threads - const triggerLabel = global.GmailApp.createLabel('test-trigger'); - global.GmailApp.createLabel('test-archived'); - + const triggerLabel = global.GmailApp.createLabel('test-trigger') + global.GmailApp.createLabel('test-archived') + const msg = createMessage({ subject: 'Test Email', body: 'Test content', - date: new Date('2024-01-01T10:00:00Z') - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - + date: new Date('2024-01-01T10:00:00Z'), + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + // Setup: Create doc and folder - const doc = global.DocumentApp.openById('test-doc'); - global.DriveApp.getFolderById('test-folder'); - + const doc = global.DocumentApp.openById('test-doc') + global.DriveApp.getFolderById('test-folder') + // Run - storeEmailsAndAttachments(); - + storeEmailsAndAttachments() + // Verify: Thread was processed and moved to archived label - expect(triggerLabel.getThreads().length).toBe(0); - const archivedLabel = global.GmailApp.getUserLabelByName('test-archived'); - expect(archivedLabel.getThreads().length).toBe(1); - + expect(triggerLabel.getThreads().length).toBe(0) + const archivedLabel = global.GmailApp.getUserLabelByName('test-archived') + expect(archivedLabel.getThreads().length).toBe(1) + // Verify: Content was added to document - const body = doc.getBody(); - const paragraphs = body.getParagraphs(); - expect(paragraphs.length).toBeGreaterThan(0); - }); + const body = doc.getBody() + const paragraphs = body.getParagraphs() + expect(paragraphs.length).toBeGreaterThan(0) + }) test('processes multiple configurations', () => { // Setup multiple configs @@ -60,540 +60,546 @@ describe('storeEmailsAndAttachments', () => { triggerLabel: 'label-1', processedLabel: 'label-1-archived', docId: 'doc-1', - folderId: 'folder-1' + folderId: 'folder-1', }, { triggerLabel: 'label-2', processedLabel: 'label-2-archived', docId: 'doc-2', - folderId: 'folder-2' - } - ]); - + folderId: 'folder-2', + }, + ]) + // Setup labels and threads for both configs - global.GmailApp.createLabel('label-1'); - global.GmailApp.createLabel('label-1-archived'); - global.GmailApp.createLabel('label-2'); - global.GmailApp.createLabel('label-2-archived'); - - const msg1 = createMessage({ subject: 'Email 1', body: 'Body 1' }); - const msg2 = createMessage({ subject: 'Email 2', body: 'Body 2' }); - - global.GmailApp.__addThreadWithLabels(['label-1'], [msg1]); - global.GmailApp.__addThreadWithLabels(['label-2'], [msg2]); - - global.DocumentApp.openById('doc-1'); - global.DocumentApp.openById('doc-2'); - global.DriveApp.getFolderById('folder-1'); - global.DriveApp.getFolderById('folder-2'); - + global.GmailApp.createLabel('label-1') + global.GmailApp.createLabel('label-1-archived') + global.GmailApp.createLabel('label-2') + global.GmailApp.createLabel('label-2-archived') + + const msg1 = createMessage({ subject: 'Email 1', body: 'Body 1' }) + const msg2 = createMessage({ subject: 'Email 2', body: 'Body 2' }) + + global.GmailApp.__addThreadWithLabels(['label-1'], [msg1]) + global.GmailApp.__addThreadWithLabels(['label-2'], [msg2]) + + global.DocumentApp.openById('doc-1') + global.DocumentApp.openById('doc-2') + global.DriveApp.getFolderById('folder-1') + global.DriveApp.getFolderById('folder-2') + // Run - storeEmailsAndAttachments(); - + storeEmailsAndAttachments() + // Verify both configs were processed - const archived1 = global.GmailApp.getUserLabelByName('label-1-archived'); - const archived2 = global.GmailApp.getUserLabelByName('label-2-archived'); - expect(archived1.getThreads().length).toBe(1); - expect(archived2.getThreads().length).toBe(1); - }); + const archived1 = global.GmailApp.getUserLabelByName('label-1-archived') + const archived2 = global.GmailApp.getUserLabelByName('label-2-archived') + expect(archived1.getThreads().length).toBe(1) + expect(archived2.getThreads().length).toBe(1) + }) test('handles pause when rebuild does not complete', () => { // Import rebuildAllDocs - const { rebuildAllDocs } = require('../code.gs'); - + const { rebuildAllDocs } = require('../code.gs') + // Setup config with many threads to trigger timeout simulation global.getProcessConfig.mockReturnValue([ { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - } - ]); - - global.GmailApp.createLabel('test-trigger'); - const processedLabel = global.GmailApp.createLabel('test-archived'); - + folderId: 'test-folder', + }, + ]) + + global.GmailApp.createLabel('test-trigger') + const processedLabel = global.GmailApp.createLabel('test-archived') + // Add many threads to processed label for (let i = 0; i < 150; i++) { - const msg = createMessage({ subject: `Email ${i}`, body: `Body ${i}` }); - global.GmailApp.__addThreadWithLabels(['test-archived'], [msg]); + const msg = createMessage({ subject: `Email ${i}`, body: `Body ${i}` }) + global.GmailApp.__addThreadWithLabels(['test-archived'], [msg]) } - - global.DocumentApp.openById('test-doc'); - + + global.DocumentApp.openById('test-doc') + // Mock Date to simulate timeout - const originalDate = Date; - let callCount = 0; + const originalDate = Date + let callCount = 0 global.Date = class extends originalDate { getTime() { - callCount++; + callCount++ if (callCount > 50) { - return 5 * 60 * 1000; // 5 minutes - exceeds threshold + return 5 * 60 * 1000 // 5 minutes - exceeds threshold } - return 0; + return 0 } - }; - + } + // Run - should pause due to simulated timeout - rebuildAllDocs(); - + rebuildAllDocs() + // Restore Date - global.Date = originalDate; - + global.Date = originalDate + // Should have processed some but not all threads - expect(processedLabel.getThreads().length).toBeLessThan(150); - expect(processedLabel.getThreads().length).toBeGreaterThan(0); - }); -}); + expect(processedLabel.getThreads().length).toBeLessThan(150) + expect(processedLabel.getThreads().length).toBeGreaterThan(0) + }) +}) describe('processLabelGroup', () => { beforeEach(() => { - global.__mocks.docs.__reset(); - global.__mocks.gmail.__reset(); - global.__mocks.drive.__reset(); - jest.clearAllMocks(); - }); + global.__mocks.docs.__reset() + global.__mocks.gmail.__reset() + global.__mocks.drive.__reset() + jest.clearAllMocks() + }) test('processes emails and adds them to document', () => { // Setup - const triggerLabel = global.GmailApp.createLabel('test-trigger'); - global.GmailApp.createLabel('test-archived'); - + const triggerLabel = global.GmailApp.createLabel('test-trigger') + global.GmailApp.createLabel('test-archived') + // Create messages with different dates to test sorting const msg1 = createMessage({ subject: 'Oldest Email', body: 'Body 1', - date: new Date('2024-01-01T10:00:00Z') - }); + date: new Date('2024-01-01T10:00:00Z'), + }) const msg2 = createMessage({ subject: 'Newest Email', body: 'Body 2', - date: new Date('2024-01-01T12:00:00Z') - }); + date: new Date('2024-01-01T12:00:00Z'), + }) const msg3 = createMessage({ subject: 'Middle Email', body: 'Body 3', - date: new Date('2024-01-01T11:00:00Z') - }); - + date: new Date('2024-01-01T11:00:00Z'), + }) + // Add in non-sorted order - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg2, msg1, msg3]); - - const doc = global.DocumentApp.openById('test-doc'); - const body = doc.getBody(); - global.DriveApp.getFolderById('test-folder'); - + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg2, msg1, msg3]) + + const doc = global.DocumentApp.openById('test-doc') + const body = doc.getBody() + global.DriveApp.getFolderById('test-folder') + // Run const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - processLabelGroup(config); - + folderId: 'test-folder', + } + processLabelGroup(config) + // Verify - expect(triggerLabel.getThreads().length).toBe(0); - const archived = global.GmailApp.getUserLabelByName('test-archived'); - expect(archived.getThreads().length).toBe(1); - - const paragraphs = body.getParagraphs(); - expect(paragraphs.length).toBeGreaterThan(0); - + expect(triggerLabel.getThreads().length).toBe(0) + const archived = global.GmailApp.getUserLabelByName('test-archived') + expect(archived.getThreads().length).toBe(1) + + const paragraphs = body.getParagraphs() + expect(paragraphs.length).toBeGreaterThan(0) + // Verify messages are in order (newest first in doc due to prepend) - const subjectParas = paragraphs.filter(p => p.getText().includes('Subject:')); - expect(subjectParas[0].getText()).toContain('Newest Email'); - expect(subjectParas[1].getText()).toContain('Middle Email'); - expect(subjectParas[2].getText()).toContain('Oldest Email'); - }); + const subjectParas = paragraphs.filter((p) => + p.getText().includes('Subject:') + ) + expect(subjectParas[0].getText()).toContain('Newest Email') + expect(subjectParas[1].getText()).toContain('Middle Email') + expect(subjectParas[2].getText()).toContain('Oldest Email') + }) test('creates processed label if it does not exist', () => { // Setup - no processed label exists - const triggerLabel = global.GmailApp.createLabel('test-trigger'); - - const msg = createMessage({ subject: 'Test', body: 'Body' }); - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - - global.DocumentApp.openById('test-doc'); - global.DriveApp.getFolderById('test-folder'); - + const triggerLabel = global.GmailApp.createLabel('test-trigger') + + const msg = createMessage({ subject: 'Test', body: 'Body' }) + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + + global.DocumentApp.openById('test-doc') + global.DriveApp.getFolderById('test-folder') + // Verify processed label doesn't exist yet - expect(global.GmailApp.getUserLabelByName('test-archived')).toBeNull(); - + expect(global.GmailApp.getUserLabelByName('test-archived')).toBeNull() + // Run const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - processLabelGroup(config); - + folderId: 'test-folder', + } + processLabelGroup(config) + // Verify processed label was created - const archived = global.GmailApp.getUserLabelByName('test-archived'); - expect(archived).not.toBeNull(); - expect(archived.getThreads().length).toBe(1); - }); + const archived = global.GmailApp.getUserLabelByName('test-archived') + expect(archived).not.toBeNull() + expect(archived.getThreads().length).toBe(1) + }) test('returns early if trigger label not found', () => { // Setup - trigger label doesn't exist - global.DocumentApp.openById('test-doc'); - global.DriveApp.getFolderById('test-folder'); - + global.DocumentApp.openById('test-doc') + global.DriveApp.getFolderById('test-folder') + // Run const config = { triggerLabel: 'non-existent-label', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - + folderId: 'test-folder', + } + // Should not throw - expect(() => processLabelGroup(config)).not.toThrow(); - }); + expect(() => processLabelGroup(config)).not.toThrow() + }) test('returns early if no threads found', () => { // Setup - label exists but has no threads - global.GmailApp.createLabel('test-trigger'); - global.GmailApp.createLabel('test-archived'); - global.DocumentApp.openById('test-doc'); - global.DriveApp.getFolderById('test-folder'); - + global.GmailApp.createLabel('test-trigger') + global.GmailApp.createLabel('test-archived') + global.DocumentApp.openById('test-doc') + global.DriveApp.getFolderById('test-folder') + // Run const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - + folderId: 'test-folder', + } + // Should not throw - expect(() => processLabelGroup(config)).not.toThrow(); - }); + expect(() => processLabelGroup(config)).not.toThrow() + }) test('handles document opening errors', () => { // Setup - const triggerLabel = global.GmailApp.createLabel('test-trigger'); - const msg = createMessage({ subject: 'Test', body: 'Body' }); - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - + const triggerLabel = global.GmailApp.createLabel('test-trigger') + const msg = createMessage({ subject: 'Test', body: 'Body' }) + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + // Mock DocumentApp.openById to throw error - const originalOpenById = global.DocumentApp.openById; + const originalOpenById = global.DocumentApp.openById global.DocumentApp.openById = jest.fn(() => { - throw new Error('Document not found'); - }); - + throw new Error('Document not found') + }) + // Run with doc that throws error const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'error-doc', - folderId: 'test-folder' - }; - + folderId: 'test-folder', + } + // Should not throw - expect(() => processLabelGroup(config)).not.toThrow(); - + expect(() => processLabelGroup(config)).not.toThrow() + // Thread should not be moved since processing failed - expect(triggerLabel.getThreads().length).toBe(1); - + expect(triggerLabel.getThreads().length).toBe(1) + // Restore - global.DocumentApp.openById = originalOpenById; - }); + global.DocumentApp.openById = originalOpenById + }) test('handles label creation error gracefully', () => { // Setup - const triggerLabel = global.GmailApp.createLabel('test-trigger'); - const msg = createMessage({ subject: 'Test', body: 'Body' }); - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - - global.DocumentApp.openById('test-doc'); - global.DriveApp.getFolderById('test-folder'); - + const triggerLabel = global.GmailApp.createLabel('test-trigger') + const msg = createMessage({ subject: 'Test', body: 'Body' }) + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + + global.DocumentApp.openById('test-doc') + global.DriveApp.getFolderById('test-folder') + // Mock createLabel to throw error - const originalCreateLabel = global.GmailApp.createLabel; + const originalCreateLabel = global.GmailApp.createLabel global.GmailApp.createLabel = jest.fn(() => { - throw new Error('Cannot create label'); - }); - + throw new Error('Cannot create label') + }) + // Run - processed label doesn't exist and creation will fail const config = { triggerLabel: 'test-trigger', processedLabel: 'new-archived-label', docId: 'test-doc', - folderId: 'test-folder' - }; - + folderId: 'test-folder', + } + // Should not throw - expect(() => processLabelGroup(config)).not.toThrow(); - + expect(() => processLabelGroup(config)).not.toThrow() + // Restore - global.GmailApp.createLabel = originalCreateLabel; - + global.GmailApp.createLabel = originalCreateLabel + // Thread should still be moved (label creation error is non-fatal) - expect(triggerLabel.getThreads().length).toBe(0); - }); + expect(triggerLabel.getThreads().length).toBe(0) + }) test('processes attachments with deduplication', () => { // Setup - global.GmailApp.createLabel('test-trigger'); - global.GmailApp.createLabel('test-archived'); - - const attachment = createBlob('file content', 'test.txt'); + global.GmailApp.createLabel('test-trigger') + global.GmailApp.createLabel('test-archived') + + const attachment = createBlob('file content', 'test.txt') const msg = createMessage({ subject: 'Email with attachment', body: 'Body content', - attachments: [attachment] - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - - const doc = global.DocumentApp.openById('test-doc'); - const folder = global.DriveApp.getFolderById('test-folder'); - + attachments: [attachment], + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + + const doc = global.DocumentApp.openById('test-doc') + const folder = global.DriveApp.getFolderById('test-folder') + // Run const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - processLabelGroup(config); - + folderId: 'test-folder', + } + processLabelGroup(config) + // Verify attachment was saved - const files = folder.__getFiles(); - expect(files.length).toBe(1); - expect(files[0].getName()).toBe('test.txt'); - + const files = folder.__getFiles() + expect(files.length).toBe(1) + expect(files[0].getName()).toBe('test.txt') + // Verify document mentions attachment - const body = doc.getBody(); - const paragraphs = body.getParagraphs(); - const attachmentMentioned = paragraphs.some(p => - p.getText().includes('[Attachments]') || p.getText().includes('test.txt') - ); - expect(attachmentMentioned).toBe(true); - }); + const body = doc.getBody() + const paragraphs = body.getParagraphs() + const attachmentMentioned = paragraphs.some( + (p) => + p.getText().includes('[Attachments]') || + p.getText().includes('test.txt') + ) + expect(attachmentMentioned).toBe(true) + }) test('skips duplicate attachments', () => { // Setup - global.GmailApp.createLabel('test-trigger'); - global.GmailApp.createLabel('test-archived'); - - const folder = global.DriveApp.getFolderById('test-folder'); - + global.GmailApp.createLabel('test-trigger') + global.GmailApp.createLabel('test-archived') + + const folder = global.DriveApp.getFolderById('test-folder') + // Pre-create the attachment in the folder with EXACT same content - const existingBlob = createBlob('exactly the same content', 'duplicate.txt'); - folder.createFile(existingBlob); - + const existingBlob = createBlob('exactly the same content', 'duplicate.txt') + folder.createFile(existingBlob) + // Create email with same attachment (exact same content and size) - const attachment = createBlob('exactly the same content', 'duplicate.txt'); + const attachment = createBlob('exactly the same content', 'duplicate.txt') const msg = createMessage({ subject: 'Email with duplicate', body: 'Body', - attachments: [attachment] - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - - const doc = global.DocumentApp.openById('test-doc'); - + attachments: [attachment], + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + + const doc = global.DocumentApp.openById('test-doc') + // Run const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - processLabelGroup(config); - + folderId: 'test-folder', + } + processLabelGroup(config) + // Verify attachment was NOT duplicated (still only 1 file) - const files = folder.__getFiles(); - expect(files.length).toBe(1); - + const files = folder.__getFiles() + expect(files.length).toBe(1) + // Verify document shows it was skipped - const body = doc.getBody(); - const paragraphs = body.getParagraphs(); - const skipMentioned = paragraphs.some(p => + const body = doc.getBody() + const paragraphs = body.getParagraphs() + const skipMentioned = paragraphs.some((p) => p.getText().includes('DUPLICATE SKIPPED') - ); - expect(skipMentioned).toBe(true); - }); + ) + expect(skipMentioned).toBe(true) + }) test('detects different content with same size', () => { // Setup - global.GmailApp.createLabel('test-trigger'); - global.GmailApp.createLabel('test-archived'); - - const folder = global.DriveApp.getFolderById('test-folder'); - + global.GmailApp.createLabel('test-trigger') + global.GmailApp.createLabel('test-archived') + + const folder = global.DriveApp.getFolderById('test-folder') + // Pre-create file with same size but different content - const existingBlob = createBlob('content123', 'file.txt'); // 10 bytes - folder.createFile(existingBlob); - + const existingBlob = createBlob('content123', 'file.txt') // 10 bytes + folder.createFile(existingBlob) + // Create email with attachment that has same size, different content - const attachment = createBlob('different', 'file.txt'); // 9 bytes - actually different size + const attachment = createBlob('different', 'file.txt') // 9 bytes - actually different size // Let's use same-length content - const attachment2 = createBlob('contenz456', 'file.txt'); // 10 bytes, different content + const attachment2 = createBlob('contenz456', 'file.txt') // 10 bytes, different content const msg = createMessage({ subject: 'Email', body: 'Body', - attachments: [attachment2] - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - - const doc = global.DocumentApp.openById('test-doc'); - + attachments: [attachment2], + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + + const doc = global.DocumentApp.openById('test-doc') + // Run const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - processLabelGroup(config); - + folderId: 'test-folder', + } + processLabelGroup(config) + // Verify we now have 2 files (original + renamed due to hash mismatch) - const files = folder.__getFiles(); - expect(files.length).toBe(2); - }); + const files = folder.__getFiles() + expect(files.length).toBe(2) + }) test('renames attachment when name conflicts but content differs', () => { // Setup - global.GmailApp.createLabel('test-trigger'); - global.GmailApp.createLabel('test-archived'); - - const folder = global.DriveApp.getFolderById('test-folder'); - + global.GmailApp.createLabel('test-trigger') + global.GmailApp.createLabel('test-archived') + + const folder = global.DriveApp.getFolderById('test-folder') + // Pre-create file with same name but different content - const existingBlob = createBlob('different content', 'file.txt'); - folder.createFile(existingBlob); - + const existingBlob = createBlob('different content', 'file.txt') + folder.createFile(existingBlob) + // Create email with attachment that has same name, different content - const attachment = createBlob('new content', 'file.txt'); + const attachment = createBlob('new content', 'file.txt') const msg = createMessage({ subject: 'Email with conflict', body: 'Body', - attachments: [attachment] - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - - const doc = global.DocumentApp.openById('test-doc'); - + attachments: [attachment], + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + + const doc = global.DocumentApp.openById('test-doc') + // Run const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - processLabelGroup(config); - + folderId: 'test-folder', + } + processLabelGroup(config) + // Verify we now have 2 files (original + renamed) - const files = folder.__getFiles(); - expect(files.length).toBe(2); - + const files = folder.__getFiles() + expect(files.length).toBe(2) + // One should be the original name, other should be renamed - const names = files.map(f => f.getName()).sort(); - expect(names[0]).toBe('file.txt'); - expect(names[1]).toMatch(/file.*\.txt/); // Should have timestamp inserted - }); + const names = files.map((f) => f.getName()).sort() + expect(names[0]).toBe('file.txt') + expect(names[1]).toMatch(/file.*\.txt/) // Should have timestamp inserted + }) test('handles setHeading error with fallback to bold', () => { // Setup - global.GmailApp.createLabel('test-trigger'); - global.GmailApp.createLabel('test-archived'); - + global.GmailApp.createLabel('test-trigger') + global.GmailApp.createLabel('test-archived') + const msg = createMessage({ subject: 'Test Subject', - body: 'Test body' - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - - const doc = global.DocumentApp.openById('test-doc'); - const body = doc.getBody(); - global.DriveApp.getFolderById('test-folder'); - + body: 'Test body', + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + + const doc = global.DocumentApp.openById('test-doc') + const body = doc.getBody() + global.DriveApp.getFolderById('test-folder') + // Spy on insertParagraph and make setHeading throw for subject line - const originalInsertParagraph = body.insertParagraph.bind(body); - + const originalInsertParagraph = body.insertParagraph.bind(body) + body.insertParagraph = (index, text) => { - const para = originalInsertParagraph(index, text); - + const para = originalInsertParagraph(index, text) + // Make setHeading throw for subject line to trigger catch block if (text && text.includes('Subject:')) { - const originalSetHeading = para.setHeading.bind(para); + const originalSetHeading = para.setHeading.bind(para) para.setHeading = (heading) => { // Throw error to trigger catch block - throw new Error('Document is busy'); - }; + throw new Error('Document is busy') + } } - - return para; - }; - + + return para + } + // Run const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - + folderId: 'test-folder', + } + // Should not throw - catch block should handle error gracefully - expect(() => processLabelGroup(config)).not.toThrow(); - + expect(() => processLabelGroup(config)).not.toThrow() + // Verify content was still added despite the error - const paragraphs = body.getParagraphs(); - expect(paragraphs.length).toBeGreaterThan(0); - + const paragraphs = body.getParagraphs() + expect(paragraphs.length).toBeGreaterThan(0) + // Verify subject paragraph exists - const subjectExists = paragraphs.some(p => p.getText().includes('Test Subject')); - expect(subjectExists).toBe(true); - }); + const subjectExists = paragraphs.some((p) => + p.getText().includes('Test Subject') + ) + expect(subjectExists).toBe(true) + }) test('processes email body with reply headers using getCleanBody', () => { // Setup - global.GmailApp.createLabel('test-trigger'); - global.GmailApp.createLabel('test-archived'); - + global.GmailApp.createLabel('test-trigger') + global.GmailApp.createLabel('test-archived') + // Create message with Gmail reply header const bodyWithHeader = `This is the actual content. On Mon, Jan 1, 2024 at 10:00 AM Someone wrote: > This is quoted text that should be removed. -> More quoted text.`; - +> More quoted text.` + const msg = createMessage({ subject: 'Test Email', - body: bodyWithHeader - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - - const doc = global.DocumentApp.openById('test-doc'); - const body = doc.getBody(); - global.DriveApp.getFolderById('test-folder'); - + body: bodyWithHeader, + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + + const doc = global.DocumentApp.openById('test-doc') + const body = doc.getBody() + global.DriveApp.getFolderById('test-folder') + // Run const config = { triggerLabel: 'test-trigger', processedLabel: 'test-archived', docId: 'test-doc', - folderId: 'test-folder' - }; - processLabelGroup(config); - + folderId: 'test-folder', + } + processLabelGroup(config) + // Verify - const paragraphs = body.getParagraphs(); - const bodyText = paragraphs.map(p => p.getText()).join('\n'); - + const paragraphs = body.getParagraphs() + const bodyText = paragraphs.map((p) => p.getText()).join('\n') + // Should include the actual content - expect(bodyText).toContain('This is the actual content'); + expect(bodyText).toContain('This is the actual content') // Should NOT include the quoted reply - expect(bodyText).not.toContain('This is quoted text that should be removed'); - }); -}); + expect(bodyText).not.toContain('This is quoted text that should be removed') + }) +}) diff --git a/src/gmail-to-drive-by-labels/tests/gas-utils.test.js b/src/gmail-to-drive-by-labels/tests/gas-utils.test.js index d17a1b5f..23245f57 100644 --- a/src/gmail-to-drive-by-labels/tests/gas-utils.test.js +++ b/src/gmail-to-drive-by-labels/tests/gas-utils.test.js @@ -1,87 +1,92 @@ -const { getCleanBody, getFileHash } = require('../../gas-utils'); +const { getCleanBody, getFileHash } = require('../../gas-utils') describe('getCleanBody', () => { test('returns empty string for falsy input', () => { - expect(getCleanBody(null)).toBe(''); - expect(getCleanBody('')).toBe(''); - }); + expect(getCleanBody(null)).toBe('') + expect(getCleanBody('')).toBe('') + }) test('removes quoted lines starting with > and <', () => { - const input = 'Hello\n> quoted line\n< another quote\nWorld'; - expect(getCleanBody(input)).toBe('Hello\nWorld'); - }); + const input = 'Hello\n> quoted line\n< another quote\nWorld' + expect(getCleanBody(input)).toBe('Hello\nWorld') + }) test('cuts off at reply header (On ... wrote:)', () => { - const input = 'Line1\nOn Jan 1, 2020, John Doe wrote:\nQuoted'; - expect(getCleanBody(input)).toBe('Line1'); - }); + const input = + 'Line1\nOn Jan 1, 2020, John Doe wrote:\nQuoted' + expect(getCleanBody(input)).toBe('Line1') + }) test('cuts off at confidentiality notice', () => { - const input = 'Message body\nThis is a confidentiality notice: do not share'; - expect(getCleanBody(input)).toBe('Message body'); - }); + const input = 'Message body\nThis is a confidentiality notice: do not share' + expect(getCleanBody(input)).toBe('Message body') + }) test('handles match at start of text (lineStart = 0)', () => { - const input = 'On Jan 1, 2020, John Doe wrote:\nQuoted content here'; - expect(getCleanBody(input)).toBe(''); - }); + const input = + 'On Jan 1, 2020, John Doe wrote:\nQuoted content here' + expect(getCleanBody(input)).toBe('') + }) test('preserves text when no patterns match', () => { - const input = 'Regular email content\nNo special patterns here'; - expect(getCleanBody(input)).toBe('Regular email content\nNo special patterns here'); - }); + const input = 'Regular email content\nNo special patterns here' + expect(getCleanBody(input)).toBe( + 'Regular email content\nNo special patterns here' + ) + }) test('normalizes multiple consecutive line breaks to single line break', () => { - const input = 'Paragraph 1\n\n\n\nParagraph 2'; - const result = getCleanBody(input); + const input = 'Paragraph 1\n\n\n\nParagraph 2' + const result = getCleanBody(input) // Should normalize to single line break (no blank lines) - expect(result).not.toContain('\n\n'); - expect(result).toBe('Paragraph 1\nParagraph 2'); - }); + expect(result).not.toContain('\n\n') + expect(result).toBe('Paragraph 1\nParagraph 2') + }) test('handles multiple occurrences of excessive line breaks', () => { - const input = 'Line 1\n\n\nLine 2\n\n\n\n\nLine 3'; - const result = getCleanBody(input); - expect(result).toBe('Line 1\nLine 2\nLine 3'); - }); + const input = 'Line 1\n\n\nLine 2\n\n\n\n\nLine 3' + const result = getCleanBody(input) + expect(result).toBe('Line 1\nLine 2\nLine 3') + }) test('preserves single line breaks', () => { - const input = 'Line 1\nLine 2\nLine 3'; - expect(getCleanBody(input)).toBe('Line 1\nLine 2\nLine 3'); - }); + const input = 'Line 1\nLine 2\nLine 3' + expect(getCleanBody(input)).toBe('Line 1\nLine 2\nLine 3') + }) test('normalizes double line breaks to single (for signatures)', () => { - const input = 'Paragraph 1\n\nParagraph 2'; - expect(getCleanBody(input)).toBe('Paragraph 1\nParagraph 2'); - }); + const input = 'Paragraph 1\n\nParagraph 2' + expect(getCleanBody(input)).toBe('Paragraph 1\nParagraph 2') + }) test('handles email signature with excessive line breaks', () => { - const input = 'Thank you!\n\n\n\nJohn Doe\n\nSoftware Engineer\n\n\nAcme Corp'; - const result = getCleanBody(input); - expect(result).toBe('Thank you!\nJohn Doe\nSoftware Engineer\nAcme Corp'); - expect(result).not.toContain('\n\n'); - }); -}); + const input = + 'Thank you!\n\n\n\nJohn Doe\n\nSoftware Engineer\n\n\nAcme Corp' + const result = getCleanBody(input) + expect(result).toBe('Thank you!\nJohn Doe\nSoftware Engineer\nAcme Corp') + expect(result).not.toContain('\n\n') + }) +}) describe('getFileHash', () => { test('computes md5 for a Buffer', () => { - const buf = Buffer.from('hello world'); - expect(getFileHash(buf)).toBe('5eb63bbbe01eeed093cb22bb8f5acdc3'); - }); + const buf = Buffer.from('hello world') + expect(getFileHash(buf)).toBe('5eb63bbbe01eeed093cb22bb8f5acdc3') + }) test('computes md5 for an object with getBytes()', () => { - const blob = { getBytes: () => Buffer.from('abc') }; - expect(getFileHash(blob)).toBe('900150983cd24fb0d6963f7d28e17f72'); - }); + const blob = { getBytes: () => Buffer.from('abc') } + expect(getFileHash(blob)).toBe('900150983cd24fb0d6963f7d28e17f72') + }) test('computes md5 for an object with bytes property', () => { - const blob = { bytes: Buffer.from('test') }; - expect(getFileHash(blob)).toBe('098f6bcd4621d373cade4e832627b4f6'); - }); + const blob = { bytes: Buffer.from('test') } + expect(getFileHash(blob)).toBe('098f6bcd4621d373cade4e832627b4f6') + }) test('throws error for unsupported blob type', () => { - expect(() => getFileHash('not a blob')).toThrow('Unsupported blob type'); - expect(() => getFileHash({})).toThrow('Unsupported blob type'); - expect(() => getFileHash(null)).toThrow('Unsupported blob type'); - }); -}); + expect(() => getFileHash('not a blob')).toThrow('Unsupported blob type') + expect(() => getFileHash({})).toThrow('Unsupported blob type') + expect(() => getFileHash(null)).toThrow('Unsupported blob type') + }) +}) diff --git a/src/gmail-to-drive-by-labels/tests/integration.test.js b/src/gmail-to-drive-by-labels/tests/integration.test.js index 7da9ae2e..33f1a005 100644 --- a/src/gmail-to-drive-by-labels/tests/integration.test.js +++ b/src/gmail-to-drive-by-labels/tests/integration.test.js @@ -1,501 +1,520 @@ -const { createMessage, createBlob } = require('../../../test-utils/mocks'); -const { processMessageToDoc, processMessagesToDoc } = require('../src/index'); +const { createMessage, createBlob } = require('../../../test-utils/mocks') +const { processMessageToDoc, processMessagesToDoc } = require('../src/index') // This test uses the actual processing functions from src/index.js // to verify that the prepend behavior works correctly end-to-end describe('Gmail to Drive integration with prepend behavior', () => { - let doc, body, folder, processedLabel; + let doc, body, folder, processedLabel beforeEach(() => { - global.__mocks.docs.__reset(); - global.__mocks.gmail.__reset(); - global.__mocks.drive.__reset(); - - doc = global.DocumentApp.openById('test-doc'); - body = doc.getBody(); - folder = global.DriveApp.getFolderById('test-folder'); - + global.__mocks.docs.__reset() + global.__mocks.gmail.__reset() + global.__mocks.drive.__reset() + + doc = global.DocumentApp.openById('test-doc') + body = doc.getBody() + folder = global.DriveApp.getFolderById('test-folder') + // Create the processed label - processedLabel = global.GmailApp.createLabel('test-archived'); - }); + processedLabel = global.GmailApp.createLabel('test-archived') + }) test('processes emails and prepends them to document in newest-first order', () => { // Setup: Add trigger label and emails - const triggerLabel = global.GmailApp.createLabel('test-trigger'); - + const triggerLabel = global.GmailApp.createLabel('test-trigger') + // Create three messages in chronological order const msg1 = createMessage({ subject: 'First Email', body: 'Content of first email', - date: new Date('2024-01-01T10:00:00Z') - }); - + date: new Date('2024-01-01T10:00:00Z'), + }) + const msg2 = createMessage({ subject: 'Second Email', body: 'Content of second email', - date: new Date('2024-01-01T11:00:00Z') - }); - + date: new Date('2024-01-01T11:00:00Z'), + }) + const msg3 = createMessage({ subject: 'Third Email', body: 'Content of third email', - date: new Date('2024-01-01T12:00:00Z') - }); - + date: new Date('2024-01-01T12:00:00Z'), + }) + // Add them to a thread - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg1, msg2, msg3]); - + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg1, msg2, msg3]) + // Process using the real function - const threads = triggerLabel.getThreads(); - expect(threads.length).toBe(1); - + const threads = triggerLabel.getThreads() + expect(threads.length).toBe(1) + threads.forEach((thread) => { - const messages = thread.getMessages(); - processMessagesToDoc(messages, body, folder); - }); - + const messages = thread.getMessages() + processMessagesToDoc(messages, body, folder) + }) + // Verify: Most recent email should be at the top - const paragraphs = body.getParagraphs(); - + const paragraphs = body.getParagraphs() + // Should have 4 paragraphs per email (subject, date, content, separator) × 3 emails + 1 thread separator = 13 paragraphs - expect(paragraphs.length).toBe(13); - + expect(paragraphs.length).toBe(13) + // Thread separator should be at top - expect(paragraphs[0].getText()).toBe('=============================='); - + expect(paragraphs[0].getText()).toBe('==============================') + // Third (most recent) email should be at top (after thread separator) - expect(paragraphs[1].getText()).toBe('Subject: Third Email'); - expect(paragraphs[2].getText()).toContain('2024'); - expect(paragraphs[3].getText()).toBe('Content of third email'); - expect(paragraphs[4].getText()).toBe('------------------------------'); - + expect(paragraphs[1].getText()).toBe('Subject: Third Email') + expect(paragraphs[2].getText()).toContain('2024') + expect(paragraphs[3].getText()).toBe('Content of third email') + expect(paragraphs[4].getText()).toBe('------------------------------') + // Second email should be in middle - expect(paragraphs[5].getText()).toBe('Subject: Second Email'); - expect(paragraphs[7].getText()).toBe('Content of second email'); - + expect(paragraphs[5].getText()).toBe('Subject: Second Email') + expect(paragraphs[7].getText()).toBe('Content of second email') + // First email should be at bottom - expect(paragraphs[9].getText()).toBe('Subject: First Email'); - expect(paragraphs[11].getText()).toBe('Content of first email'); - }); + expect(paragraphs[9].getText()).toBe('Subject: First Email') + expect(paragraphs[11].getText()).toBe('Content of first email') + }) test('prepends email with attachments correctly using production deduplication logic', () => { - const triggerLabel = global.GmailApp.createLabel('test-trigger'); - + const triggerLabel = global.GmailApp.createLabel('test-trigger') + // Create attachments - const attachment1 = createBlob('file content 1', 'file1.txt'); - const attachment2 = createBlob('file content 2', 'file2.pdf'); - + const attachment1 = createBlob('file content 1', 'file1.txt') + const attachment2 = createBlob('file content 2', 'file2.pdf') + const msg = createMessage({ subject: 'Email with Attachments', body: 'Email body content', date: new Date('2024-01-01T10:00:00Z'), - attachments: [attachment1, attachment2] - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - + attachments: [attachment1, attachment2], + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + // Process using the real function - const threads = triggerLabel.getThreads(); - const messages = threads[0].getMessages(); - - processMessagesToDoc(messages, body, folder); - + const threads = triggerLabel.getThreads() + const messages = threads[0].getMessages() + + processMessagesToDoc(messages, body, folder) + // Verify structure - const paragraphs = body.getParagraphs(); + const paragraphs = body.getParagraphs() // Thread separator + Subject + Date + Content + [Attachments] + file1 + file2 + separator = 8 paragraphs - expect(paragraphs[0].getText()).toBe('=============================='); - expect(paragraphs[1].getText()).toBe('Subject: Email with Attachments'); - expect(paragraphs[4].getText()).toBe('[Attachments]:'); - expect(paragraphs[5].getText()).toBe('- file1.txt'); - expect(paragraphs[6].getText()).toBe('- file2.pdf'); - expect(paragraphs[7].getText()).toBe('------------------------------'); - + expect(paragraphs[0].getText()).toBe('==============================') + expect(paragraphs[1].getText()).toBe('Subject: Email with Attachments') + expect(paragraphs[4].getText()).toBe('[Attachments]:') + expect(paragraphs[5].getText()).toBe('- file1.txt') + expect(paragraphs[6].getText()).toBe('- file2.pdf') + expect(paragraphs[7].getText()).toBe('------------------------------') + // Verify files were created - const files = folder.__getFiles(); - expect(files.length).toBe(2); - }); + const files = folder.__getFiles() + expect(files.length).toBe(2) + }) test('new emails prepend before existing document content', () => { // Pre-populate document with existing content - body.appendParagraph('Subject: Old Email'); - body.appendParagraph('Date: 2023-12-31'); - body.appendParagraph('This is old content'); - body.appendParagraph('------------------------------'); - + body.appendParagraph('Subject: Old Email') + body.appendParagraph('Date: 2023-12-31') + body.appendParagraph('This is old content') + body.appendParagraph('------------------------------') + // Now process a new email - const triggerLabel = global.GmailApp.createLabel('test-trigger'); + const triggerLabel = global.GmailApp.createLabel('test-trigger') const msg = createMessage({ subject: 'New Email', body: 'Fresh content', - date: new Date('2024-01-01T10:00:00Z') - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - + date: new Date('2024-01-01T10:00:00Z'), + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + // Process using the real function - const threads = triggerLabel.getThreads(); - const message = threads[0].getMessages()[0]; - - processMessageToDoc(message, body, folder); - + const threads = triggerLabel.getThreads() + const message = threads[0].getMessages()[0] + + processMessageToDoc(message, body, folder) + // Verify new content is at top, old content at bottom - const paragraphs = body.getParagraphs(); - expect(paragraphs.length).toBe(8); - + const paragraphs = body.getParagraphs() + expect(paragraphs.length).toBe(8) + // New email at top - expect(paragraphs[0].getText()).toBe('Subject: New Email'); - expect(paragraphs[2].getText()).toBe('Fresh content'); - + expect(paragraphs[0].getText()).toBe('Subject: New Email') + expect(paragraphs[2].getText()).toBe('Fresh content') + // Old email at bottom - expect(paragraphs[4].getText()).toBe('Subject: Old Email'); - expect(paragraphs[6].getText()).toBe('This is old content'); - }); + expect(paragraphs[4].getText()).toBe('Subject: Old Email') + expect(paragraphs[6].getText()).toBe('This is old content') + }) test('processes multiple threads and maintains newest-first ordering', () => { - const triggerLabel = global.GmailApp.createLabel('test-trigger'); - + const triggerLabel = global.GmailApp.createLabel('test-trigger') + // Create two separate threads (simulating different email conversations) const thread1Msg = createMessage({ subject: 'Thread 1 Email', body: 'Thread 1 content', - date: new Date('2024-01-01T10:00:00Z') - }); - + date: new Date('2024-01-01T10:00:00Z'), + }) + const thread2Msg = createMessage({ subject: 'Thread 2 Email', body: 'Thread 2 content', - date: new Date('2024-01-01T11:00:00Z') - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [thread1Msg]); - global.GmailApp.__addThreadWithLabels(['test-trigger'], [thread2Msg]); - + date: new Date('2024-01-01T11:00:00Z'), + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [thread1Msg]) + global.GmailApp.__addThreadWithLabels(['test-trigger'], [thread2Msg]) + // Process all threads using real function - const threads = triggerLabel.getThreads(); - expect(threads.length).toBe(2); - + const threads = triggerLabel.getThreads() + expect(threads.length).toBe(2) + threads.forEach((thread) => { - const messages = thread.getMessages(); - processMessagesToDoc(messages, body, folder); - }); - + const messages = thread.getMessages() + processMessagesToDoc(messages, body, folder) + }) + // Verify both threads are in document - const paragraphs = body.getParagraphs(); + const paragraphs = body.getParagraphs() // Each thread has: separator + subject + date + content + message separator = 5 paragraphs × 2 threads = 10 paragraphs - expect(paragraphs.length).toBe(10); - + expect(paragraphs.length).toBe(10) + // Most recently processed thread should be at top (after thread separator) - expect(paragraphs[0].getText()).toBe('=============================='); - expect(paragraphs[1].getText()).toBe('Subject: Thread 2 Email'); - expect(paragraphs[5].getText()).toBe('=============================='); - expect(paragraphs[6].getText()).toBe('Subject: Thread 1 Email'); - }); + expect(paragraphs[0].getText()).toBe('==============================') + expect(paragraphs[1].getText()).toBe('Subject: Thread 2 Email') + expect(paragraphs[5].getText()).toBe('==============================') + expect(paragraphs[6].getText()).toBe('Subject: Thread 1 Email') + }) test('handles attachment deduplication correctly', () => { // Pre-create a file in the folder - const existingBlob = createBlob('duplicate content', 'duplicate.txt'); - folder.createFile(existingBlob); - + const existingBlob = createBlob('duplicate content', 'duplicate.txt') + folder.createFile(existingBlob) + // Create a message with the same attachment - const duplicateAttachment = createBlob('duplicate content', 'duplicate.txt'); + const duplicateAttachment = createBlob('duplicate content', 'duplicate.txt') const msg = createMessage({ subject: 'Email with Duplicate', body: 'Test deduplication', date: new Date('2024-01-01T10:00:00Z'), - attachments: [duplicateAttachment] - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - const threads = global.GmailApp.getUserLabelByName('test-trigger').getThreads(); - const messages = threads[0].getMessages(); - - processMessagesToDoc(messages, body, folder); - + attachments: [duplicateAttachment], + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + const threads = + global.GmailApp.getUserLabelByName('test-trigger').getThreads() + const messages = threads[0].getMessages() + + processMessagesToDoc(messages, body, folder) + // Verify duplicate was skipped - const paragraphs = body.getParagraphs(); - expect(paragraphs.some(p => p.getText().includes('[DUPLICATE SKIPPED]'))).toBe(true); - + const paragraphs = body.getParagraphs() + expect( + paragraphs.some((p) => p.getText().includes('[DUPLICATE SKIPPED]')) + ).toBe(true) + // Should still only have 1 file (the original) - const files = folder.__getFiles(); - expect(files.length).toBe(1); - }); + const files = folder.__getFiles() + expect(files.length).toBe(1) + }) test('handles attachment name conflicts with timestamp renaming', () => { // Pre-create a file with same name but different content - const existingBlob = createBlob('original content', 'file.txt'); - folder.createFile(existingBlob); - + const existingBlob = createBlob('original content', 'file.txt') + folder.createFile(existingBlob) + // Create a message with attachment that has same name but different content - const newAttachment = createBlob('new content', 'file.txt'); + const newAttachment = createBlob('new content', 'file.txt') const msg = createMessage({ subject: 'Email with Name Conflict', body: 'Test name conflict', date: new Date('2024-01-01T10:00:00Z'), - attachments: [newAttachment] - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - const threads = global.GmailApp.getUserLabelByName('test-trigger').getThreads(); - const messages = threads[0].getMessages(); - - processMessagesToDoc(messages, body, folder); - + attachments: [newAttachment], + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + const threads = + global.GmailApp.getUserLabelByName('test-trigger').getThreads() + const messages = threads[0].getMessages() + + processMessagesToDoc(messages, body, folder) + // Should have 2 files now (original and renamed new one) - const files = folder.__getFiles(); - expect(files.length).toBe(2); - + const files = folder.__getFiles() + expect(files.length).toBe(2) + // New file should have timestamp in name - const fileNames = files.map(f => f.getName()); - expect(fileNames).toContain('file.txt'); - expect(fileNames.some(name => name.startsWith('file_') && name.endsWith('.txt'))).toBe(true); - }); + const fileNames = files.map((f) => f.getName()) + expect(fileNames).toContain('file.txt') + expect( + fileNames.some( + (name) => name.startsWith('file_') && name.endsWith('.txt') + ) + ).toBe(true) + }) test('handles files with size mismatch correctly', () => { // Pre-create a file - const existingBlob = createBlob('short content', 'test.txt'); - folder.createFile(existingBlob); - + const existingBlob = createBlob('short content', 'test.txt') + folder.createFile(existingBlob) + // Create attachment with same name but different size - const newAttachment = createBlob('much longer content here', 'test.txt'); + const newAttachment = createBlob('much longer content here', 'test.txt') const msg = createMessage({ subject: 'Email with Different Size', body: 'Test size mismatch', date: new Date('2024-01-01T10:00:00Z'), - attachments: [newAttachment] - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - const threads = global.GmailApp.getUserLabelByName('test-trigger').getThreads(); - const messages = threads[0].getMessages(); - - processMessagesToDoc(messages, body, folder); - + attachments: [newAttachment], + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + const threads = + global.GmailApp.getUserLabelByName('test-trigger').getThreads() + const messages = threads[0].getMessages() + + processMessagesToDoc(messages, body, folder) + // Should have 2 files (size mismatch means not a duplicate) - const files = folder.__getFiles(); - expect(files.length).toBe(2); - }); + const files = folder.__getFiles() + expect(files.length).toBe(2) + }) test('handles files with same size but different hash', () => { // Pre-create a file - const existingBlob = createBlob('content_a', 'hash-test.txt'); - folder.createFile(existingBlob); - + const existingBlob = createBlob('content_a', 'hash-test.txt') + folder.createFile(existingBlob) + // Create attachment with same name and size but different content - const newAttachment = createBlob('content_b', 'hash-test.txt'); + const newAttachment = createBlob('content_b', 'hash-test.txt') const msg = createMessage({ subject: 'Email with Hash Mismatch', body: 'Test hash comparison', date: new Date('2024-01-01T10:00:00Z'), - attachments: [newAttachment] - }); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - const threads = global.GmailApp.getUserLabelByName('test-trigger').getThreads(); - const messages = threads[0].getMessages(); - - processMessagesToDoc(messages, body, folder); - + attachments: [newAttachment], + }) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + const threads = + global.GmailApp.getUserLabelByName('test-trigger').getThreads() + const messages = threads[0].getMessages() + + processMessagesToDoc(messages, body, folder) + // Should have 2 files (hash mismatch means different content) - const files = folder.__getFiles(); - expect(files.length).toBe(2); - }); + const files = folder.__getFiles() + expect(files.length).toBe(2) + }) test('handles attachments with no file extension', () => { - const attachment = createBlob('file content', 'README'); + const attachment = createBlob('file content', 'README') const msg = createMessage({ subject: 'Email with No Extension', body: 'Test file without extension', date: new Date('2024-01-01T10:00:00Z'), - attachments: [attachment] - }); - + attachments: [attachment], + }) + // Pre-create a file with same name to trigger renaming - folder.createFile(createBlob('different content', 'README')); - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - const threads = global.GmailApp.getUserLabelByName('test-trigger').getThreads(); - const messages = threads[0].getMessages(); - - processMessagesToDoc(messages, body, folder); - + folder.createFile(createBlob('different content', 'README')) + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + const threads = + global.GmailApp.getUserLabelByName('test-trigger').getThreads() + const messages = threads[0].getMessages() + + processMessagesToDoc(messages, body, folder) + // Verify paragraph mentions the attachment - const paragraphs = body.getParagraphs(); - expect(paragraphs.some(p => p.getText().includes('README'))).toBe(true); - + const paragraphs = body.getParagraphs() + expect(paragraphs.some((p) => p.getText().includes('README'))).toBe(true) + // Should have 2 files (original and new with timestamp) - const files = folder.__getFiles(); - expect(files.length).toBe(2); - }); + const files = folder.__getFiles() + expect(files.length).toBe(2) + }) test('handles messages with Logger and DocumentApp options', () => { const msg = createMessage({ subject: 'Test with Options', body: 'Testing Logger and DocumentApp', - date: new Date('2024-01-01T10:00:00Z') - }); - + date: new Date('2024-01-01T10:00:00Z'), + }) + // Mock Logger and DocumentApp const mockLogger = { - log: jest.fn() - }; - + log: jest.fn(), + } + const mockDocumentApp = { ParagraphHeading: { HEADING_3: 'HEADING_3' }, - Attribute: { BOLD: 'BOLD' } - }; - + Attribute: { BOLD: 'BOLD' }, + } + const options = { Logger: mockLogger, - DocumentApp: mockDocumentApp - }; - - processMessageToDoc(msg, body, folder, options); - + DocumentApp: mockDocumentApp, + } + + processMessageToDoc(msg, body, folder, options) + // Verify Logger was called - expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('Test with Options')); - + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Test with Options') + ) + // Verify message was added - const paragraphs = body.getParagraphs(); - expect(paragraphs[0].getText()).toBe('Subject: Test with Options'); - }); + const paragraphs = body.getParagraphs() + expect(paragraphs[0].getText()).toBe('Subject: Test with Options') + }) test('handles DocumentApp setHeading failure with fallback to bold', () => { const msg = createMessage({ subject: 'Test Heading Fallback', body: 'Testing fallback', - date: new Date('2024-01-01T10:00:00Z') - }); - + date: new Date('2024-01-01T10:00:00Z'), + }) + // Mock DocumentApp with setHeading that throws const mockDocumentApp = { ParagraphHeading: { HEADING_3: 'HEADING_3' }, - Attribute: { BOLD: 'BOLD' } - }; - + Attribute: { BOLD: 'BOLD' }, + } + // Override insertParagraph to return para with setHeading that throws - const originalInsertParagraph = body.insertParagraph; - body.insertParagraph = function(index, text) { - const para = originalInsertParagraph.call(this, index, text); - para.setHeading = function() { - throw new Error('Document busy'); - }; - return para; - }; - - const options = { DocumentApp: mockDocumentApp }; - - processMessageToDoc(msg, body, folder, options); - + const originalInsertParagraph = body.insertParagraph + body.insertParagraph = function (index, text) { + const para = originalInsertParagraph.call(this, index, text) + para.setHeading = function () { + throw new Error('Document busy') + } + return para + } + + const options = { DocumentApp: mockDocumentApp } + + processMessageToDoc(msg, body, folder, options) + // Restore original - body.insertParagraph = originalInsertParagraph; - + body.insertParagraph = originalInsertParagraph + // Verify message was still added despite error - const paragraphs = body.getParagraphs(); - expect(paragraphs[0].getText()).toBe('Subject: Test Heading Fallback'); - }); + const paragraphs = body.getParagraphs() + expect(paragraphs[0].getText()).toBe('Subject: Test Heading Fallback') + }) test('handles Utilities.sleep when provided', () => { const msg = createMessage({ subject: 'Test with Utilities', body: 'Testing Utilities.sleep', - date: new Date('2024-01-01T10:00:00Z') - }); - + date: new Date('2024-01-01T10:00:00Z'), + }) + const mockUtilities = { - sleep: jest.fn() - }; - + sleep: jest.fn(), + } + const options = { - Utilities: mockUtilities - }; - - processMessageToDoc(msg, body, folder, options); - + Utilities: mockUtilities, + } + + processMessageToDoc(msg, body, folder, options) + // Verify Utilities.sleep was called - expect(mockUtilities.sleep).toHaveBeenCalledWith(500); - }); + expect(mockUtilities.sleep).toHaveBeenCalledWith(500) + }) test('handles duplicate detection with Logger option', () => { // Pre-create a file - const existingBlob = createBlob('duplicate content', 'dup.txt'); - folder.createFile(existingBlob); - + const existingBlob = createBlob('duplicate content', 'dup.txt') + folder.createFile(existingBlob) + // Create message with duplicate attachment - const duplicateAttachment = createBlob('duplicate content', 'dup.txt'); + const duplicateAttachment = createBlob('duplicate content', 'dup.txt') const msg = createMessage({ subject: 'Email with Duplicate and Logger', body: 'Test Logger on duplicate', date: new Date('2024-01-01T10:00:00Z'), - attachments: [duplicateAttachment] - }); - + attachments: [duplicateAttachment], + }) + const mockLogger = { - log: jest.fn() - }; - + log: jest.fn(), + } + const options = { - Logger: mockLogger - }; - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - const threads = global.GmailApp.getUserLabelByName('test-trigger').getThreads(); - const messages = threads[0].getMessages(); - - processMessagesToDoc(messages, body, folder, options); - + Logger: mockLogger, + } + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + const threads = + global.GmailApp.getUserLabelByName('test-trigger').getThreads() + const messages = threads[0].getMessages() + + processMessagesToDoc(messages, body, folder, options) + // Verify Logger.log was called for duplicate - expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('duplicate')); - + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('duplicate') + ) + // Verify duplicate was skipped - const paragraphs = body.getParagraphs(); - expect(paragraphs.some(p => p.getText().includes('[DUPLICATE SKIPPED]'))).toBe(true); - }); + const paragraphs = body.getParagraphs() + expect( + paragraphs.some((p) => p.getText().includes('[DUPLICATE SKIPPED]')) + ).toBe(true) + }) test('handles name conflict with Utilities and Session options', () => { // Pre-create a file with same name but different content - const existingBlob = createBlob('original content', 'conflict.txt'); - folder.createFile(existingBlob); - + const existingBlob = createBlob('original content', 'conflict.txt') + folder.createFile(existingBlob) + // Create message with attachment that has same name but different content - const newAttachment = createBlob('new different content', 'conflict.txt'); + const newAttachment = createBlob('new different content', 'conflict.txt') const msg = createMessage({ subject: 'Email with Name Conflict and Utilities', body: 'Test Utilities.formatDate', date: new Date('2024-01-01T10:00:00Z'), - attachments: [newAttachment] - }); - + attachments: [newAttachment], + }) + const mockUtilities = { formatDate: jest.fn().mockReturnValue('_123456'), - sleep: jest.fn() - }; - + sleep: jest.fn(), + } + const mockSession = { - getScriptTimeZone: jest.fn().mockReturnValue('America/New_York') - }; - + getScriptTimeZone: jest.fn().mockReturnValue('America/New_York'), + } + const options = { Utilities: mockUtilities, - Session: mockSession - }; - - global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]); - const threads = global.GmailApp.getUserLabelByName('test-trigger').getThreads(); - const messages = threads[0].getMessages(); - - processMessagesToDoc(messages, body, folder, options); - + Session: mockSession, + } + + global.GmailApp.__addThreadWithLabels(['test-trigger'], [msg]) + const threads = + global.GmailApp.getUserLabelByName('test-trigger').getThreads() + const messages = threads[0].getMessages() + + processMessagesToDoc(messages, body, folder, options) + // Verify Utilities.formatDate was called - expect(mockUtilities.formatDate).toHaveBeenCalled(); - expect(mockSession.getScriptTimeZone).toHaveBeenCalled(); - + expect(mockUtilities.formatDate).toHaveBeenCalled() + expect(mockSession.getScriptTimeZone).toHaveBeenCalled() + // Should have 2 files (original and renamed new one) - const files = folder.__getFiles(); - expect(files.length).toBe(2); - + const files = folder.__getFiles() + expect(files.length).toBe(2) + // Verify one file has timestamp in name - const fileNames = files.map(f => f.getName()); - expect(fileNames).toContain('conflict.txt'); - expect(fileNames.some(name => name.includes('_123456'))).toBe(true); - }); -}); + const fileNames = files.map((f) => f.getName()) + expect(fileNames).toContain('conflict.txt') + expect(fileNames.some((name) => name.includes('_123456'))).toBe(true) + }) +}) diff --git a/src/gmail-to-drive-by-labels/tests/mocks.integration.test.js b/src/gmail-to-drive-by-labels/tests/mocks.integration.test.js index 15cf839d..8a882737 100644 --- a/src/gmail-to-drive-by-labels/tests/mocks.integration.test.js +++ b/src/gmail-to-drive-by-labels/tests/mocks.integration.test.js @@ -1,32 +1,34 @@ -const { createMessage, createBlob } = require('../../../test-utils/mocks'); +const { createMessage, createBlob } = require('../../../test-utils/mocks') describe('Apps Script mocks integration', () => { test('GmailApp createLabel and thread handling', () => { - const label = global.GmailApp.createLabel('TEST'); - const msg = createMessage({ subject: 'Hi', body: 'Hello' }); - const thread = global.GmailApp.__addThreadWithLabels(['TEST'], [msg]); + const label = global.GmailApp.createLabel('TEST') + const msg = createMessage({ subject: 'Hi', body: 'Hello' }) + const thread = global.GmailApp.__addThreadWithLabels(['TEST'], [msg]) - const found = global.GmailApp.getUserLabelByName('TEST'); - expect(found).not.toBeNull(); - const threads = found.getThreads(); - expect(threads.length).toBe(1); - expect(threads[0].getMessages()[0].getSubject()).toBe('Hi'); - }); + const found = global.GmailApp.getUserLabelByName('TEST') + expect(found).not.toBeNull() + const threads = found.getThreads() + expect(threads.length).toBe(1) + expect(threads[0].getMessages()[0].getSubject()).toBe('Hi') + }) test('DocumentApp appendParagraph and DriveApp deduplication helpers', () => { - const doc = global.DocumentApp.openById('doc-1'); - const body = doc.getBody(); - body.appendParagraph('Hello'); - expect(body.getParagraphs().length).toBe(1); + const doc = global.DocumentApp.openById('doc-1') + const body = doc.getBody() + body.appendParagraph('Hello') + expect(body.getParagraphs().length).toBe(1) - const folder = global.DriveApp.getFolderById('f-1'); - const b1 = createBlob('a', 'foo.txt'); - const b2 = createBlob('a', 'foo.txt'); - const f1 = folder.createFile(b1); + const folder = global.DriveApp.getFolderById('f-1') + const b1 = createBlob('a', 'foo.txt') + const b2 = createBlob('a', 'foo.txt') + const f1 = folder.createFile(b1) // files with same content should be separate objects but can be compared by getBlob - const existing = folder.getFilesByName('foo.txt'); - expect(existing.hasNext()).toBe(true); - const file = existing.next(); - expect(file.getBlob().getBytes().toString()).toBe(Buffer.from('a').toString()); - }); -}); + const existing = folder.getFilesByName('foo.txt') + expect(existing.hasNext()).toBe(true) + const file = existing.next() + expect(file.getBlob().getBytes().toString()).toBe( + Buffer.from('a').toString() + ) + }) +}) diff --git a/src/gmail-to-drive-by-labels/tests/prepend-behavior.test.js b/src/gmail-to-drive-by-labels/tests/prepend-behavior.test.js index 1117b78f..fd194a89 100644 --- a/src/gmail-to-drive-by-labels/tests/prepend-behavior.test.js +++ b/src/gmail-to-drive-by-labels/tests/prepend-behavior.test.js @@ -1,100 +1,103 @@ -const { createMessage } = require('../../../test-utils/mocks'); +const { createMessage } = require('../../../test-utils/mocks') describe('Email prepending to document', () => { - let doc, body, folder, label, thread, message; + let doc, body, folder, label, thread, message beforeEach(() => { - global.__mocks.docs.__reset(); - global.__mocks.gmail.__reset(); - global.__mocks.drive.__reset(); - + global.__mocks.docs.__reset() + global.__mocks.gmail.__reset() + global.__mocks.drive.__reset() + // Setup document - doc = global.DocumentApp.openById('test-doc'); - body = doc.getBody(); - + doc = global.DocumentApp.openById('test-doc') + body = doc.getBody() + // Setup folder - folder = global.DriveApp.getFolderById('test-folder'); - + folder = global.DriveApp.getFolderById('test-folder') + // Setup email message = createMessage({ subject: 'Test Email', body: 'This is the email body', - date: new Date('2024-01-01T12:00:00Z') - }); - - thread = global.GmailApp.__addThreadWithLabels(['test-label'], [message]); - label = global.GmailApp.getUserLabelByName('test-label'); - }); + date: new Date('2024-01-01T12:00:00Z'), + }) + + thread = global.GmailApp.__addThreadWithLabels(['test-label'], [message]) + label = global.GmailApp.getUserLabelByName('test-label') + }) test('new email content should be inserted at the top of document', () => { // Add initial content to document (simulating existing content) - body.appendParagraph('Old Content 1'); - body.appendParagraph('Old Content 2'); - + body.appendParagraph('Old Content 1') + body.appendParagraph('Old Content 2') + // Verify initial state - expect(body.getParagraphs().length).toBe(2); - expect(body.getParagraphs()[0].getText()).toBe('Old Content 1'); - expect(body.getParagraphs()[1].getText()).toBe('Old Content 2'); - + expect(body.getParagraphs().length).toBe(2) + expect(body.getParagraphs()[0].getText()).toBe('Old Content 1') + expect(body.getParagraphs()[1].getText()).toBe('Old Content 2') + // Simulate adding new email at the top - const subjectText = 'Subject: Test Email'; - const headingPara = body.insertParagraph(0, subjectText); - headingPara.setHeading('HEADING_3'); - - body.insertParagraph(1, 'Date: Mon Jan 01 2024 12:00:00 GMT+0000 (Coordinated Universal Time)'); - body.insertParagraph(2, 'This is the email body'); - body.insertParagraph(3, '------------------------------'); - + const subjectText = 'Subject: Test Email' + const headingPara = body.insertParagraph(0, subjectText) + headingPara.setHeading('HEADING_3') + + body.insertParagraph( + 1, + 'Date: Mon Jan 01 2024 12:00:00 GMT+0000 (Coordinated Universal Time)' + ) + body.insertParagraph(2, 'This is the email body') + body.insertParagraph(3, '------------------------------') + // Verify new content is at top - const paragraphs = body.getParagraphs(); - expect(paragraphs.length).toBe(6); - expect(paragraphs[0].getText()).toBe('Subject: Test Email'); - expect(paragraphs[1].getText()).toContain('Date:'); - expect(paragraphs[2].getText()).toBe('This is the email body'); - expect(paragraphs[3].getText()).toBe('------------------------------'); - expect(paragraphs[4].getText()).toBe('Old Content 1'); - expect(paragraphs[5].getText()).toBe('Old Content 2'); - }); + const paragraphs = body.getParagraphs() + expect(paragraphs.length).toBe(6) + expect(paragraphs[0].getText()).toBe('Subject: Test Email') + expect(paragraphs[1].getText()).toContain('Date:') + expect(paragraphs[2].getText()).toBe('This is the email body') + expect(paragraphs[3].getText()).toBe('------------------------------') + expect(paragraphs[4].getText()).toBe('Old Content 1') + expect(paragraphs[5].getText()).toBe('Old Content 2') + }) test('multiple emails should be prepended in order (newest first)', () => { // Add first email - body.insertParagraph(0, 'Subject: First Email'); - body.insertParagraph(1, 'Date: 2024-01-01'); - body.insertParagraph(2, 'First email body'); - body.insertParagraph(3, '------------------------------'); - + body.insertParagraph(0, 'Subject: First Email') + body.insertParagraph(1, 'Date: 2024-01-01') + body.insertParagraph(2, 'First email body') + body.insertParagraph(3, '------------------------------') + // Add second email (should go to top) - body.insertParagraph(0, 'Subject: Second Email'); - body.insertParagraph(1, 'Date: 2024-01-02'); - body.insertParagraph(2, 'Second email body'); - body.insertParagraph(3, '------------------------------'); - - const paragraphs = body.getParagraphs(); - expect(paragraphs.length).toBe(8); - + body.insertParagraph(0, 'Subject: Second Email') + body.insertParagraph(1, 'Date: 2024-01-02') + body.insertParagraph(2, 'Second email body') + body.insertParagraph(3, '------------------------------') + + const paragraphs = body.getParagraphs() + expect(paragraphs.length).toBe(8) + // Newest email should be at top - expect(paragraphs[0].getText()).toBe('Subject: Second Email'); - expect(paragraphs[4].getText()).toBe('Subject: First Email'); - }); + expect(paragraphs[0].getText()).toBe('Subject: Second Email') + expect(paragraphs[4].getText()).toBe('Subject: First Email') + }) test('prepending with attachments listed', () => { // Add initial content - body.appendParagraph('Old Content'); - + body.appendParagraph('Old Content') + // Add new email with attachments - body.insertParagraph(0, 'Subject: Email with attachments'); - body.insertParagraph(1, 'Date: 2024-01-01'); - body.insertParagraph(2, 'Email body'); - body.insertParagraph(3, '[Attachments]:'); - body.insertParagraph(4, '- file1.txt'); - body.insertParagraph(5, '------------------------------'); - - const paragraphs = body.getParagraphs(); - expect(paragraphs.length).toBe(7); - + body.insertParagraph(0, 'Subject: Email with attachments') + body.insertParagraph(1, 'Date: 2024-01-01') + body.insertParagraph(2, 'Email body') + body.insertParagraph(3, '[Attachments]:') + body.insertParagraph(4, '- file1.txt') + body.insertParagraph(5, '------------------------------') + + const paragraphs = body.getParagraphs() + expect(paragraphs.length).toBe(7) + // New content should be at top - expect(paragraphs[0].getText()).toBe('Subject: Email with attachments'); - expect(paragraphs[3].getText()).toBe('[Attachments]:'); - expect(paragraphs[6].getText()).toBe('Old Content'); - }); -}); + expect(paragraphs[0].getText()).toBe('Subject: Email with attachments') + expect(paragraphs[3].getText()).toBe('[Attachments]:') + expect(paragraphs[6].getText()).toBe('Old Content') + }) +}) diff --git a/src/gmail-to-drive-by-labels/tests/rebuild.test.js b/src/gmail-to-drive-by-labels/tests/rebuild.test.js index 8cba66b5..c3fbae83 100644 --- a/src/gmail-to-drive-by-labels/tests/rebuild.test.js +++ b/src/gmail-to-drive-by-labels/tests/rebuild.test.js @@ -1,4 +1,4 @@ -const { createMessage } = require('../../../test-utils/mocks'); +const { createMessage } = require('../../../test-utils/mocks') // Mock the config global.getProcessConfig = jest.fn(() => [ @@ -6,263 +6,266 @@ global.getProcessConfig = jest.fn(() => [ triggerLabel: 'test-label', processedLabel: 'test-label-archived', docId: 'doc-1', - folderId: 'folder-1' - } -]); + folderId: 'folder-1', + }, +]) // Load the code after mocks are set up -const { rebuildDoc, rebuildAllDocs } = require('../code.gs'); +const { rebuildDoc, rebuildAllDocs } = require('../code.gs') describe('rebuildDoc', () => { beforeEach(() => { - global.GmailApp.__reset(); - global.DocumentApp.__reset(); - global.DriveApp.__reset(); - global.PropertiesService.__reset(); - jest.clearAllMocks(); - }); + global.GmailApp.__reset() + global.DocumentApp.__reset() + global.DriveApp.__reset() + global.PropertiesService.__reset() + jest.clearAllMocks() + }) test('clears document and moves emails from processed to trigger label', () => { // Setup: Create labels - const triggerLabel = global.GmailApp.createLabel('test-label'); - const processedLabel = global.GmailApp.createLabel('test-label-archived'); + const triggerLabel = global.GmailApp.createLabel('test-label') + const processedLabel = global.GmailApp.createLabel('test-label-archived') // Setup: Add some processed threads - const msg1 = createMessage({ subject: 'Email 1', body: 'Body 1' }); - const msg2 = createMessage({ subject: 'Email 2', body: 'Body 2' }); - global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg1]); - global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg2]); + const msg1 = createMessage({ subject: 'Email 1', body: 'Body 1' }) + const msg2 = createMessage({ subject: 'Email 2', body: 'Body 2' }) + global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg1]) + global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg2]) // Setup: Create document with content - const doc = global.DocumentApp.openById('doc-1'); - const body = doc.getBody(); - body.appendParagraph('Old content 1'); - body.appendParagraph('Old content 2'); - body.appendParagraph('Old content 3'); + const doc = global.DocumentApp.openById('doc-1') + const body = doc.getBody() + body.appendParagraph('Old content 1') + body.appendParagraph('Old content 2') + body.appendParagraph('Old content 3') // Verify initial state - expect(body.getParagraphs().length).toBe(3); - expect(processedLabel.getThreads().length).toBe(2); - expect(triggerLabel.getThreads().length).toBe(0); + expect(body.getParagraphs().length).toBe(3) + expect(processedLabel.getThreads().length).toBe(2) + expect(triggerLabel.getThreads().length).toBe(0) // Run rebuild const config = { triggerLabel: 'test-label', processedLabel: 'test-label-archived', docId: 'doc-1', - folderId: 'folder-1' - }; - const completed = rebuildDoc(config); + folderId: 'folder-1', + } + const completed = rebuildDoc(config) // Verify document is cleared - expect(body.getParagraphs().length).toBe(0); + expect(body.getParagraphs().length).toBe(0) // Verify emails are moved back to trigger label - expect(triggerLabel.getThreads().length).toBe(2); - expect(processedLabel.getThreads().length).toBe(0); - + expect(triggerLabel.getThreads().length).toBe(2) + expect(processedLabel.getThreads().length).toBe(0) + // Verify operation completed - expect(completed).toBe(true); - }); + expect(completed).toBe(true) + }) test('handles missing processed label gracefully', () => { // Setup: Create only trigger label (no processed label) - const triggerLabel = global.GmailApp.createLabel('test-label'); + const triggerLabel = global.GmailApp.createLabel('test-label') // Setup: Create document with content - const doc = global.DocumentApp.openById('doc-1'); - const body = doc.getBody(); - body.appendParagraph('Old content'); + const doc = global.DocumentApp.openById('doc-1') + const body = doc.getBody() + body.appendParagraph('Old content') // Run rebuild const config = { triggerLabel: 'test-label', processedLabel: 'test-label-archived', docId: 'doc-1', - folderId: 'folder-1' - }; + folderId: 'folder-1', + } // Should not throw - expect(() => rebuildDoc(config)).not.toThrow(); + expect(() => rebuildDoc(config)).not.toThrow() // Document should still be cleared - expect(body.getParagraphs().length).toBe(0); - }); + expect(body.getParagraphs().length).toBe(0) + }) test('handles missing trigger label gracefully', () => { // Setup: Create document - const doc = global.DocumentApp.openById('doc-1'); - const body = doc.getBody(); - body.appendParagraph('Old content'); + const doc = global.DocumentApp.openById('doc-1') + const body = doc.getBody() + body.appendParagraph('Old content') // Run rebuild with non-existent trigger label const config = { triggerLabel: 'non-existent-label', processedLabel: 'test-label-archived', docId: 'doc-1', - folderId: 'folder-1' - }; + folderId: 'folder-1', + } // Should not throw - expect(() => rebuildDoc(config)).not.toThrow(); + expect(() => rebuildDoc(config)).not.toThrow() // Document should not be cleared (function returns early) - expect(body.getParagraphs().length).toBe(1); - }); + expect(body.getParagraphs().length).toBe(1) + }) test('clears empty document without errors', () => { // Setup: Create labels - global.GmailApp.createLabel('test-label'); + global.GmailApp.createLabel('test-label') // Setup: Create empty document - const doc = global.DocumentApp.openById('doc-1'); - const body = doc.getBody(); + const doc = global.DocumentApp.openById('doc-1') + const body = doc.getBody() // Verify initial state - expect(body.getParagraphs().length).toBe(0); + expect(body.getParagraphs().length).toBe(0) // Run rebuild const config = { triggerLabel: 'test-label', processedLabel: 'test-label-archived', docId: 'doc-1', - folderId: 'folder-1' - }; + folderId: 'folder-1', + } // Should not throw - expect(() => rebuildDoc(config)).not.toThrow(); + expect(() => rebuildDoc(config)).not.toThrow() // Document should still be empty - expect(body.getParagraphs().length).toBe(0); - }); + expect(body.getParagraphs().length).toBe(0) + }) test('moves multiple threads correctly', () => { // Setup: Create labels - const triggerLabel = global.GmailApp.createLabel('test-label'); - const processedLabel = global.GmailApp.createLabel('test-label-archived'); + const triggerLabel = global.GmailApp.createLabel('test-label') + const processedLabel = global.GmailApp.createLabel('test-label-archived') // Setup: Add many processed threads - const threads = []; + const threads = [] for (let i = 0; i < 25; i++) { - const msg = createMessage({ subject: `Email ${i}`, body: `Body ${i}` }); - const thread = global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]); - threads.push(thread); + const msg = createMessage({ subject: `Email ${i}`, body: `Body ${i}` }) + const thread = global.GmailApp.__addThreadWithLabels( + ['test-label-archived'], + [msg] + ) + threads.push(thread) } // Setup: Create document - const doc = global.DocumentApp.openById('doc-1'); - const body = doc.getBody(); - body.appendParagraph('Content'); + const doc = global.DocumentApp.openById('doc-1') + const body = doc.getBody() + body.appendParagraph('Content') // Verify initial state - expect(processedLabel.getThreads().length).toBe(25); - expect(triggerLabel.getThreads().length).toBe(0); + expect(processedLabel.getThreads().length).toBe(25) + expect(triggerLabel.getThreads().length).toBe(0) // Run rebuild const config = { triggerLabel: 'test-label', processedLabel: 'test-label-archived', docId: 'doc-1', - folderId: 'folder-1' - }; - rebuildDoc(config); + folderId: 'folder-1', + } + rebuildDoc(config) // Verify all threads are moved - expect(triggerLabel.getThreads().length).toBe(25); - expect(processedLabel.getThreads().length).toBe(0); - }); + expect(triggerLabel.getThreads().length).toBe(25) + expect(processedLabel.getThreads().length).toBe(0) + }) test('handles document opening errors gracefully', () => { // Setup: Create labels - global.GmailApp.createLabel('test-label'); - const processedLabel = global.GmailApp.createLabel('test-label-archived'); + global.GmailApp.createLabel('test-label') + const processedLabel = global.GmailApp.createLabel('test-label-archived') // Setup: Add a processed thread - const msg = createMessage({ subject: 'Email 1', body: 'Body 1' }); - global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]); + const msg = createMessage({ subject: 'Email 1', body: 'Body 1' }) + global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]) // Mock DocumentApp.openById to throw an error - const originalOpenById = global.DocumentApp.openById; + const originalOpenById = global.DocumentApp.openById global.DocumentApp.openById = jest.fn(() => { - throw new Error('Document not found'); - }); + throw new Error('Document not found') + }) // Run rebuild const config = { triggerLabel: 'test-label', processedLabel: 'test-label-archived', docId: 'invalid-doc-id', - folderId: 'folder-1' - }; + folderId: 'folder-1', + } // Should not throw and should return early - expect(() => rebuildDoc(config)).not.toThrow(); + expect(() => rebuildDoc(config)).not.toThrow() // Verify emails were NOT moved (function returned early) - expect(processedLabel.getThreads().length).toBe(1); + expect(processedLabel.getThreads().length).toBe(1) // Restore original function - global.DocumentApp.openById = originalOpenById; - }); + global.DocumentApp.openById = originalOpenById + }) test('handles document setText errors gracefully', () => { // Setup: Create labels - global.GmailApp.createLabel('test-label'); - const processedLabel = global.GmailApp.createLabel('test-label-archived'); + global.GmailApp.createLabel('test-label') + const processedLabel = global.GmailApp.createLabel('test-label-archived') // Setup: Add a processed thread - const msg = createMessage({ subject: 'Email 1', body: 'Body 1' }); - global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]); + const msg = createMessage({ subject: 'Email 1', body: 'Body 1' }) + global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]) // Setup: Create a mock document that throws on setText const mockDoc = { getBody: () => ({ setText: jest.fn(() => { - throw new Error('Permission denied'); - }) - }) - }; + throw new Error('Permission denied') + }), + }), + } // Mock DocumentApp.openById to return our mock document - const originalOpenById = global.DocumentApp.openById; - global.DocumentApp.openById = jest.fn(() => mockDoc); + const originalOpenById = global.DocumentApp.openById + global.DocumentApp.openById = jest.fn(() => mockDoc) // Run rebuild const config = { triggerLabel: 'test-label', processedLabel: 'test-label-archived', docId: 'doc-1', - folderId: 'folder-1' - }; + folderId: 'folder-1', + } // Should not throw and should return early - expect(() => rebuildDoc(config)).not.toThrow(); + expect(() => rebuildDoc(config)).not.toThrow() // Verify emails were NOT moved (function returned early) - expect(processedLabel.getThreads().length).toBe(1); + expect(processedLabel.getThreads().length).toBe(1) // Restore original function - global.DocumentApp.openById = originalOpenById; - }); + global.DocumentApp.openById = originalOpenById + }) test('handles resumable batching for large label sets', () => { // Setup: Create labels - const triggerLabel = global.GmailApp.createLabel('test-label'); - const processedLabel = global.GmailApp.createLabel('test-label-archived'); + const triggerLabel = global.GmailApp.createLabel('test-label') + const processedLabel = global.GmailApp.createLabel('test-label-archived') // Setup: Add many processed threads (more than batch size) for (let i = 0; i < 150; i++) { - const msg = createMessage({ subject: `Email ${i}`, body: `Body ${i}` }); - global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]); + const msg = createMessage({ subject: `Email ${i}`, body: `Body ${i}` }) + global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]) } // Setup: Create document - const doc = global.DocumentApp.openById('doc-1'); - doc.getBody().appendParagraph('Content'); + const doc = global.DocumentApp.openById('doc-1') + doc.getBody().appendParagraph('Content') // Verify initial state - expect(processedLabel.getThreads().length).toBe(150); - expect(triggerLabel.getThreads().length).toBe(0); + expect(processedLabel.getThreads().length).toBe(150) + expect(triggerLabel.getThreads().length).toBe(0) // Run rebuild - should handle batching automatically const config = { @@ -270,87 +273,87 @@ describe('rebuildDoc', () => { processedLabel: 'test-label-archived', docId: 'doc-1', folderId: 'folder-1', - batchSize: 100 // Use smaller batch size for testing - }; - + batchSize: 100, // Use smaller batch size for testing + } + // First run - processes up to batchSize (100) - const completed1 = rebuildDoc(config); - + const completed1 = rebuildDoc(config) + // Should not complete if there are more than batchSize threads - expect(completed1).toBe(false); - + expect(completed1).toBe(false) + // Document should be cleared - expect(doc.getBody().getParagraphs().length).toBe(0); - + expect(doc.getBody().getParagraphs().length).toBe(0) + // First batch should be moved (100 threads) - expect(triggerLabel.getThreads().length).toBe(100); - expect(processedLabel.getThreads().length).toBe(50); - + expect(triggerLabel.getThreads().length).toBe(100) + expect(processedLabel.getThreads().length).toBe(50) + // State should be saved - const properties = global.PropertiesService.getUserProperties(); - const stateKey = 'rebuild_state_test_label'; - let savedState = properties.getProperty(stateKey); - expect(savedState).not.toBeNull(); - let state = JSON.parse(savedState); - expect(state.phase).toBe('move_emails'); - + const properties = global.PropertiesService.getUserProperties() + const stateKey = 'rebuild_state_test_label' + let savedState = properties.getProperty(stateKey) + expect(savedState).not.toBeNull() + let state = JSON.parse(savedState) + expect(state.phase).toBe('move_emails') + // Second run - processes remaining 50 threads - const completed2 = rebuildDoc(config); - + const completed2 = rebuildDoc(config) + // Should complete on second run - expect(completed2).toBe(true); - + expect(completed2).toBe(true) + // All threads should be moved - expect(triggerLabel.getThreads().length).toBe(150); - expect(processedLabel.getThreads().length).toBe(0); - + expect(triggerLabel.getThreads().length).toBe(150) + expect(processedLabel.getThreads().length).toBe(0) + // State should be cleaned up - expect(properties.getProperty(stateKey)).toBeNull(); - }); + expect(properties.getProperty(stateKey)).toBeNull() + }) test('uses default batch size of 250 when not specified', () => { // Setup - const triggerLabel = global.GmailApp.createLabel('test-label'); - const processedLabel = global.GmailApp.createLabel('test-label-archived'); + const triggerLabel = global.GmailApp.createLabel('test-label') + const processedLabel = global.GmailApp.createLabel('test-label-archived') // Setup: Add 200 threads (less than default batch size of 250) for (let i = 0; i < 200; i++) { - const msg = createMessage({ subject: `Email ${i}`, body: `Body ${i}` }); - global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]); + const msg = createMessage({ subject: `Email ${i}`, body: `Body ${i}` }) + global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]) } // Setup: Create document - const doc = global.DocumentApp.openById('doc-1'); + const doc = global.DocumentApp.openById('doc-1') // Config without batchSize specified const config = { triggerLabel: 'test-label', processedLabel: 'test-label-archived', docId: 'doc-1', - folderId: 'folder-1' + folderId: 'folder-1', // No batchSize - should default to 250 - }; - + } + // Run - should complete in one batch since 200 < 250 - const completed = rebuildDoc(config); - + const completed = rebuildDoc(config) + // Should complete because all threads fit in default batch size - expect(completed).toBe(true); - + expect(completed).toBe(true) + // All threads should be moved - expect(triggerLabel.getThreads().length).toBe(200); - expect(processedLabel.getThreads().length).toBe(0); - }); -}); + expect(triggerLabel.getThreads().length).toBe(200) + expect(processedLabel.getThreads().length).toBe(0) + }) +}) describe('rebuildAllDocs', () => { beforeEach(() => { - global.GmailApp.__reset(); - global.DocumentApp.__reset(); - global.DriveApp.__reset(); - global.PropertiesService.__reset(); - jest.clearAllMocks(); - }); + global.GmailApp.__reset() + global.DocumentApp.__reset() + global.DriveApp.__reset() + global.PropertiesService.__reset() + jest.clearAllMocks() + }) test('rebuilds all configured documents', () => { // Setup multiple configs @@ -359,39 +362,39 @@ describe('rebuildAllDocs', () => { triggerLabel: 'label-1', processedLabel: 'label-1-archived', docId: 'doc-1', - folderId: 'folder-1' + folderId: 'folder-1', }, { triggerLabel: 'label-2', processedLabel: 'label-2-archived', docId: 'doc-2', - folderId: 'folder-2' - } - ]); + folderId: 'folder-2', + }, + ]) // Setup: Create labels and documents - global.GmailApp.createLabel('label-1'); - global.GmailApp.createLabel('label-1-archived'); - global.GmailApp.createLabel('label-2'); - global.GmailApp.createLabel('label-2-archived'); + global.GmailApp.createLabel('label-1') + global.GmailApp.createLabel('label-1-archived') + global.GmailApp.createLabel('label-2') + global.GmailApp.createLabel('label-2-archived') - const doc1 = global.DocumentApp.openById('doc-1'); - const doc2 = global.DocumentApp.openById('doc-2'); - - doc1.getBody().appendParagraph('Doc 1 content'); - doc2.getBody().appendParagraph('Doc 2 content'); + const doc1 = global.DocumentApp.openById('doc-1') + const doc2 = global.DocumentApp.openById('doc-2') + + doc1.getBody().appendParagraph('Doc 1 content') + doc2.getBody().appendParagraph('Doc 2 content') // Verify initial state - expect(doc1.getBody().getParagraphs().length).toBe(1); - expect(doc2.getBody().getParagraphs().length).toBe(1); + expect(doc1.getBody().getParagraphs().length).toBe(1) + expect(doc2.getBody().getParagraphs().length).toBe(1) // Run rebuild all - rebuildAllDocs(); + rebuildAllDocs() // Verify both documents are cleared - expect(doc1.getBody().getParagraphs().length).toBe(0); - expect(doc2.getBody().getParagraphs().length).toBe(0); - }); + expect(doc1.getBody().getParagraphs().length).toBe(0) + expect(doc2.getBody().getParagraphs().length).toBe(0) + }) test('handles single configuration', () => { // Setup single config (default mock) @@ -400,19 +403,19 @@ describe('rebuildAllDocs', () => { triggerLabel: 'test-label', processedLabel: 'test-label-archived', docId: 'doc-1', - folderId: 'folder-1' - } - ]); + folderId: 'folder-1', + }, + ]) // Setup: Create label and document - global.GmailApp.createLabel('test-label'); - const doc = global.DocumentApp.openById('doc-1'); - doc.getBody().appendParagraph('Content'); + global.GmailApp.createLabel('test-label') + const doc = global.DocumentApp.openById('doc-1') + doc.getBody().appendParagraph('Content') // Run rebuild all - expect(() => rebuildAllDocs()).not.toThrow(); + expect(() => rebuildAllDocs()).not.toThrow() // Verify document is cleared - expect(doc.getBody().getParagraphs().length).toBe(0); - }); -}); + expect(doc.getBody().getParagraphs().length).toBe(0) + }) +}) diff --git a/test-utils/mocks.js b/test-utils/mocks.js index 29db2a85..0bd54188 100644 --- a/test-utils/mocks.js +++ b/test-utils/mocks.js @@ -1,167 +1,209 @@ // Minimal, opinionated mocks for Google Apps Script services used in tests const makeIterator = (arr) => { - let i = 0; + let i = 0 return { hasNext: () => i < arr.length, - next: () => arr[i++] - }; -}; + next: () => arr[i++], + } +} function createLabel(name) { - const threads = []; + const threads = [] return { getName: () => name, getThreads: () => threads.slice(), - addThread: (thread) => { if (!threads.includes(thread)) threads.push(thread); }, - addToThread: (thread) => { if (!threads.includes(thread)) threads.push(thread); }, + addThread: (thread) => { + if (!threads.includes(thread)) threads.push(thread) + }, + addToThread: (thread) => { + if (!threads.includes(thread)) threads.push(thread) + }, removeFromThread: (thread) => { - const idx = threads.indexOf(thread); - if (idx !== -1) threads.splice(idx, 1); - } - }; + const idx = threads.indexOf(thread) + if (idx !== -1) threads.splice(idx, 1) + }, + } } function createThread(messages) { - return { + const thread = { getMessages: () => messages.slice(), - addLabel: (label) => label.addThread(this), + addLabel: (label) => label.addThread(thread), // The real API uses Label methods to add/remove; we keep simple - }; + } + return thread } -function createMessage({subject = '', body = '', date = new Date(), attachments = []} = {}) { +function createMessage({ + subject = '', + body = '', + date = new Date(), + attachments = [], +} = {}) { return { getSubject: () => subject, getPlainBody: () => body, getDate: () => date, - getAttachments: () => attachments.slice() - }; + getAttachments: () => attachments.slice(), + } } function createBlob(bytesOrBuffer, name = 'file.bin') { - const buf = Buffer.isBuffer(bytesOrBuffer) ? bytesOrBuffer : Buffer.from(bytesOrBuffer || ''); + const buf = Buffer.isBuffer(bytesOrBuffer) + ? bytesOrBuffer + : Buffer.from(bytesOrBuffer || '') return { getBytes: () => buf, getName: () => name, - setName: function(newName) { name = newName; }, - copyBlob: function() { + setName: function (newName) { + name = newName + }, + copyBlob: function () { // Return a new blob with the same content - return createBlob(buf, name); + return createBlob(buf, name) }, bytes: buf, // Direct buffer access for tests that need it without method call - asBuffer: () => buf - }; + asBuffer: () => buf, + } } function createDriveFolder(id = 'root') { - const files = []; + const files = [] return { id, - getFilesByName: (name) => makeIterator(files.filter(f => f.getName() === name)), + getFilesByName: (name) => + makeIterator(files.filter((f) => f.getName() === name)), createFile: (blob) => { - const file = createFile(blob.getName ? blob.getName() : 'file', blob); - files.push(file); - return file; + const file = createFile(blob.getName ? blob.getName() : 'file', blob) + files.push(file) + return file }, // helper for tests - __getFiles: () => files - }; + __getFiles: () => files, + } } function createFile(name, blob) { - let _name = name || (blob && blob.getName && blob.getName()) || 'file'; - const bytes = blob && typeof blob.getBytes === 'function' ? blob.getBytes() : Buffer.from(''); + let _name = name || (blob && blob.getName && blob.getName()) || 'file' + const bytes = + blob && typeof blob.getBytes === 'function' + ? blob.getBytes() + : Buffer.from('') return { getName: () => _name, getSize: () => bytes.length, getBlob: () => ({ getBytes: () => bytes }), - setName: (n) => { _name = n; }, - }; + setName: (n) => { + _name = n + }, + } } function createDocument(id = 'doc1') { - const paragraphs = []; + const paragraphs = [] return { id, getBody: () => ({ appendParagraph: (text) => { const para = { text, - setHeading: (h) => { para.heading = h; }, - setAttributes: (s) => { para.attrs = s; }, - getText: () => para.text - }; - paragraphs.push(para); - return para; + setHeading: (h) => { + para.heading = h + }, + setAttributes: (s) => { + para.attrs = s + }, + getText: () => para.text, + } + paragraphs.push(para) + return para }, getParagraphs: () => paragraphs.slice(), getNumChildren: () => paragraphs.length, getChild: (index) => paragraphs[index], removeChild: (child) => { - const idx = paragraphs.indexOf(child); - if (idx !== -1) paragraphs.splice(idx, 1); + const idx = paragraphs.indexOf(child) + if (idx !== -1) paragraphs.splice(idx, 1) }, setText: (text) => { // Replace the body content: clear all existing paragraphs - paragraphs.length = 0; + paragraphs.length = 0 // If text is non-empty, add it as a single new paragraph if (text) { paragraphs.push({ text, setHeading: () => {}, setAttributes: () => {}, - getText: () => text - }); + getText: () => text, + }) } }, insertParagraph: (childIndex, text) => { // Validate childIndex like Apps Script does - if (typeof childIndex !== 'number' || childIndex < 0 || childIndex > paragraphs.length) { - throw new Error('Invalid childIndex: ' + childIndex); + if ( + typeof childIndex !== 'number' || + childIndex < 0 || + childIndex > paragraphs.length + ) { + throw new Error('Invalid childIndex: ' + childIndex) } const para = { text, - setHeading: (h) => { para.heading = h; }, - setAttributes: (s) => { para.attrs = s; }, - getText: () => para.text - }; - paragraphs.splice(childIndex, 0, para); - return para; + setHeading: (h) => { + para.heading = h + }, + setAttributes: (s) => { + para.attrs = s + }, + getText: () => para.text, + } + paragraphs.splice(childIndex, 0, para) + return para }, - getParagraphs: () => paragraphs.slice() - }) - }; + }), + } } function createGmailApp() { - const labels = new Map(); + const labels = new Map() return { __labels: labels, getUserLabelByName: (name) => labels.get(name) || null, createLabel: (name) => { - if (labels.has(name)) return labels.get(name); - const l = createLabel(name); - labels.set(name, l); - return l; + if (labels.has(name)) return labels.get(name) + const l = createLabel(name) + labels.set(name, l) + return l }, // Helpers for tests __addThreadWithLabels: (labelNames, messages) => { - const thread = createThread(messages || []); + const thread = createThread(messages || []) labelNames.forEach((ln) => { - let l = labels.get(ln); - if (!l) { l = createLabel(ln); labels.set(ln, l); } - l.addThread(thread); - }); - return thread; + let l = labels.get(ln) + if (!l) { + l = createLabel(ln) + labels.set(ln, l) + } + l.addThread(thread) + }) + return thread }, // Reset - __reset: () => labels.clear() - }; + __reset: () => labels.clear(), + } } // Minimal Calendar and Spreadsheet mocks for tests -function createCalendarEvent({id, title='', start=new Date(), end=new Date(), description='', location='', attendees=[]} = {}) { +function createCalendarEvent({ + id, + title = '', + start = new Date(), + end = new Date(), + description = '', + location = '', + attendees = [], +} = {}) { return { getId: () => id, getTitle: () => title, @@ -169,186 +211,221 @@ function createCalendarEvent({id, title='', start=new Date(), end=new Date(), de getEndTime: () => end, getDescription: () => description, getLocation: () => location, - getGuestList: () => (attendees || []).map(a => ({ getEmail: () => a })) - }; + getGuestList: () => (attendees || []).map((a) => ({ getEmail: () => a })), + } } -function createCalendar(id='primary') { - const events = []; +function createCalendar(id = 'primary') { + const events = [] return { id, __events: events, - getEvents: (start, end) => events.filter(e => e.getStartTime() >= start && e.getStartTime() <= end), - __addEvent: (evt) => { events.push(evt); }, - __reset: () => { events.length = 0 } - }; + getEvents: (start, end) => + events.filter( + (e) => e.getStartTime() >= start && e.getStartTime() <= end + ), + __addEvent: (evt) => { + events.push(evt) + }, + __reset: () => { + events.length = 0 + }, + } } -function createSheet(name='Sheet1') { - const headers = []; - const rows = []; +function createSheet(name = 'Sheet1') { + const headers = [] + const rows = [] return { getName: () => name, - getDataRange: () => ({ getValues: () => headers.length ? [headers.slice(), ...rows.map(r=>r.slice())] : rows.map(r=>r.slice()) }), + getDataRange: () => ({ + getValues: () => + headers.length + ? [headers.slice(), ...rows.map((r) => r.slice())] + : rows.map((r) => r.slice()), + }), getLastRow: () => rows.length + (headers.length ? 1 : 0), - appendRow: (row) => { rows.push(row.slice()); }, + appendRow: (row) => { + rows.push(row.slice()) + }, insertRowBefore: (rowIndex) => { // Insert a new row before the given rowIndex (1-based) if (rowIndex === 1 && headers.length === 0 && rows.length > 0) { // Inserting before row 1 when no header but data exists // Insert empty row at beginning to preserve existing data - rows.splice(0, 0, []); + rows.splice(0, 0, []) } else if (rowIndex === 1 && headers.length === 0) { // Inserting before row 1 when no header and no data - no-op // setValues will handle setting the header } else { - const idx = rowIndex - 1 - (headers.length ? 1 : 0); + const idx = rowIndex - 1 - (headers.length ? 1 : 0) if (idx >= 0) { - rows.splice(idx, 0, []); + rows.splice(idx, 0, []) } } }, getRange: (row, col, numRows, numCols) => { - const start = row - 1 - (headers.length ? 1 : 0); + const start = row - 1 - (headers.length ? 1 : 0) return { setValues: (vals) => { // Special case: if row is 1 and headers are empty, we're setting the header if (row === 1 && headers.length === 0) { // Setting the header row (works for empty sheet or sheet with data) - headers.length = 0; - vals[0].forEach(x => headers.push(x)); + headers.length = 0 + vals[0].forEach((x) => headers.push(x)) // If we had a placeholder row from insertRowBefore, remove it if (rows.length > 0 && rows[0].length === 0) { - rows.shift(); + rows.shift() } } else if (row === 1 && start === -1) { // Setting the header row when it already exists - headers.length = 0; - vals[0].forEach(x => headers.push(x)); + headers.length = 0 + vals[0].forEach((x) => headers.push(x)) } else { for (let r = 0; r < vals.length; r++) { - const dest = start + r; - rows[dest] = rows[dest] || []; - for (let c = 0; c < vals[r].length; c++) rows[dest][col - 1 + c] = vals[r][c]; + const dest = start + r + rows[dest] = rows[dest] || [] + for (let c = 0; c < vals[r].length; c++) + rows[dest][col - 1 + c] = vals[r][c] } } - } + }, } }, deleteRow: (rowIndex) => { - const idx = rowIndex - 1 - (headers.length ? 1 : 0); - if (idx >= 0 && idx < rows.length) rows.splice(idx,1); + const idx = rowIndex - 1 - (headers.length ? 1 : 0) + if (idx >= 0 && idx < rows.length) rows.splice(idx, 1) + }, + __setHeader: (h) => { + headers.length = 0 + h.forEach((x) => headers.push(x)) }, - __setHeader: (h) => { headers.length = 0; h.forEach(x=>headers.push(x)) }, __getRows: () => rows, - __reset: () => { - headers.length = 0; - rows.length = 0; - } - }; + __reset: () => { + headers.length = 0 + rows.length = 0 + }, + } } -function createSpreadsheet(id='ss1') { - const sheets = new Map(); +function createSpreadsheet(id = 'ss1') { + const sheets = new Map() // Always have a default first sheet - const firstSheet = createSheet('Sheet1'); - sheets.set('Sheet1', firstSheet); - + const firstSheet = createSheet('Sheet1') + sheets.set('Sheet1', firstSheet) + return { id, getSheetByName: (name) => { if (!sheets.has(name)) { - sheets.set(name, createSheet(name)); + sheets.set(name, createSheet(name)) } - return sheets.get(name); + return sheets.get(name) }, insertSheet: (name) => { - if (sheets.has(name)) return sheets.get(name); - const sheet = createSheet(name); - sheets.set(name, sheet); - return sheet; + if (sheets.has(name)) return sheets.get(name) + const sheet = createSheet(name) + sheets.set(name, sheet) + return sheet }, getSheets: () => Array.from(sheets.values()), - __reset: () => { - sheets.clear(); - firstSheet.__reset(); - sheets.set('Sheet1', firstSheet); - } - }; + __reset: () => { + sheets.clear() + firstSheet.__reset() + sheets.set('Sheet1', firstSheet) + }, + } } function createDriveApp() { - const folders = new Map(); + const folders = new Map() return { __folders: folders, getFolderById: (id) => { - if (!folders.has(id)) folders.set(id, createDriveFolder(id)); - return folders.get(id); + if (!folders.has(id)) folders.set(id, createDriveFolder(id)) + return folders.get(id) }, - __reset: () => folders.clear() - }; + __reset: () => folders.clear(), + } } function createDocumentApp() { - const docs = new Map(); + const docs = new Map() return { openById: (id) => { - if (!docs.has(id)) docs.set(id, createDocument(id)); - return docs.get(id); + if (!docs.has(id)) docs.set(id, createDocument(id)) + return docs.get(id) }, __reset: () => docs.clear(), // Apps Script DocumentApp enums ParagraphHeading: { HEADING_3: 'HEADING_3' }, - Attribute: { BOLD: 'BOLD' } - }; + Attribute: { BOLD: 'BOLD' }, + } } function createPropertiesService() { - const userProperties = new Map(); + const userProperties = new Map() return { getUserProperties: () => ({ - getProperty: (key) => userProperties.has(key) ? userProperties.get(key) : null, + getProperty: (key) => + userProperties.has(key) ? userProperties.get(key) : null, setProperty: (key, value) => userProperties.set(key, value), deleteProperty: (key) => userProperties.delete(key), - __reset: () => userProperties.clear() + __reset: () => userProperties.clear(), }), - __reset: () => userProperties.clear() - }; + __reset: () => userProperties.clear(), + } } function installGlobals(globals) { - const gmail = createGmailApp(); - const drive = createDriveApp(); - const docs = createDocumentApp(); - const calendar = createCalendar(); - const spreadsheet = createSpreadsheet(); - const properties = createPropertiesService(); + const gmail = createGmailApp() + const drive = createDriveApp() + const docs = createDocumentApp() + const calendar = createCalendar() + const spreadsheet = createSpreadsheet() + const properties = createPropertiesService() - globals.GmailApp = gmail; - globals.DriveApp = drive; - globals.DocumentApp = docs; - globals.CalendarApp = { + globals.GmailApp = gmail + globals.DriveApp = drive + globals.DocumentApp = docs + globals.CalendarApp = { getDefaultCalendar: () => calendar, - getCalendarById: (id) => calendar - }; - globals.SpreadsheetApp = { + getCalendarById: (id) => calendar, + } + globals.SpreadsheetApp = { openById: (id) => spreadsheet, - getActiveSpreadsheet: () => spreadsheet - }; - globals.PropertiesService = properties; + getActiveSpreadsheet: () => spreadsheet, + } + globals.PropertiesService = properties - globals.__mocks = { gmail, drive, docs, calendar, spreadsheet, properties, createMessage, createBlob, createCalendarEvent }; + globals.__mocks = { + gmail, + drive, + docs, + calendar, + spreadsheet, + properties, + createMessage, + createBlob, + createCalendarEvent, + } } function resetAll(globals) { if (globals.__mocks) { - globals.__mocks.gmail.__reset(); - globals.__mocks.drive.__reset(); - globals.__mocks.docs.__reset(); - globals.__mocks.calendar.__reset(); - globals.__mocks.spreadsheet.__reset(); - globals.__mocks.properties.__reset(); + globals.__mocks.gmail.__reset() + globals.__mocks.drive.__reset() + globals.__mocks.docs.__reset() + globals.__mocks.calendar.__reset() + globals.__mocks.spreadsheet.__reset() + globals.__mocks.properties.__reset() } } -module.exports = { installGlobals, resetAll, createMessage, createBlob, createCalendarEvent }; +module.exports = { + installGlobals, + resetAll, + createMessage, + createBlob, + createCalendarEvent, +} diff --git a/test-utils/setup.js b/test-utils/setup.js index 3924b5e9..0967472f 100644 --- a/test-utils/setup.js +++ b/test-utils/setup.js @@ -1,13 +1,13 @@ // Basic globals to emulate small parts of the Apps Script runtime used in tests global.Session = { - getScriptTimeZone: () => 'UTC' -}; + getScriptTimeZone: () => 'UTC', +} global.Utilities = { formatDate: (date, tz, format) => { // Simple deterministic formatting for tests - const d = new Date(date); - return d.toISOString(); + const d = new Date(date) + return d.toISOString() }, // Provide a simple sleep stub used in code sleep: (ms) => {}, @@ -15,23 +15,22 @@ global.Utilities = { computeDigest: (algorithm, bytes) => { // Simple deterministic hash for testing // Convert bytes to a string and create a fake hash - const crypto = require('crypto'); - const hash = crypto.createHash('md5').update(Buffer.from(bytes)).digest(); + const crypto = require('crypto') + const hash = crypto.createHash('md5').update(Buffer.from(bytes)).digest() // Return as array of numbers (like GAS does) - return Array.from(hash); + return Array.from(hash) }, DigestAlgorithm: { - MD5: 'MD5' - } -}; + MD5: 'MD5', + }, +} global.Logger = { - log: () => {} -}; + log: () => {}, +} // Install richer mocks for GmailApp, DriveApp and DocumentApp -const { installGlobals, resetAll } = require('./mocks'); -installGlobals(global); - -afterEach(() => resetAll(global)); +const { installGlobals, resetAll } = require('./mocks') +installGlobals(global) +afterEach(() => resetAll(global)) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..5224ec83 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "strict": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["google-apps-script", "jest"] + }, + "include": ["./src/**/*", "./test-utils/**/*"] +}