diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..08d95f9 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,43 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "browser": true, + "es6": true, + "node": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/no-non-null-assertion": "warn", + "no-console": ["warn", { "allow": ["warn", "error", "log"] }], + "semi": ["error", "always"], + "quotes": ["error", "single", { "avoidEscape": true }], + "comma-dangle": ["error", "never"], + "no-trailing-spaces": "error", + "indent": ["error", 4, { "SwitchCase": 1 }], + "linebreak-style": ["error", "unix"], + "eol-last": ["error", "always"], + "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }] + }, + "ignorePatterns": [ + "node_modules/", + "build/", + "dist/", + "main.js", + "*.js", + "docs/" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index c8fc7b4..e8c46e8 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,7 @@ AGENTS.md agents.md .kiro .windsurf + # Personal, not for public release scripts/ memo/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9379061 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Musashino Software + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7c7839 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# Voice Input Plugin for Obsidian + +High-accuracy voice input for Obsidian. Uses OpenAI GPT-4o Audio Transcriptions to transcribe speech (optimized for Japanese) and insert into your notes with one click or push-to-talk. + +## Features + +- One‑click recording: start/stop from a microphone ribbon icon +- Push‑to‑talk: long‑press to record, release to stop +- Model selection: GPT‑4o Transcribe or GPT‑4o mini Transcribe +- AI post‑processing: optional dictionary-based cleanup (JA) +- Quick controls in view: copy/clear/insert at cursor/append to end +- Auto‑save drafts: periodic and on blur, automatic restore + +## Requirements + +- OpenAI API key (Audio Transcriptions) +- Internet connectivity +- Obsidian desktop (Windows/macOS). This plugin is desktop‑only. + +Note: OpenAI usage is billed by API. + +## Installation (manual) + +1. Download the latest assets from Releases +2. Copy `main.js`, `manifest.json`, `styles.css`, `fvad.wasm`, `fvad.js` +3. Place them under `/.obsidian/plugins/voice-input/` +4. Restart Obsidian and enable the plugin + +## Commands + +- Open Voice Input (`open-voice-input`): opens the Voice Input view. Assign a hotkey from Obsidian’s Settings → Hotkeys if desired. + +## Usage + +1) Open the view +- Click the microphone ribbon icon, or run “Open Voice Input”. + +2) Record +- Click “Start Voice Input” to toggle recording, or use push‑to‑talk: long‑press the record button (starts after a short threshold), release to stop. + +3) Use the result +- Copy, Insert at Cursor, or Append to End of the active note. Clear resets the area. + +Tip: A settings gear in the view header opens the plugin’s settings. + +## Settings + +- OpenAI API Key: stored locally (encrypted at rest) +- Transcription Model: `gpt-4o-transcribe` or `gpt-4o-mini-transcribe` +- AI Post‑processing: enable dictionary‑based cleanup (Japanese) +- Maximum Recording Duration: slider (default 5 min) +- Plugin Language: English/Japanese (auto‑detected from Obsidian, adjustable) + +## Security & Privacy + +- Processing in memory; audio is not written to disk by the plugin +- HTTPS for all network requests (via Obsidian’s `requestUrl`) +- API key is encrypted for storage + +See also OpenAI’s Privacy Policy. + +## Troubleshooting + +- Microphone not recognized: allow mic access and restart Obsidian +- Transcription fails: verify API key, connectivity, and OpenAI credit +- Quiet audio: move closer to the mic and reduce background noise + +## Development + +- Install deps: `npm ci` +- Build: `npm run build-plugin` (outputs `build/latest/`) +- Deploy locally: `npm run deploy-local` (copies to detected vaults) +- Analyze unused code (build-time): `npm run analyze:unused` + +Third‑party licensing: see `THIRD_PARTY_LICENSES.md`. + +— + +# Voice Input Plugin for Obsidian(日本語) + +高精度な音声認識でメモを作成できるプラグインです。OpenAI GPT‑4oの音声文字起こしを使用し、日本語に最適化した文字起こしを行います。 + +## 機能 + +- ワンクリック録音(リボンのマイクアイコン) +- プッシュトゥトーク(長押しで録音開始、離して停止) +- モデル選択(GPT‑4o Transcribe / GPT‑4o mini Transcribe) +- AI後処理(辞書ベースの補正、JA向け) +- ビュー内のクイック操作(コピー/クリア/カーソル位置へ挿入/末尾へ追記) +- 自動保存(定期保存とフォーカス外れ時)。再オープン時に自動復元 + +## 必要条件 + +- OpenAI APIキー(Audio Transcriptions) +- インターネット接続 +- Obsidianデスクトップ版(Windows/macOS)。本プラグインはデスクトップ専用です。 + +※ OpenAI APIの利用には課金が発生します。 + +## インストール(手動) + +1. Releases から最新版を取得 +2. `main.js`、`manifest.json`、`styles.css`、`fvad.wasm`、`fvad.js` を配置 +3. `/.obsidian/plugins/voice-input/` に置く +4. Obsidianを再起動し、プラグインを有効化 + +## コマンド + +- Open Voice Input(`open-voice-input`): 音声入力ビューを開きます。必要に応じてホットキーを割り当ててください。 + +## 使い方 + +1) ビューを開く +- リボンのマイクアイコンをクリック、またはコマンドパレットから “Open Voice Input”。 + +2) 録音 +- 「音声入力開始」でトグル録音、またはプッシュトゥトーク(ボタン長押しで短い閾値後に録音開始、指を離すと停止)。 + +3) 結果の利用 +- コピー、カーソル位置へ挿入、末尾へ追記。クリアでテキストエリアを初期化。 + +ヒント: ビューのヘッダにある歯車ボタンから設定を開けます。 + +## 設定 + +- OpenAI APIキー: ローカルに暗号化して保存 +- 文字起こしモデル: `gpt-4o-transcribe` または `gpt-4o-mini-transcribe` +- AI後処理: 辞書ベースの補正(日本語向け) +- 最大録音時間: スライダー(初期値5分) +- プラグイン言語: 英語/日本語(Obsidian設定から自動検出、変更可) + +## セキュリティ / プライバシー + +- 処理はメモリ内で行い、音声ファイルはプラグイン側でディスク保存しません +- 通信はHTTPS(Obsidianの `requestUrl` 経由) +- APIキーは保存時に暗号化 + +OpenAIのプライバシーポリシーもご参照ください。 + +## トラブルシューティング + +- マイクが認識されない: マイク許可の付与、Obsidianの再起動 +- 文字起こしに失敗: APIキー、通信、OpenAIクレジット残高を確認 +- 音量が小さい/雑音が多い: マイクに近づく、静かな環境で録音 + +## 開発 + +- 依存インストール: `npm ci` +- ビルド: `npm run build-plugin` +- ローカル配布: `npm run deploy-local` + +サードパーティライセンスは `THIRD_PARTY_LICENSES.md` を参照してください。 +- 静かな環境で録音してください +- マイクとの距離を調整してください(30cm程度推奨) +- 他のアプリケーションがマイクを使用していないか確認 + +## ライセンス + +MIT License - 詳細は[LICENSE](LICENSE)ファイルをご確認ください。 + +詳細なライセンス情報については [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) をご覧ください。 + +## 作者 + +**Musashino Software** + +- GitHub: [@mssoftjp](https://github.com/mssoftjp) +- 支援: [Buy Me a Coffee](https://buymeacoffee.com/mssoft) + +## 貢献 + +バグ報告や機能要望は[Issues](https://github.com/mssoftjp/obsidian-voice-input-private/issues)でお願いします。 diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..9ab51ec --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,83 @@ +# Third-Party Licenses + +This project uses the following third-party libraries. Licenses and attributions are provided below. + +## Runtime Dependencies + +### @echogarden/fvad-wasm +- License: MIT +- Version: 0.2.0 +- Purpose: WebAssembly-based Voice Activity Detection (WebRTC VAD) +- Repository: https://github.com/echogarden-project/fvad-wasm + +This package provides a WebAssembly build of the WebRTC Voice Activity Detection (VAD). The original WebRTC VAD implementation is licensed under the BSD 3-Clause license and is attributed below. + +## Development Dependencies + +The following tools are used during development and are typically MIT or Apache-2.0 licensed: + +- TypeScript (Apache-2.0): TypeScript compiler and types +- ESBuild (MIT): JavaScript bundler and minifier +- ESLint (MIT): Linting for JavaScript/TypeScript +- Jest (MIT): Testing framework +- Obsidian API (MIT): Obsidian plugin development API + +## WebRTC VAD Attribution (BSD 3-Clause) + +``` +Copyright (c) 2011, The WebRTC project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +## License Texts + +### MIT License + +``` +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..5d1c9e9 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,58 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; +import fs from "fs"; +import path from "path"; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === "production"); + +// Ensure build/latest exists as the output directory +const outDir = path.join(process.cwd(), "build", "latest"); +fs.mkdirSync(outDir, { recursive: true }); + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + minifyWhitespace: prod, + minifyIdentifiers: prod, + minifySyntax: prod, + legalComments: 'inline', + outfile: path.join(outDir, "main.js"), +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..93691d7 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,28 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + roots: ['/src', '/tests'], + testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }], + }, + setupFilesAfterEnv: ['/tests/setup.ts'], + moduleFileExtensions: ['ts', 'js'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/plugin/index.ts', + '!src/**/index.ts', + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + testTimeout: 10000, + moduleNameMapper: { + '^obsidian$': '/tests/mocks/obsidian.ts', + }, +}; \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..5de84bc --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "voice-input", + "name": "Voice Input", + "version": "0.8.0", + "minAppVersion": "17.0.0", + "description": "Voice input with real-time transcription using GPT-4o.", + "author": "Musashino Software", + "authorUrl": "https://github.com/mssoftjp", + "fundingUrl": "https://buymeacoffee.com/mssoft", + "isDesktopOnly": true +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a9f9d16 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6582 @@ +{ + "name": "voice-input", + "version": "0.8.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "voice-input", + "version": "0.8.0", + "license": "MIT", + "dependencies": { + "@echogarden/fvad-wasm": "^0.2.0", + "@types/jest": "^30.0.0", + "jest": "^30.0.5", + "ts-jest": "^29.4.0" + }, + "devDependencies": { + "@types/node": "^16.11.6", + "@typescript-eslint/eslint-plugin": "^5.29.0", + "@typescript-eslint/parser": "^5.29.0", + "builtin-modules": "3.3.0", + "esbuild": "0.17.3", + "eslint": "^8.57.1", + "jest-environment-jsdom": "^30.0.5", + "obsidian": "latest", + "tslib": "2.4.0", + "typescript": "4.7.4" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/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/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", + "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "license": "MIT" + }, + "node_modules/@codemirror/state": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", + "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@echogarden/fvad-wasm": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@echogarden/fvad-wasm/-/fvad-wasm-0.2.0.tgz", + "integrity": "sha512-jPPzN6uV23dsOkKnGxajBDw81Xx3ICecw72sIzI+m4PzFWpSf/QOLvlgf7mySfqCngD54LRC1aDgD5haB45dbg==", + "license": "BSD-3-Clause" + }, + "node_modules/@emnapi/core": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", + "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", + "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.3.tgz", + "integrity": "sha512-1Mlz934GvbgdDmt26rTLmf03cAgLg5HyOgJN+ZGCeP3Q9ynYTNMn2/LQxIl7Uy+o4K6Rfi2OuLsr12JQQR8gNg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.3.tgz", + "integrity": "sha512-XvJsYo3dO3Pi4kpalkyMvfQsjxPWHYjoX4MDiB/FUM4YMfWcXa5l4VCwFWVYI1+92yxqjuqrhNg0CZg3gSouyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.3.tgz", + "integrity": "sha512-nuV2CmLS07Gqh5/GrZLuqkU9Bm6H6vcCspM+zjp9TdQlxJtIe+qqEXQChmfc7nWdyr/yz3h45Utk1tUn8Cz5+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.3.tgz", + "integrity": "sha512-01Hxaaat6m0Xp9AXGM8mjFtqqwDjzlMP0eQq9zll9U85ttVALGCGDuEvra5Feu/NbP5AEP1MaopPwzsTcUq1cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.3.tgz", + "integrity": "sha512-Eo2gq0Q/er2muf8Z83X21UFoB7EU6/m3GNKvrhACJkjVThd0uA+8RfKpfNhuMCl1bKRfBzKOk6xaYKQZ4lZqvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.3.tgz", + "integrity": "sha512-CN62ESxaquP61n1ZjQP/jZte8CE09M6kNn3baos2SeUfdVBkWN5n6vGp2iKyb/bm/x4JQzEvJgRHLGd5F5b81w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.3.tgz", + "integrity": "sha512-feq+K8TxIznZE+zhdVurF3WNJ/Sa35dQNYbaqM/wsCbWdzXr5lyq+AaTUSER2cUR+SXPnd/EY75EPRjf4s1SLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.3.tgz", + "integrity": "sha512-CLP3EgyNuPcg2cshbwkqYy5bbAgK+VhyfMU7oIYyn+x4Y67xb5C5ylxsNUjRmr8BX+MW3YhVNm6Lq6FKtRTWHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.3.tgz", + "integrity": "sha512-JHeZXD4auLYBnrKn6JYJ0o5nWJI9PhChA/Nt0G4MvLaMrvXuWnY93R3a7PiXeJQphpL1nYsaMcoV2QtuvRnF/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.3.tgz", + "integrity": "sha512-FyXlD2ZjZqTFh0sOQxFDiWG1uQUEOLbEh9gKN/7pFxck5Vw0qjWSDqbn6C10GAa1rXJpwsntHcmLqydY9ST9ZA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.3.tgz", + "integrity": "sha512-OrDGMvDBI2g7s04J8dh8/I7eSO+/E7nMDT2Z5IruBfUO/RiigF1OF6xoH33Dn4W/OwAWSUf1s2nXamb28ZklTA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.3.tgz", + "integrity": "sha512-DcnUpXnVCJvmv0TzuLwKBC2nsQHle8EIiAJiJ+PipEVC16wHXaPEKP0EqN8WnBe0TPvMITOUlP2aiL5YMld+CQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.3.tgz", + "integrity": "sha512-BDYf/l1WVhWE+FHAW3FzZPtVlk9QsrwsxGzABmN4g8bTjmhazsId3h127pliDRRu5674k1Y2RWejbpN46N9ZhQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.3.tgz", + "integrity": "sha512-WViAxWYMRIi+prTJTyV1wnqd2mS2cPqJlN85oscVhXdb/ZTFJdrpaqm/uDsZPGKHtbg5TuRX/ymKdOSk41YZow==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.3.tgz", + "integrity": "sha512-Iw8lkNHUC4oGP1O/KhumcVy77u2s6+KUjieUqzEU3XuWJqZ+AY7uVMrrCbAiwWTkpQHkr00BuXH5RpC6Sb/7Ug==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.3.tgz", + "integrity": "sha512-0AGkWQMzeoeAtXQRNB3s4J1/T2XbigM2/Mn2yU1tQSmQRmHIZdkGbVq2A3aDdNslPyhb9/lH0S5GMTZ4xsjBqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.3.tgz", + "integrity": "sha512-4+rR/WHOxIVh53UIQIICryjdoKdHsFZFD4zLSonJ9RRw7bhKzVyXbnRPsWSfwybYqw9sB7ots/SYyufL1mBpEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.3.tgz", + "integrity": "sha512-cVpWnkx9IYg99EjGxa5Gc0XmqumtAwK3aoz7O4Dii2vko+qXbkHoujWA68cqXjhh6TsLaQelfDO4MVnyr+ODeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.3.tgz", + "integrity": "sha512-RxmhKLbTCDAY2xOfrww6ieIZkZF+KBqG7S2Ako2SljKXRFi+0863PspK74QQ7JpmWwncChY25JTJSbVBYGQk2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.3.tgz", + "integrity": "sha512-0r36VeEJ4efwmofxVJRXDjVRP2jTmv877zc+i+Pc7MNsIr38NfsjkQj23AfF7l0WbB+RQ7VUb+LDiqC/KY/M/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.3.tgz", + "integrity": "sha512-wgO6rc7uGStH22nur4aLFcq7Wh86bE9cOFmfTr/yxN3BXvDEdCSXyKkO+U5JIt53eTOgC47v9k/C1bITWL/Teg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.3.tgz", + "integrity": "sha512-FdVl64OIuiKjgXBjwZaJLKp0eaEckifbhn10dXWhysMJkWblg3OEEGKSIyhiD5RSgAya8WzP3DNkngtIg3Nt7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.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/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "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/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/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==", + "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/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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==", + "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/@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==", + "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/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.0.5.tgz", + "integrity": "sha512-xY6b0XiL0Nav3ReresUarwl2oIz1gTnxGbGpho9/rbUWsLH0f1OD/VT84xs8c7VmH7MChnLb0pag6PhZhAdDiA==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.0.5.tgz", + "integrity": "sha512-fKD0OulvRsXF1hmaFgHhVJzczWzA1RXMMo9LTPuFXo9q/alDbME3JIyWYqovWsUBWSoBcsHaGPSLF9rz4l9Qeg==", + "license": "MIT", + "dependencies": { + "@jest/console": "30.0.5", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@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.0.5", + "jest-config": "30.0.5", + "jest-haste-map": "30.0.5", + "jest-message-util": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.5", + "jest-resolve-dependencies": "30.0.5", + "jest-runner": "30.0.5", + "jest-runtime": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "jest-watcher": "30.0.5", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "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==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.5.tgz", + "integrity": "sha512-aRX7WoaWx1oaOkDQvCWImVQ8XNtdv5sEWgk4gxR6NXb7WBUnL5sRak4WRzIQRZ1VTWPvV4VI4mgGjNL9TeKMYA==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.0.5.tgz", + "integrity": "sha512-gpWwiVxZunkoglP8DCnT3As9x5O8H6gveAOpvaJd2ATAoSh7ZSSCWbr9LQtUMvr8WD3VjG9YnDhsmkCK5WN1rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/expect": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.0.5.tgz", + "integrity": "sha512-6udac8KKrtTtC+AXZ2iUN/R7dp7Ydry+Fo6FPFnDG54wjVMnb6vW/XNlf7Xj8UDjAE3aAVAsR4KFyKk3TCXmTA==", + "license": "MIT", + "dependencies": { + "expect": "30.0.5", + "jest-snapshot": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.5.tgz", + "integrity": "sha512-F3lmTT7CXWYywoVUGTCmom0vXq3HTTkaZyTAzIy+bXSBizB7o5qzlC9VCtq0arOa8GqmNsbg/cE9C6HLn7Szew==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.5.tgz", + "integrity": "sha512-ZO5DHfNV+kgEAeP3gK3XlpJLL4U3Sz6ebl/n68Uwt64qFFs5bv4bfEEjyRGK5uM0C90ewooNgFuKMdkbEoMEXw==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.0.5.tgz", + "integrity": "sha512-7oEJT19WW4oe6HR7oLRvHxwlJk2gev0U9px3ufs8sX9PoD1Eza68KF0/tlN7X0dq/WVsBScXQGgCldA1V9Y/jA==", + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/expect": "30.0.5", + "@jest/types": "30.0.5", + "jest-mock": "30.0.5" + }, + "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==", + "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.0.5", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.0.5.tgz", + "integrity": "sha512-mafft7VBX4jzED1FwGC1o/9QUM2xebzavImZMeqnsklgcyxBto8mV4HzNSzUrryJ+8R9MFOM3HgYuDradWR+4g==", + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@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.0.5", + "jest-util": "30.0.5", + "jest-worker": "30.0.5", + "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/reporters/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "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" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "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.0.5", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.0.5.tgz", + "integrity": "sha512-XcCQ5qWHLvi29UUrowgDFvV4t7ETxX91CbDczMnoqXPOIcZOxyNdSjm6kV5XMc8+HkxfRegU/MUmnTbJRzGrUQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "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==", + "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.0.5", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.5.tgz", + "integrity": "sha512-wPyztnK0gbDMQAJZ43tdMro+qblDHH1Ru/ylzUo21TBKqt88ZqnKKK2m30LKmLLoKtR2lxdpCC/P3g1vfKcawQ==", + "license": "MIT", + "dependencies": { + "@jest/console": "30.0.5", + "@jest/types": "30.0.5", + "@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.0.5", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.0.5.tgz", + "integrity": "sha512-Aea/G1egWoIIozmDD7PBXUOxkekXl7ueGzrsGGi1SbeKgQqCYCIf+wfbflEbf2LiPxL8j2JZGLyrzZagjvW4YQ==", + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.0.5", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.5.tgz", + "integrity": "sha512-Vk8amLQCmuZyy6GbBht1Jfo9RSdBtg7Lks+B0PecnjI8J+PCLQPGh7uI8Q/2wwpW2gLdiAfiHNsmekKlywULqg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.0.5", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.0", + "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.0.5", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.5", + "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.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "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.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "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==", + "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==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "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/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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.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": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "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==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-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==", + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "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/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-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==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "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/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.5.tgz", + "integrity": "sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg==", + "license": "MIT", + "dependencies": { + "@jest/transform": "30.0.5", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.0", + "babel-preset-jest": "30.0.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz", + "integrity": "sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz", + "integrity": "sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz", + "integrity": "sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.0.1", + "babel-preset-current-node-syntax": "^1.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "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==", + "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", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001731", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", + "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", + "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.194", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.194.tgz", + "integrity": "sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==", + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz", + "integrity": "sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.3", + "@esbuild/android-arm64": "0.17.3", + "@esbuild/android-x64": "0.17.3", + "@esbuild/darwin-arm64": "0.17.3", + "@esbuild/darwin-x64": "0.17.3", + "@esbuild/freebsd-arm64": "0.17.3", + "@esbuild/freebsd-x64": "0.17.3", + "@esbuild/linux-arm": "0.17.3", + "@esbuild/linux-arm64": "0.17.3", + "@esbuild/linux-ia32": "0.17.3", + "@esbuild/linux-loong64": "0.17.3", + "@esbuild/linux-mips64el": "0.17.3", + "@esbuild/linux-ppc64": "0.17.3", + "@esbuild/linux-riscv64": "0.17.3", + "@esbuild/linux-s390x": "0.17.3", + "@esbuild/linux-x64": "0.17.3", + "@esbuild/netbsd-x64": "0.17.3", + "@esbuild/openbsd-x64": "0.17.3", + "@esbuild/sunos-x64": "0.17.3", + "@esbuild/win32-arm64": "0.17.3", + "@esbuild/win32-ia32": "0.17.3", + "@esbuild/win32-x64": "0.17.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "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/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/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/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/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/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/esrecurse/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/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "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/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.5.tgz", + "integrity": "sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ==", + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.0.5", + "@jest/get-type": "30.0.1", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" + }, + "engines": { + "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-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "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==", + "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", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=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", + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "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", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/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-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "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", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "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==", + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "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==", + "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/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.0.5.tgz", + "integrity": "sha512-y2mfcJywuTUkvLm2Lp1/pFX8kTgMO5yyQGq/Sk/n2mN7XWYp4JsCZ/QXW34M8YScgk8bPZlREH04f6blPnoHnQ==", + "license": "MIT", + "dependencies": { + "@jest/core": "30.0.5", + "@jest/types": "30.0.5", + "import-local": "^3.2.0", + "jest-cli": "30.0.5" + }, + "bin": { + "jest": "bin/jest.js" + }, + "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-changed-files": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.5.tgz", + "integrity": "sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==", + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.0.5", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.0.5.tgz", + "integrity": "sha512-h/sjXEs4GS+NFFfqBDYT7y5Msfxh04EwWLhQi0F8kuWpe+J/7tICSlswU8qvBqumR3kFgHbfu7vU6qruWWBPug==", + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/expect": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.0.5", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-runtime": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", + "p-limit": "^3.1.0", + "pretty-format": "30.0.5", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.0.5.tgz", + "integrity": "sha512-Sa45PGMkBZzF94HMrlX4kUyPOwUpdZasaliKN3mifvDmkhLYqLLg8HQTzn6gq7vJGahFYMQjXgyJWfYImKZzOw==", + "license": "MIT", + "dependencies": { + "@jest/core": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "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-config": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.0.5.tgz", + "integrity": "sha512-aIVh+JNOOpzUgzUnPn5FLtyVnqc3TQHVMupYtyeURSb//iLColiMIR8TxCIDKyx9ZgjKnXGucuW68hCxgbrwmA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.0.1", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.0.5", + "@jest/types": "30.0.5", + "babel-jest": "30.0.5", + "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.0.5", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.5", + "jest-runner": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "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-config/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "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" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-diff": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", + "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz", + "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==", + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.0.5.tgz", + "integrity": "sha512-dKjRsx1uZ96TVyejD3/aAWcNKy6ajMaN531CwWIsrazIqIoXI9TnnpPlkrEYku/8rkS3dh2rbH+kMOyiEIv0xQ==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "jest-util": "30.0.5", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.0.5.tgz", + "integrity": "sha512-BmnDEoAH+jEjkPrvE9DTKS2r3jYSJWlN/r46h0/DBUxKrkgt2jAZ5Nj4wXLAcV1KWkRpcFqA5zri9SWzJZ1cCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/environment-jsdom-abstract": "30.0.5", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.0.5.tgz", + "integrity": "sha512-ppYizXdLMSvciGsRsMEnv/5EFpvOdXBaXRBzFUDPWrsfmog4kYrOGWXarLllz6AXan6ZAA/kYokgDWuos1IKDA==", + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.5.tgz", + "integrity": "sha512-dkmlWNlsTSR0nH3nRfW5BKbqHefLZv0/6LCccG0xFCTWcJu8TuEwG+5Cm75iBfjVoockmO6J35o5gxtFSn5xeg==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.5", + "jest-worker": "30.0.5", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.0.5.tgz", + "integrity": "sha512-3Uxr5uP8jmHMcsOtYMRB/zf1gXN3yUIc+iPorhNETG54gErFIiUhLvyY/OggYpSMOEYqsmRxmuU4ZOoX5jpRFg==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.5.tgz", + "integrity": "sha512-uQgGWt7GOrRLP1P7IwNWwK1WAQbq+m//ZY0yXygyfWp0rJlksMSLQAA4wYQC3b6wl3zfnchyTx+k3HZ5aPtCbQ==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "jest-diff": "30.0.5", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.5.tgz", + "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "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==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.5.tgz", + "integrity": "sha512-d+DjBQ1tIhdz91B79mywH5yYu76bZuE96sSbxj8MkjWVx5WNdt1deEFRONVL4UkKLSrAbMkdhb24XN691yDRHg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.5", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.0.5", + "jest-validate": "30.0.5", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.0.5.tgz", + "integrity": "sha512-/xMvBR4MpwkrHW4ikZIWRttBBRZgWK4d6xt3xW1iRDSKt4tXzYkMkyPfBnSCgv96cpkrctfXs6gexeqMYqdEpw==", + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.0.5.tgz", + "integrity": "sha512-JcCOucZmgp+YuGgLAXHNy7ualBx4wYSgJVWrYMRBnb79j9PD0Jxh0EHvR5Cx/r0Ce+ZBC4hCdz2AzFFLl9hCiw==", + "license": "MIT", + "dependencies": { + "@jest/console": "30.0.5", + "@jest/environment": "30.0.5", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.0.5", + "jest-haste-map": "30.0.5", + "jest-leak-detector": "30.0.5", + "jest-message-util": "30.0.5", + "jest-resolve": "30.0.5", + "jest-runtime": "30.0.5", + "jest-util": "30.0.5", + "jest-watcher": "30.0.5", + "jest-worker": "30.0.5", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.0.5.tgz", + "integrity": "sha512-7oySNDkqpe4xpX5PPiJTe5vEa+Ak/NnNz2bGYZrA1ftG3RL3EFlHaUkA1Cjx+R8IhK0Vg43RML5mJedGTPNz3A==", + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/globals": "30.0.5", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "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.0.5", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.5", + "jest-snapshot": "30.0.5", + "jest-util": "30.0.5", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "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" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-snapshot": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.0.5.tgz", + "integrity": "sha512-T00dWU/Ek3LqTp4+DcW6PraVxjk28WY5Ua/s+3zUKSERZSNyxTqhDXCWKG5p2HAJ+crVQ3WJ2P9YVHpj1tkW+g==", + "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.0.5", + "@jest/get-type": "30.0.1", + "@jest/snapshot-utils": "30.0.5", + "@jest/transform": "30.0.5", + "@jest/types": "30.0.5", + "babel-preset-current-node-syntax": "^1.1.0", + "chalk": "^4.1.2", + "expect": "30.0.5", + "graceful-fs": "^4.2.11", + "jest-diff": "30.0.5", + "jest-matcher-utils": "30.0.5", + "jest-message-util": "30.0.5", + "jest-util": "30.0.5", + "pretty-format": "30.0.5", + "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-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@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==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.5.tgz", + "integrity": "sha512-ouTm6VFHaS2boyl+k4u+Qip4TSH7Uld5tyD8psQ8abGgt2uYYB8VwVfAHWHjHc0NWmGGbwO5h0sCPOGHHevefw==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "@jest/types": "30.0.5", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.0.5" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.0.5.tgz", + "integrity": "sha512-z9slj/0vOwBDBjN3L4z4ZYaA+pG56d6p3kTUhFRYGvXbXMWhXmb/FIxREZCD06DYUwDKKnj2T80+Pb71CQ0KEg==", + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.0.5", + "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.0.5", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.5.tgz", + "integrity": "sha512-ojRXsWzEP16NdUuBw/4H/zkZdHOa7MMYCk4E430l+8fELeLg/mqmMlRhjL7UNZvQrDmnovWZV4DxX03fZF48fQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.0.5", + "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==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "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==", + "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==", + "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==", + "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==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.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==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", + "integrity": "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw==", + "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", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT" + }, + "node_modules/natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "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", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", + "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/obsidian": { + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz", + "integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "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", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "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==", + "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", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "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==", + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "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==", + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/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==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.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/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "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/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==", + "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/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "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==", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-jest": { + "version": "29.4.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", + "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==", + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "ejs": "^3.1.10", + "fast-json-stable-stringify": "^2.1.0", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.2", + "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/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "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==", + "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.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "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", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "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/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==", + "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/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==", + "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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "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==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3a5cc2a --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "voice-input", + "version": "0.8.0", + "description": "Voice input plugin for Obsidian with accurate transcription", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "build-plugin": "npm run build && node scripts/post-build.mjs", + "build-timestamp": "./scripts/build-timestamp.sh", + "deploy": "./scripts/build-timestamp.sh", + "analyze:unused": "node scripts/analyze-unused.mjs", + "deploy-local": "npm run build-plugin && node scripts/deploy-local.mjs", + "deploy-icloud": "npm run build-plugin && node scripts/deploy-icloud.mjs", + "deploy:quick": "npm run deploy-local", + "check": "npm run lint && npm run build", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "version": "node version-bump.mjs && git add manifest.json versions.json" + }, + "keywords": [ + "obsidian", + "plugin", + "voice", + "transcription", + "gpt-4o" + ], + "author": "Musashino Software", + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "@typescript-eslint/eslint-plugin": "^5.29.0", + "@typescript-eslint/parser": "^5.29.0", + "builtin-modules": "3.3.0", + "esbuild": "0.17.3", + "eslint": "^8.57.1", + "jest-environment-jsdom": "^30.0.5", + "obsidian": "latest", + "tslib": "2.4.0", + "typescript": "4.7.4" + }, + "dependencies": { + "@echogarden/fvad-wasm": "^0.2.0", + "@types/jest": "^30.0.0", + "jest": "^30.0.5", + "ts-jest": "^29.4.0" + } +} diff --git a/src/config/constants.ts b/src/config/constants.ts new file mode 100644 index 0000000..ceb7b20 --- /dev/null +++ b/src/config/constants.ts @@ -0,0 +1,212 @@ +/** + * 技術的な定数の定義 + * これらの値は内部的に使用され、通常ユーザーが変更する必要はありません + */ + +/** + * オーディオ関連の定数 + */ +export const AUDIO_CONSTANTS = { + /** WebRTC VADが要求するサンプルレート (Hz) */ + SAMPLE_RATE: 16000, + + /** オーディオ処理のバッファサイズ */ + BUFFER_SIZE: 4096, + + /** 最大録音時間 (秒) */ + MAX_RECORDING_SECONDS: 300, + + /** デフォルトのリングバッファ最大時間 (秒) */ + DEFAULT_RING_BUFFER_SECONDS: 300, + + /** オーディオコンテキストのレイテンシーヒント */ + LATENCY_HINT: 'interactive' as const, + + /** デフォルトのゲイン値 */ + DEFAULT_GAIN: 1.0, + + /** オーディオフィルター設定 */ + FILTERS: { + /** ハイパスフィルターの周波数 (Hz) - 低周波ノイズ除去 */ + HIGH_PASS_FREQ: 80, + /** ローパスフィルターの周波数 (Hz) - 高周波ノイズ除去 */ + LOW_PASS_FREQ: 7600 + }, + + /** マイク入力検出の閾値 */ + MIC_DETECTION_THRESHOLD: 0.0001, + + /** 録音フォーマット */ + WEBM_CODEC: 'audio/webm;codecs=opus', + + /** MediaRecorderのデータ収集間隔 (ms) */ + RECORDER_TIMESLICE: 100, + + /** バッファ使用率の警告閾値 (%) */ + BUFFER_WARNING_THRESHOLD: 80, + + /** バッファ統計ログの出力確率 */ + BUFFER_STATS_LOG_PROBABILITY: 0.01 +} as const; + +/** + * VAD (Voice Activity Detection) 関連の定数 + */ +export const VAD_CONSTANTS = { + /** VADフレームサイズ (16kHzで30ms) */ + FRAME_SIZE: 480, + + /** VADが要求するサンプルレート (Hz) */ + SAMPLE_RATE: 16000, + + /** 入力オーディオのサンプルレート (Hz) */ + INPUT_SAMPLE_RATE: 48000, + + /** ノイズレベルの閾値 */ + NOISE_THRESHOLDS: { + /** 騒音環境の閾値 */ + NOISY: 0.1, + /** 通常環境の閾値 */ + NORMAL: 0.05 + }, + + /** VADモード自動調整の間隔 (フレーム数) */ + ADJUSTMENT_INTERVAL: 100, + + /** ノイズレベルの指数移動平均係数 */ + NOISE_LEVEL_EMA: { + /** 前の値の重み */ + PREVIOUS: 0.95, + /** 現在の値の重み */ + CURRENT: 0.05 + }, + + /** VAD結果の蓄積閾値 (フレーム数) */ + VAD_ACCUMULATION_THRESHOLD: 10 +} as const; + +/** + * 可視化関連の定数 + */ +export const VISUALIZATION_CONSTANTS = { + /** キャンバスのサイズ */ + CANVAS: { + WIDTH: 200, + HEIGHT: 60 + }, + + /** 波形履歴の最大長 */ + MAX_HISTORY_LENGTH: 300, + + /** FFTサイズ (詳細表示用) */ + FFT_SIZE: 2048, + + /** FFTサイズ (シンプル表示用) */ + SIMPLE_FFT_SIZE: 256, + + /** 関連する周波数ビンの数 */ + RELEVANT_BINS: 50, + + /** オーディオレベルのスケーリング係数 */ + LEVEL_SCALING: 128, + + /** VAD閾値のデフォルト値 */ + DEFAULT_VAD_THRESHOLD: 0.02 +} as const; + +/** + * API関連の定数 + */ +export const API_CONSTANTS = { + /** APIエンドポイント */ + ENDPOINTS: { + /** 音声文字起こしAPI */ + TRANSCRIPTION: 'https://api.openai.com/v1/audio/transcriptions' + }, + + /** APIパラメータ */ + PARAMETERS: { + /** 文字起こしの温度パラメータ (決定論的) */ + TRANSCRIPTION_TEMPERATURE: 0.0, + /** 最大トークン数 */ + MAX_TOKENS: 2000 + } +} as const; + +/** + * ファイルシステム関連の定数 + */ +export const FILE_CONSTANTS = { + /** プラグインID */ + PLUGIN_ID: 'voice-input', + + /** WASMファイルのパス */ + WASM_PATHS: { + JS: 'src/lib/fvad-wasm/fvad.js', + WASM: 'src/lib/fvad-wasm/fvad.wasm' + } +} as const; + +/** + * 辞書関連の定数 + */ +export const DICTIONARY_CONSTANTS = { + /** 固定補正の最大個数 */ + MAX_DEFINITE_CORRECTIONS: 50, + + /** 文脈補正の最大個数 */ + MAX_CONTEXTUAL_CORRECTIONS: 150, + + /** 補正カテゴリ(IMEの品詞分類を参考) */ + CATEGORIES: [ + 'noun', // 名詞 + 'person', // 人名 + 'place', // 地名 + 'org', // 組織名 + 'proper', // その他固有名詞 + 'technical', // 専門用語 + 'spoken', // 話し言葉 + 'symbol' // 記号・単位 + ] as const, + + /** デフォルトカテゴリ */ + DEFAULT_CATEGORY: 'noun' +} as const; + +/** + * UI関連の定数 + */ +export const UI_CONSTANTS = { + /** プッシュ・トゥ・トークの閾値 (ms) */ + PUSH_TO_TALK_THRESHOLD: 300, + + /** プッシュトゥトークのフラグリセット遅延 (ms) */ + PUSH_TO_TALK_RESET_DELAY: 100, + + /** フィードバック表示の遅延時間 (ms) */ + FEEDBACK_DELAY: 3000, + + /** レコードボタンのスタイル */ + RECORD_BUTTON: { + PADDING: '32px 20px', + FONT_SIZE: '20px', + MIN_HEIGHT: '80px' + }, + + /** テキストエリアの最小高さ */ + TEXTAREA_MIN_HEIGHT: '200px', + + /** アニメーション */ + ANIMATION: { + /** パルスアニメーションの周期 */ + PULSE_DURATION: '1.5s' + } +} as const; + +/** すべての定数の型 */ +export type AudioConstants = typeof AUDIO_CONSTANTS; +export type VADConstants = typeof VAD_CONSTANTS; +export type VisualizationConstants = typeof VISUALIZATION_CONSTANTS; +export type APIConstants = typeof API_CONSTANTS; +export type FileConstants = typeof FILE_CONSTANTS; +export type UIConstants = typeof UI_CONSTANTS; diff --git a/src/config/costs.ts b/src/config/costs.ts new file mode 100644 index 0000000..36b6591 --- /dev/null +++ b/src/config/costs.ts @@ -0,0 +1,107 @@ +/** + * APIモデルのコスト情報 + * 価格は1分あたりのUSDで表記 + * 最新の価格はOpenAIの公式サイトで確認してください。 + */ + +/** + * 音声文字起こしモデルのコスト ($/分) + */ +export const TRANSCRIPTION_MODEL_COSTS = { + /** GPT-4o Transcribe - 高精度版 */ + 'gpt-4o-transcribe': 0.06, + + /** GPT-4o Mini Transcribe - コスト効率版 */ + 'gpt-4o-mini-transcribe': 0.03, + + /** Whisper Large (参考) */ + 'whisper-1': 0.006 +} as const; + +/** + * コスト計算のヘルパー関数 + */ +export const CostCalculator = { + /** + * 音声文字起こしのコストを計算 + * @param durationSeconds 音声の長さ(秒) + * @param model 使用するモデル + * @returns コスト(USD) + */ + calculateTranscriptionCost( + durationSeconds: number, + model: keyof typeof TRANSCRIPTION_MODEL_COSTS + ): number { + const durationMinutes = durationSeconds / 60; + const costPerMinute = TRANSCRIPTION_MODEL_COSTS[model]; + return durationMinutes * costPerMinute; + }, + + /** + * テキストからトークン数を概算 + * 日本語の場合: 1文字 ≈ 0.5トークン + * 英語の場合: 1単語 ≈ 1.3トークン + * @param text テキスト + * @param language 言語 + * @returns 概算トークン数 + */ + estimateTokens(text: string, language = 'ja'): number { + if (language === 'ja') { + // 日本語: 文字数の約半分 + return Math.ceil(text.length * 0.5); + } else { + // 英語: 単語数の約1.3倍 + const words = text.split(/\s+/).length; + return Math.ceil(words * 1.3); + } + }, + + /** + * 月間コストを予測(文字起こしのみ) + * @param dailyMinutes 1日あたりの使用分数 + * @param transcriptionModel 文字起こしモデル + * @returns 月間予測コスト(USD) + */ + estimateMonthlyCost( + dailyMinutes: number, + transcriptionModel: keyof typeof TRANSCRIPTION_MODEL_COSTS + ): number { + // 文字起こしコスト(30日分) + return dailyMinutes * 30 * TRANSCRIPTION_MODEL_COSTS[transcriptionModel]; + } +} as const; + +/** + * コスト情報の表示用フォーマット + */ +export const COST_DISPLAY = { + /** + * 金額をフォーマット + * @param amount 金額(USD) + * @param decimals 小数点以下の桁数 + * @returns フォーマットされた文字列 + */ + formatCost(amount: number, decimals = 2): string { + return `$${amount.toFixed(decimals)}`; + }, + + /** + * コストの比較を表示 + * @param costA コストA + * @param costB コストB + * @returns 比較結果の文字列 + */ + compareCost(costA: number, costB: number): string { + const ratio = costA / costB; + if (ratio > 1) { + return `${ratio.toFixed(1)}倍高い`; + } else { + return `${(1 / ratio).toFixed(1)}倍安い`; + } + } +} as const; + +/** コスト関連の型定義 */ +export type TranscriptionModelCosts = typeof TRANSCRIPTION_MODEL_COSTS; +export type CostCalculatorType = typeof CostCalculator; +export type CostDisplayType = typeof COST_DISPLAY; diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..264ce92 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,109 @@ +/** + * ユーザー設定のデフォルト値 + * これらの値はユーザーが設定画面から変更可能です + */ + +/** + * オーディオ設定のデフォルト値 + */ +export const DEFAULT_AUDIO_SETTINGS = { + /** 無音検出による自動停止までの時間 (ms) */ + autoStopSilenceDuration: 2000, + + /** オーディオゲイン */ + gain: 1.0, + + /** エコーキャンセレーション */ + echoCancellation: true, + + /** ノイズ抑制 */ + noiseSuppression: true, + + /** 自動ゲインコントロール */ + autoGainControl: true, + + /** チャンネル数 (モノラル) */ + channelCount: 1 +} as const; + +/** + * VAD設定のデフォルト値 + */ +export const DEFAULT_VAD_SETTINGS = { + /** VADモード (0-3: 0=低感度, 3=高感度) */ + mode: 3, + + /** 最小音声継続時間 (ms) */ + minSpeechDuration: 100, + + /** 最小無音継続時間 (ms) */ + minSilenceDuration: 1000, + + /** 音声前後のパディング (ms) */ + speechPadding: 200, + + /** 環境に応じた自動調整 */ + autoAdjust: true +} as const; + +/** + * UI設定のデフォルト値 + */ +export const DEFAULT_UI_SETTINGS = { + /** プッシュ・トゥ・トーク機能の有効化 */ + pushToTalkEnabled: true, + + /** 音声レベルの可視化表示 */ + showVisualization: true, + + /** タイムスタンプのフォーマット */ + timestampFormat: 'YYYY-MM-DD-HH-mm-ss', + + /** 録音ボタンのサイズ */ + recordButtonSize: 'large' as 'small' | 'medium' | 'large' +} as const; + +/** + * 文字起こし設定のデフォルト値 + */ +export const DEFAULT_TRANSCRIPTION_SETTINGS = { + /** デフォルトの文字起こしモデル */ + model: 'gpt-4o-transcribe' as const, + + /** 言語設定 */ + language: 'ja', + + /** 文字起こし補正の有効化 */ + enableTranscriptionCorrection: true +} as const; + +/** + * 統合されたデフォルト設定 + */ +export const DEFAULT_USER_SETTINGS = { + /** 基本設定 */ + openaiApiKey: '', + language: DEFAULT_TRANSCRIPTION_SETTINGS.language, + enableTranscriptionCorrection: DEFAULT_TRANSCRIPTION_SETTINGS.enableTranscriptionCorrection, + + /** 音声設定 */ + autoStopSilenceDuration: DEFAULT_AUDIO_SETTINGS.autoStopSilenceDuration, + minSpeechDuration: DEFAULT_VAD_SETTINGS.minSpeechDuration, + + /** モデル設定 */ + transcriptionModel: DEFAULT_TRANSCRIPTION_SETTINGS.model, + + /** 詳細設定 (オプション) */ + advanced: { + audio: DEFAULT_AUDIO_SETTINGS, + vad: DEFAULT_VAD_SETTINGS, + ui: DEFAULT_UI_SETTINGS + } +} as const; + +/** デフォルト設定の型 */ +export type DefaultAudioSettings = typeof DEFAULT_AUDIO_SETTINGS; +export type DefaultVADSettings = typeof DEFAULT_VAD_SETTINGS; +export type DefaultUISettings = typeof DEFAULT_UI_SETTINGS; +export type DefaultTranscriptionSettings = typeof DEFAULT_TRANSCRIPTION_SETTINGS; +export type DefaultUserSettings = typeof DEFAULT_USER_SETTINGS; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..0b51fb0 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,158 @@ +/** + * 設定ファイルの統合エクスポート + * すべての設定定数、デフォルト値、コスト情報をまとめて提供 + */ + +// 定数のエクスポート +export * from './constants'; +export { + AUDIO_CONSTANTS, + VAD_CONSTANTS, + VISUALIZATION_CONSTANTS, + API_CONSTANTS, + FILE_CONSTANTS, + UI_CONSTANTS +} from './constants'; + +// デフォルト値のエクスポート +export * from './defaults'; +export { + DEFAULT_AUDIO_SETTINGS, + DEFAULT_VAD_SETTINGS, + DEFAULT_TRANSCRIPTION_SETTINGS, + DEFAULT_USER_SETTINGS +} from './defaults'; + +// Message constants have been migrated to i18n system +// See src/i18n/ for translations + +// コスト情報のエクスポート +export * from './costs'; +export { + TRANSCRIPTION_MODEL_COSTS, + CostCalculator, + COST_DISPLAY +} from './costs'; + +/** + * 設定ヘルパー関数 + * @deprecated 現在未使用 - 将来の拡張用に保持 + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const ConfigHelper = { + /** + * ユーザー設定とデフォルト設定をマージ + * @param userSettings ユーザーの設定 + * @param defaults デフォルト設定 + * @returns マージされた設定 + */ + mergeWithDefaults>( + userSettings: Partial, + defaults: T + ): T { + return { + ...defaults, + ...userSettings, + // ネストされたオブジェクトも再帰的にマージ + ...Object.keys(defaults).reduce((acc, key) => { + if ( + typeof defaults[key] === 'object' && + defaults[key] !== null && + !Array.isArray(defaults[key]) && + userSettings[key] + ) { + (acc as Record)[key] = this.mergeWithDefaults(userSettings[key], defaults[key]); + } + return acc; + }, {} as Partial) + }; + }, + + /** + * 設定値の検証 + * @param value 検証する値 + * @param min 最小値 + * @param max 最大値 + * @returns 検証済みの値 + */ + validateNumber(value: number, min: number, max: number): number { + if (isNaN(value)) { + throw new Error('Value must be a number'); + } + return Math.max(min, Math.min(max, value)); + }, + + /** + * 設定値が有効な範囲内かチェック + * @param settings 設定オブジェクト + * @returns エラーメッセージの配列(エラーがない場合は空配列) + */ + validateSettings(settings: Record): string[] { + const errors: string[] = []; + + // 音声設定の検証 + if (settings.autoStopSilenceDuration !== undefined) { + const value = settings.autoStopSilenceDuration as number; + if (typeof value === 'number' && (value < 100 || value > 10000)) { + errors.push('Auto stop duration must be between 100ms and 10000ms'); + } + } + + // VAD設定の検証 + if (settings.minSpeechDuration !== undefined) { + const value = settings.minSpeechDuration as number; + if (typeof value === 'number' && (value < 50 || value > 5000)) { + errors.push('Minimum speech duration must be between 50ms and 5000ms'); + } + } + + return errors; + }, + + /** + * 環境変数から設定を読み込み + * @param envPrefix 環境変数のプレフィックス + * @returns 環境変数から読み込んだ設定 + */ + loadFromEnv(envPrefix = 'VOICE_TRANSCRIPTION_'): Record { + const config: Record = {}; + + // ブラウザ環境では環境変数は使用できないため、空オブジェクトを返す + if (typeof process === 'undefined' || !process.env) { + return config; + } + + Object.keys(process.env).forEach(key => { + if (key.startsWith(envPrefix)) { + const configKey = key + .substring(envPrefix.length) + .toLowerCase() + .replace(/_/g, '.'); + + let value: string | number | boolean = process.env[key] || ''; + + // 型変換の試行 + if (value === 'true') value = true; + else if (value === 'false') value = false; + else if (!isNaN(Number(value))) value = Number(value); + + // ネストされたキーの処理 + const keys = configKey.split('.'); + let current: Record = config; + + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) { + current[keys[i]] = {}; + } + current = current[keys[i]] as Record; + } + + current[keys[keys.length - 1]] = value; + } + }); + + return config; + } +} as const; + +// ConfigHelperは現在未使用のため、必要になったらエクスポートを追加 diff --git a/src/core/audio/AudioRecorder.ts b/src/core/audio/AudioRecorder.ts new file mode 100644 index 0000000..3860e33 --- /dev/null +++ b/src/core/audio/AudioRecorder.ts @@ -0,0 +1,588 @@ +import { VADProcessor, VADSegment } from './VADProcessor'; +import { AudioVisualizer, SimpleAudioLevelIndicator } from './AudioVisualizer'; +import { AudioRecorderOptions } from '../../interfaces'; +import { TranscriptionError, TranscriptionErrorType } from '../../errors'; +import { AudioRingBuffer } from './AudioRingBuffer'; +import { createAudioWorkletBlobURL, revokeAudioWorkletBlobURL } from './AudioWorkletSource'; +import { AUDIO_CONSTANTS, DEFAULT_AUDIO_SETTINGS, DEFAULT_VAD_SETTINGS, VAD_CONSTANTS } from '../../config'; +import { Disposable, CompositeDisposable } from '../../interfaces'; +import { Logger } from '../../utils'; +import { createServiceLogger } from '../../services'; +import { WindowWithWebkitAudio, hasWebkitAudioContext } from '../../types'; + +export class AudioRecorder extends Disposable { + private mediaRecorder: MediaRecorder | null = null; + private audioContext: AudioContext | null = null; + private analyserNode: AnalyserNode | null = null; + private sourceNode: MediaStreamAudioSourceNode | null = null; + private gainNode: GainNode | null = null; // 音声増幅用 + private highPassFilter: BiquadFilterNode | null = null; // 80Hz HPF + private lowPassFilter: BiquadFilterNode | null = null; // 7.6kHz LPF + private workletNode: AudioWorkletNode | null = null; + private vadProcessor: VADProcessor | null = null; + private visualizer: AudioVisualizer | SimpleAudioLevelIndicator | null = null; + private chunks: Blob[] = []; + private isRecording: boolean = false; + private stream: MediaStream | null = null; + private options: AudioRecorderOptions; + private silenceTimer: NodeJS.Timeout | null = null; + private lastSpeechTime: number = 0; + private continuousAudioData: Float32Array[] = []; + private audioRingBuffer: AudioRingBuffer; + private sampleRate: number = AUDIO_CONSTANTS.SAMPLE_RATE; + private workletReady: boolean = false; + private workletBlobURL: string | null = null; + private microphoneReady: boolean = false; + private audioDataReceived: boolean = false; + private disposables: CompositeDisposable = new CompositeDisposable(); + private logger: Logger | null = null; + private recordingStartTime: number = 0; + private maxRecordingTimer: NodeJS.Timeout | null = null; + + constructor(options: AudioRecorderOptions) { + super(); + this.options = options; + + // Loggerの遅延初期化(ServiceLocatorから取得) + try { + this.logger = createServiceLogger('AudioRecorder'); + } catch (error) { + // ServiceLocatorがまだ初期化されていない場合は後で初期化 + this.logger = null; + } + + // 型ガードでVADの使用を判定 + if (options.useVAD) { + // TypeScriptの型推論により、ここではoptions.appが必須であることが保証される + this.vadProcessor = new VADProcessor(options.app, { + vadMode: options.vadMode || DEFAULT_VAD_SETTINGS.mode, + minSpeechDuration: options.minSpeechDuration || DEFAULT_VAD_SETTINGS.minSpeechDuration, + minSilenceDuration: options.minSilenceDuration || DEFAULT_VAD_SETTINGS.minSilenceDuration, + speechPadding: DEFAULT_VAD_SETTINGS.speechPadding + }); + } + // VADを使用しない場合は、vadProcessorはnullのまま + + // Initialize ring buffer with max recording duration + const maxSeconds = this.options.maxRecordingSeconds || AUDIO_CONSTANTS.MAX_RECORDING_SECONDS; + this.audioRingBuffer = new AudioRingBuffer(maxSeconds, this.sampleRate); + } + + async initialize(): Promise { + this.throwIfDisposed(); + + if (this.options.useVAD && this.vadProcessor) { + await this.vadProcessor.initialize(); + } + + // Create audio context with configured sample rate and low latency hint + const WindowWithWebkit = window as WindowWithWebkitAudio; + const AudioContextConstructor = window.AudioContext || WindowWithWebkit.webkitAudioContext; + + if (!AudioContextConstructor) { + throw new TranscriptionError( + TranscriptionErrorType.AUDIO_INITIALIZATION_FAILED, + 'AudioContext is not supported in this browser' + ); + } + + this.audioContext = new AudioContextConstructor({ + sampleRate: AUDIO_CONSTANTS.SAMPLE_RATE, + latencyHint: AUDIO_CONSTANTS.LATENCY_HINT // Low latency mode for faster initialization + }); + this.sampleRate = this.audioContext.sampleRate; + + // Try to use AudioWorklet with blob URL + if (this.audioContext.audioWorklet) { + try { + // Create blob URL for AudioWorklet + this.workletBlobURL = createAudioWorkletBlobURL(); + await this.audioContext.audioWorklet.addModule(this.workletBlobURL); + this.workletReady = true; + this.logger?.info('AudioWorklet initialized successfully using blob URL'); + } catch (error) { + this.logger?.info('AudioWorklet not available, using ScriptProcessor fallback'); + this.workletReady = false; + // Clean up blob URL if it was created + if (this.workletBlobURL) { + revokeAudioWorkletBlobURL(this.workletBlobURL); + this.workletBlobURL = null; + } + } + } else { + // AudioWorklet not supported + this.workletReady = false; + } + + // Create visualizer if container provided + if (this.options.visualizerContainer) { + if (this.options.useSimpleVisualizer) { + this.visualizer = new SimpleAudioLevelIndicator(this.options.visualizerContainer); + } else { + this.visualizer = new AudioVisualizer(this.options.visualizerContainer); + } + } + + } + + async startRecording(): Promise { + this.throwIfDisposed(); + if (this.isRecording) { + return; + } + + try { + // Log recording session start + this.logger?.info('Starting recording session', { + sampleRate: AUDIO_CONSTANTS.SAMPLE_RATE, + channelCount: DEFAULT_AUDIO_SETTINGS.channelCount, + maxDuration: this.options.maxRecordingSeconds || AUDIO_CONSTANTS.MAX_RECORDING_SECONDS, + vadEnabled: this.options.useVAD !== false, + vadMode: this.options.useVAD !== false ? (this.options.vadMode || DEFAULT_VAD_SETTINGS.mode) : DEFAULT_VAD_SETTINGS.mode + }); + + // Notify initialization started + if (this.options.onMicrophoneStatusChange) { + this.options.onMicrophoneStatusChange('initializing'); + } + + // Get microphone access with optimized settings for transcription + this.stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: AUDIO_CONSTANTS.SAMPLE_RATE, // Optimal for speech recognition + channelCount: DEFAULT_AUDIO_SETTINGS.channelCount, // Mono audio + echoCancellation: DEFAULT_AUDIO_SETTINGS.echoCancellation, + noiseSuppression: DEFAULT_AUDIO_SETTINGS.noiseSuppression, + autoGainControl: DEFAULT_AUDIO_SETTINGS.autoGainControl + } + }); + + // Monitor stream tracks for mute state + const audioTrack = this.stream.getAudioTracks()[0]; + if (audioTrack) { + // Check if track is initially muted (Windows often starts muted) + if (audioTrack.muted) { + this.logger?.warn('Microphone track is muted, waiting for unmute...'); + } + + // Listen for mute state changes + audioTrack.addEventListener('mute', () => { + this.logger?.debug('Microphone muted'); + this.microphoneReady = false; + }); + + audioTrack.addEventListener('unmute', () => { + this.logger?.debug('Microphone unmuted'); + if (!this.microphoneReady && this.audioDataReceived) { + this.microphoneReady = true; + if (this.options.onMicrophoneStatusChange) { + this.options.onMicrophoneStatusChange('ready'); + } + } + }); + } + + // Setup audio processing chain with filters + this.sourceNode = this.audioContext!.createMediaStreamSource(this.stream); + this.gainNode = this.audioContext!.createGain(); + this.gainNode.gain.value = DEFAULT_AUDIO_SETTINGS.gain; // デフォルトゲイン + + // Create BiquadFilters for band-limiting + this.highPassFilter = this.audioContext!.createBiquadFilter(); + this.highPassFilter.type = 'highpass'; + this.highPassFilter.frequency.value = AUDIO_CONSTANTS.FILTERS.HIGH_PASS_FREQ; // HPF - 低域ノイズ除去 + + this.lowPassFilter = this.audioContext!.createBiquadFilter(); + this.lowPassFilter.type = 'lowpass'; + this.lowPassFilter.frequency.value = AUDIO_CONSTANTS.FILTERS.LOW_PASS_FREQ; // LPF - 音声帯域に制限 + + this.analyserNode = this.audioContext!.createAnalyser(); + + // Connect: source -> gain -> HPF -> LPF -> analyser + this.sourceNode.connect(this.gainNode); + this.gainNode.connect(this.highPassFilter); + this.highPassFilter.connect(this.lowPassFilter); + this.lowPassFilter.connect(this.analyserNode); + + // Connect visualizer + if (this.visualizer) { + this.visualizer.connect(this.analyserNode); + this.visualizer.start(); + } + + // Setup MediaRecorder + this.mediaRecorder = new MediaRecorder(this.stream, { + mimeType: AUDIO_CONSTANTS.WEBM_CODEC + }); + + this.chunks = []; + this.continuousAudioData = []; + this.isRecording = true; + this.recordingStartTime = Date.now(); + + // Set up maximum recording time limit + const maxSeconds = this.options.maxRecordingSeconds || AUDIO_CONSTANTS.MAX_RECORDING_SECONDS; + this.maxRecordingTimer = setTimeout(async () => { + this.logger?.info(`Maximum recording time (${maxSeconds}s) reached, stopping recording`); + const audioBlob = await this.stopRecording(); + if (audioBlob && this.options.onSpeechEnd) { + this.options.onSpeechEnd(audioBlob); + } + }, maxSeconds * 1000); + + this.mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + this.chunks.push(event.data); + } + }; + + this.mediaRecorder.start(AUDIO_CONSTANTS.RECORDER_TIMESLICE); // Collect data at configured interval + + // Start continuous audio processing for VAD + this.startContinuousProcessing(); + + this.logger?.debug('Recording started successfully', { + mimeType: AUDIO_CONSTANTS.WEBM_CODEC, + timeslice: AUDIO_CONSTANTS.RECORDER_TIMESLICE + }); + + } catch (error) { + this.logger?.error('Failed to start recording', error); + throw error; + } + } + + private async startContinuousProcessing(): Promise { + if (!this.audioContext || !this.sourceNode) return; + + if (this.workletReady) { + // Use AudioWorklet (preferred) + try { + this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor-worklet', { + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 1 + }); + + // Configure worklet + this.workletNode.port.postMessage({ + type: 'configure', + bufferSize: AUDIO_CONSTANTS.BUFFER_SIZE + }); + + // Start recording + this.workletNode.port.postMessage({ type: 'start' }); + + // Handle audio data from worklet + this.workletNode.port.onmessage = async (event) => { + if (event.data.type === 'audio' && this.isRecording) { + await this.processAudioData(event.data.data); + } + }; + + // Connect nodes: lowPassFilter (end of chain) -> worklet + this.lowPassFilter!.connect(this.workletNode); + this.workletNode.connect(this.audioContext.destination); + } catch (error) { + this.logger?.warn('Failed to use AudioWorklet, falling back to ScriptProcessor', error); + this.workletReady = false; + this.startLegacyProcessing(); + } + } else { + // Fallback to ScriptProcessor + this.startLegacyProcessing(); + } + } + + private startLegacyProcessing(): void { + if (!this.audioContext || !this.sourceNode) return; + + const bufferSize = AUDIO_CONSTANTS.BUFFER_SIZE; + const scriptProcessor = this.audioContext.createScriptProcessor(bufferSize, 1, 1); + + this.lowPassFilter!.connect(scriptProcessor); + scriptProcessor.connect(this.audioContext.destination); + + scriptProcessor.onaudioprocess = async (event) => { + if (!this.isRecording) return; + + const inputData = event.inputBuffer.getChannelData(0); + const audioData = new Float32Array(inputData); + await this.processAudioData(audioData); + }; + } + + private async processAudioData(audioData: Float32Array): Promise { + // Early return if not recording to prevent unnecessary processing + if (!this.isRecording) { + return; + } + + // Write to ring buffer to prevent unlimited memory growth + this.audioRingBuffer.write(audioData); + + // Check for actual audio data to confirm microphone is working + const audioLevel = Math.max(...audioData.map(Math.abs)); + + // If we haven't marked microphone as ready yet, check for non-zero audio data + if (!this.microphoneReady && audioLevel > AUDIO_CONSTANTS.MIC_DETECTION_THRESHOLD) { + this.audioDataReceived = true; + const audioTrack = this.stream?.getAudioTracks()[0]; + + // Check if track is not muted and we have actual audio data + if (audioTrack && !audioTrack.muted) { + this.microphoneReady = true; + this.logger?.info('Microphone is now ready - audio data detected'); + if (this.options.onMicrophoneStatusChange) { + this.options.onMicrophoneStatusChange('ready'); + } + } + } + + + if (this.options.useVAD) { + // VADモード: 音声区間を自動検出 + this.continuousAudioData.push(audioData); + + // Accumulate audio data for VAD processing + if (this.continuousAudioData.length >= VAD_CONSTANTS.VAD_ACCUMULATION_THRESHOLD) { // Process when enough frames accumulated + const combinedData = this.combineAudioData(this.continuousAudioData); + this.continuousAudioData = []; + + + // Process with VAD + const segments = this.vadProcessor ? await this.vadProcessor.detectSpeechSegments(combinedData) : []; + + if (segments.length > 0) { + this.handleSpeechDetected(); + } else { + this.handleSilenceDetected(); + } + } + } else { + // 連続録音モード: 常に録音中として扱う + this.lastSpeechTime = Date.now(); + } + + // Log buffer usage periodically + if (Math.random() < AUDIO_CONSTANTS.BUFFER_STATS_LOG_PROBABILITY) { // Log periodically + const stats = this.audioRingBuffer.getStats(); + if (stats.percentage > AUDIO_CONSTANTS.BUFFER_WARNING_THRESHOLD) { + this.logger?.warn(`Audio buffer usage high: ${stats.percentage.toFixed(1)}%`); + } + } + } + + private combineAudioData(arrays: Float32Array[]): Float32Array { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Float32Array(totalLength); + let offset = 0; + + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + + return result; + } + + private handleSpeechDetected(): void { + this.lastSpeechTime = Date.now(); + + if (this.silenceTimer) { + clearTimeout(this.silenceTimer); + this.silenceTimer = null; + } + + if (this.visualizer) { + this.visualizer.setVADStatus('speech'); + } + + if (this.options.useVAD) { + this.options.onVADStatusChange('speech'); + } + } + + private handleSilenceDetected(): void { + if (this.visualizer) { + this.visualizer.setVADStatus('silence'); + } + + if (this.options.useVAD) { + this.options.onVADStatusChange('silence'); + } + + // Check if we should auto-stop due to silence (only in VAD mode) + if (this.options.useVAD && this.lastSpeechTime > 0 && !this.silenceTimer) { + this.silenceTimer = setTimeout(async () => { + if (this.isRecording) { + const audioBlob = await this.stopRecording(); + if (audioBlob && this.options.onSpeechEnd) { + this.options.onSpeechEnd(audioBlob); + } + } + }, this.options.useVAD ? this.options.autoStopSilenceDuration! : 0); + } + } + + async stopRecording(): Promise { + this.throwIfDisposed(); + if (!this.isRecording || !this.mediaRecorder) { + return null; + } + + // Clear maximum recording timer + if (this.maxRecordingTimer) { + clearTimeout(this.maxRecordingTimer); + this.maxRecordingTimer = null; + } + + // Log recording session end + if (this.recordingStartTime) { + const duration = (Date.now() - this.recordingStartTime) / 1000; + this.logger?.info('Recording session ended', { + duration: duration.toFixed(1), + chunksCount: this.chunks.length, + totalAudioData: this.continuousAudioData.length + }); + } + + this.isRecording = false; + + return new Promise((resolve) => { + this.mediaRecorder!.onstop = () => { + const audioBlob = new Blob(this.chunks, { type: 'audio/webm' }); + + this.cleanup(); + + // Do not call onSpeechEnd here - it will be called by specific stop handlers + + resolve(audioBlob); + }; + + this.mediaRecorder!.stop(); + }); + } + + private cleanup(): void { + if (this.silenceTimer) { + clearTimeout(this.silenceTimer); + this.silenceTimer = null; + } + + if (this.maxRecordingTimer) { + clearTimeout(this.maxRecordingTimer); + this.maxRecordingTimer = null; + } + + if (this.workletNode) { + // Clear event handlers first + if (this.workletNode.port) { + this.workletNode.port.onmessage = null; + } + this.workletNode.port.postMessage({ type: 'stop' }); + this.workletNode.disconnect(); + this.workletNode = null; + } + + if (this.visualizer) { + this.visualizer.stop(); + } + + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + this.stream = null; + } + + if (this.sourceNode) { + this.sourceNode.disconnect(); + this.sourceNode = null; + } + + if (this.gainNode) { + this.gainNode.disconnect(); + this.gainNode = null; + } + + if (this.highPassFilter) { + this.highPassFilter.disconnect(); + this.highPassFilter = null; + } + + if (this.lowPassFilter) { + this.lowPassFilter.disconnect(); + this.lowPassFilter = null; + } + + if (this.analyserNode) { + this.analyserNode.disconnect(); + this.analyserNode = null; + } + + this.chunks = []; + this.continuousAudioData = []; + this.lastSpeechTime = 0; + this.audioRingBuffer.clear(); + this.microphoneReady = false; + this.audioDataReceived = false; + } + + isActive(): boolean { + return this.isRecording; + } + + isMicrophoneReady(): boolean { + return this.microphoneReady; + } + + setAudioGain(gain: number): void { + if (this.gainNode) { + this.gainNode.gain.value = gain; + } + } + + /** + * Disposableパターンの実装 + */ + protected onDispose(): void { + this.cleanup(); + + if (this.visualizer) { + this.visualizer.destroy(); + this.visualizer = null; + } + + if (this.audioContext) { + this.audioContext.close(); + this.audioContext = null; + } + + // Clean up blob URL + if (this.workletBlobURL) { + revokeAudioWorkletBlobURL(this.workletBlobURL); + this.workletBlobURL = null; + } + + if (this.vadProcessor) { + this.vadProcessor.destroy(); + this.vadProcessor = null; + } + + // Dispose all registered disposables + this.disposables.dispose(); + } + + /** + * 互換性のためのdestroyメソッド + * @deprecated dispose()を使用してください + */ + destroy(): void { + this.dispose(); + } + + setAutoStopDuration(duration: number): void { + if (this.options.useVAD) { + this.options.autoStopSilenceDuration = duration; + } + } +} \ No newline at end of file diff --git a/src/core/audio/AudioRingBuffer.ts b/src/core/audio/AudioRingBuffer.ts new file mode 100644 index 0000000..0efe08a --- /dev/null +++ b/src/core/audio/AudioRingBuffer.ts @@ -0,0 +1,114 @@ +import { AUDIO_CONSTANTS } from '../../config'; + +/** + * Ring buffer implementation for efficient audio data management + * Prevents memory leaks by limiting buffer size + */ +export class AudioRingBuffer { + private buffer: Float32Array; + private writePos: number = 0; + private readPos: number = 0; + private maxSamples: number; + private filled: boolean = false; + + constructor(maxDurationSeconds: number = AUDIO_CONSTANTS.DEFAULT_RING_BUFFER_SECONDS, sampleRate: number = AUDIO_CONSTANTS.SAMPLE_RATE) { + // Limit to maximum duration to prevent excessive memory usage + const maxAllowedSeconds = AUDIO_CONSTANTS.MAX_RECORDING_SECONDS; + const maxAllowedSamples = maxAllowedSeconds * sampleRate; + this.maxSamples = Math.min(maxDurationSeconds * sampleRate, maxAllowedSamples); + this.buffer = new Float32Array(this.maxSamples); + } + + /** + * Write audio data to the ring buffer + */ + write(data: Float32Array): void { + const dataLength = data.length; + + // If data is larger than buffer, only keep the latest samples + if (dataLength >= this.maxSamples) { + this.buffer.set(data.slice(dataLength - this.maxSamples)); + this.writePos = 0; + this.readPos = 0; + this.filled = true; + return; + } + + // Calculate available space + const remainingSpace = this.maxSamples - this.writePos; + + if (dataLength <= remainingSpace) { + // Data fits in remaining space + this.buffer.set(data, this.writePos); + this.writePos += dataLength; + } else { + // Data wraps around + this.buffer.set(data.slice(0, remainingSpace), this.writePos); + this.buffer.set(data.slice(remainingSpace), 0); + this.writePos = dataLength - remainingSpace; + this.filled = true; + } + + // Update read position if we've wrapped around + if (this.filled && this.writePos >= this.readPos) { + this.readPos = this.writePos; + } + } + + /** + * Read all available data from the buffer + */ + read(): Float32Array { + const availableSamples = this.getAvailableSamples(); + if (availableSamples === 0) { + return new Float32Array(0); + } + + const result = new Float32Array(availableSamples); + + if (this.filled) { + // Buffer has wrapped, read from readPos to end, then from start to writePos + const endSamples = this.maxSamples - this.readPos; + result.set(this.buffer.slice(this.readPos, this.maxSamples), 0); + result.set(this.buffer.slice(0, this.writePos), endSamples); + } else { + // Buffer hasn't wrapped, simple read + result.set(this.buffer.slice(0, this.writePos), 0); + } + + // Reset after reading + this.clear(); + + return result; + } + + /** + * Get the number of available samples + */ + getAvailableSamples(): number { + if (!this.filled) { + return this.writePos; + } + return this.maxSamples; + } + + /** + * Clear the buffer + */ + clear(): void { + this.writePos = 0; + this.readPos = 0; + this.filled = false; + } + + /** + * Get buffer statistics + */ + getStats(): { used: number; total: number; percentage: number } { + const used = this.getAvailableSamples(); + const total = this.maxSamples; + const percentage = (used / total) * 100; + + return { used, total, percentage }; + } +} \ No newline at end of file diff --git a/src/core/audio/AudioVisualizer.ts b/src/core/audio/AudioVisualizer.ts new file mode 100644 index 0000000..1078b9d --- /dev/null +++ b/src/core/audio/AudioVisualizer.ts @@ -0,0 +1,308 @@ +import { VISUALIZATION_CONSTANTS } from '../../config'; +import { Disposable } from '../../interfaces'; + +export class AudioVisualizer extends Disposable { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private analyser: AnalyserNode | null = null; + private dataArray: Uint8Array | null = null; + private isRecording: boolean = false; + private animationId: number | null = null; + private vadStatus: 'silence' | 'speech' = 'silence'; + private vadThreshold: number = VISUALIZATION_CONSTANTS.DEFAULT_VAD_THRESHOLD; + private currentLevel: number = 0; + private waveformHistory: number[] = []; // 時系列波形データ + private maxHistoryLength: number = VISUALIZATION_CONSTANTS.MAX_HISTORY_LENGTH; + + constructor(container: HTMLElement) { + super(); + this.canvas = document.createElement('canvas'); + this.canvas.className = 'audio-visualizer-canvas'; + this.canvas.width = 200; + this.canvas.height = 56; + container.appendChild(this.canvas); + + const context = this.canvas.getContext('2d'); + if (!context) { + throw new Error('Failed to get canvas context'); + } + this.ctx = context; + + this.drawEmptyGraph(); + } + + connect(analyser: AnalyserNode): void { + this.analyser = analyser; + this.analyser.fftSize = 2048; + const bufferLength = this.analyser.fftSize; + this.dataArray = new Uint8Array(bufferLength); + } + + start(): void { + this.isRecording = true; + this.draw(); + } + + stop(): void { + this.isRecording = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + this.clear(); + } + + setVADStatus(status: 'silence' | 'speech'): void { + this.vadStatus = status; + } + + setVADThreshold(threshold: number): void { + this.vadThreshold = threshold; + } + + private draw(): void { + if (!this.isRecording || !this.analyser || !this.dataArray) { + return; + } + + this.animationId = requestAnimationFrame(() => this.draw()); + + // Get time domain data (waveform) + this.analyser.getByteTimeDomainData(this.dataArray); + + // Calculate RMS (Root Mean Square) for current audio level + let sum = 0; + for (let i = 0; i < this.dataArray.length; i++) { + const normalized = (this.dataArray[i] - 128) / 128; + sum += normalized * normalized; + } + const rms = Math.sqrt(sum / this.dataArray.length); + this.currentLevel = rms; + + // Apply scaling factor to make waveform more visible + // RMS values are typically 0.0-0.3, so scale up by 3-5x + const scalingFactor = 4.0; + const scaledRms = Math.min(rms * scalingFactor, 1.0); // Cap at 1.0 + + // Add to history + this.waveformHistory.push(scaledRms); + if (this.waveformHistory.length > this.maxHistoryLength) { + this.waveformHistory.shift(); + } + + // Clear canvas + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // Draw waveform + this.drawWaveform(); + + // Draw center line + this.drawCenterLine(); + } + + private drawWaveform(): void { + if (this.waveformHistory.length < 2) return; + + const width = this.canvas.width; + const height = this.canvas.height; + const centerY = height / 2; + const maxAmplitude = height / 2 - 2; + + // Determine if current level is above threshold + const isAboveThreshold = this.currentLevel > this.vadThreshold; + + // Set stroke style + this.ctx.strokeStyle = isAboveThreshold ? '#40c057' : '#868e96'; + this.ctx.lineWidth = 2; + + // Draw positive waveform + this.drawWaveformPath(width, centerY, maxAmplitude, false); + this.ctx.stroke(); + + // Draw negative waveform (mirror) + this.drawWaveformPath(width, centerY, maxAmplitude, true); + this.ctx.stroke(); + + // Fill area if above threshold + if (isAboveThreshold) { + this.ctx.fillStyle = 'rgba(64, 192, 87, 0.1)'; + this.drawWaveformPath(width, centerY, maxAmplitude, false); + // Complete the path + for (let i = this.waveformHistory.length - 1; i >= 0; i--) { + const x = (i / (this.maxHistoryLength - 1)) * width; + const amplitude = this.waveformHistory[i] * maxAmplitude; + const y = centerY + amplitude; + this.ctx.lineTo(x, y); + } + this.ctx.closePath(); + this.ctx.fill(); + } + } + + private drawCenterLine(): void { + const centerY = this.canvas.height / 2; + + this.ctx.strokeStyle = '#dee2e6'; + this.ctx.lineWidth = 1; + this.ctx.setLineDash([5, 5]); + + this.ctx.beginPath(); + this.ctx.moveTo(0, centerY); + this.ctx.lineTo(this.canvas.width, centerY); + this.ctx.stroke(); + + this.ctx.setLineDash([]); + } + + + /** + * Draw waveform path + */ + private drawWaveformPath(width: number, centerY: number, maxAmplitude: number, isMirrored: boolean): void { + this.ctx.beginPath(); + for (let i = 0; i < this.waveformHistory.length; i++) { + const x = (i / (this.maxHistoryLength - 1)) * width; + const amplitude = this.waveformHistory[i] * maxAmplitude; + const y = centerY + (isMirrored ? amplitude : -amplitude); + + if (i === 0) { + this.ctx.moveTo(x, y); + } else { + this.ctx.lineTo(x, y); + } + } + } + + private clear(): void { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.waveformHistory = []; // 波形履歴もクリア + this.drawEmptyGraph(); + } + + private drawEmptyGraph(): void { + // Clear canvas + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // Draw center line + this.drawCenterLine(); + + // Draw flat waveform line + const centerY = this.canvas.height / 2; + this.ctx.strokeStyle = '#868e96'; + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(0, centerY); + this.ctx.lineTo(this.canvas.width, centerY); + this.ctx.stroke(); + } + + /** + * Disposableパターンの実装 + */ + protected onDispose(): void { + this.stop(); + if (this.canvas.parentNode) { + this.canvas.parentNode.removeChild(this.canvas); + } + } + + /** + * 互換性のためのdestroyメソッド + * @deprecated dispose()を使用してください + */ + destroy(): void { + this.dispose(); + } +} + +export class SimpleAudioLevelIndicator { + private container: HTMLElement; + private levelBar: HTMLElement; + private vadIndicator: HTMLElement; + private analyser: AnalyserNode | null = null; + private dataArray: Uint8Array | null = null; + private animationId: number | null = null; + private isActive: boolean = false; + + constructor(container: HTMLElement) { + this.container = container; + this.createUI(); + } + + private createUI(): void { + const wrapper = document.createElement('div'); + wrapper.className = 'audio-level-indicator'; + + // Level bar + const levelContainer = document.createElement('div'); + levelContainer.className = 'audio-level-container'; + + this.levelBar = document.createElement('div'); + this.levelBar.className = 'audio-level-bar'; + + levelContainer.appendChild(this.levelBar); + + // VAD indicator + this.vadIndicator = document.createElement('div'); + this.vadIndicator.className = 'audio-vad-indicator'; + + wrapper.appendChild(levelContainer); + wrapper.appendChild(this.vadIndicator); + this.container.appendChild(wrapper); + } + + connect(analyser: AnalyserNode): void { + this.analyser = analyser; + this.analyser.fftSize = 256; + this.dataArray = new Uint8Array(this.analyser.frequencyBinCount); + } + + start(): void { + this.isActive = true; + this.update(); + } + + stop(): void { + this.isActive = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + this.levelBar.style.setProperty('--audio-level-width', '0%'); + } + + setVADStatus(status: 'silence' | 'speech'): void { + if (status === 'speech') { + this.vadIndicator.classList.add('speech'); + } else { + this.vadIndicator.classList.remove('speech'); + } + } + + private update(): void { + if (!this.isActive || !this.analyser || !this.dataArray) { + return; + } + + this.animationId = requestAnimationFrame(() => this.update()); + + this.analyser.getByteFrequencyData(this.dataArray); + + // Calculate average level with emphasis on lower frequencies (voice range) + let sum = 0; + const relevantBins = Math.min(this.dataArray.length / 4, 50); // Focus on voice frequencies + for (let i = 0; i < relevantBins; i++) { + sum += this.dataArray[i]; + } + const average = sum / relevantBins; + // Apply scaling for better visibility + const percentage = Math.min((average / 128) * 100, 100); + + this.levelBar.style.setProperty('--audio-level-width', `${percentage}%`); + } + + destroy(): void { + this.stop(); + this.container.empty(); + } +} \ No newline at end of file diff --git a/src/core/audio/AudioWorkletSource.ts b/src/core/audio/AudioWorkletSource.ts new file mode 100644 index 0000000..da7cdd8 --- /dev/null +++ b/src/core/audio/AudioWorkletSource.ts @@ -0,0 +1,123 @@ +/** + * AudioWorklet source code as a string + * This allows us to create a blob URL for loading in restricted environments + */ + +export const AUDIO_WORKLET_SOURCE = ` +// AudioWorkletProcessor for real-time audio processing +class AudioProcessorWorklet extends AudioWorkletProcessor { + constructor() { + super(); + + this.bufferSize = 4096; + this.audioBuffer = []; + this.isRecording = false; + + // Listen for messages from main thread + this.port.onmessage = (event) => { + this.handleMessage(event.data); + }; + } + + /** + * Handle messages from main thread + */ + handleMessage(data) { + switch (data.type) { + case 'start': + this.isRecording = true; + this.audioBuffer = []; + break; + + case 'stop': + this.isRecording = false; + // Send any remaining buffered audio + if (this.audioBuffer.length > 0) { + this.sendAudioData(); + } + break; + + case 'configure': + if (data.bufferSize) { + this.bufferSize = data.bufferSize; + } + break; + } + } + + /** + * Process audio - called for each render quantum (128 samples) + */ + process(inputs, outputs, parameters) { + const input = inputs[0]; + + if (!this.isRecording || !input || input.length === 0) { + return true; // Keep processor alive + } + + // Get the first channel + const channelData = input[0]; + if (!channelData || channelData.length === 0) { + return true; + } + + // Buffer the audio data + this.audioBuffer.push(new Float32Array(channelData)); + + // Check if we've accumulated enough data + const totalSamples = this.audioBuffer.reduce((sum, buffer) => sum + buffer.length, 0); + if (totalSamples >= this.bufferSize) { + this.sendAudioData(); + } + + return true; // Keep processor alive + } + + /** + * Send accumulated audio data to main thread + */ + sendAudioData() { + if (this.audioBuffer.length === 0) { + return; + } + + // Combine all buffers into one + const totalLength = this.audioBuffer.reduce((sum, buffer) => sum + buffer.length, 0); + const combinedBuffer = new Float32Array(totalLength); + + let offset = 0; + for (const buffer of this.audioBuffer) { + combinedBuffer.set(buffer, offset); + offset += buffer.length; + } + + // Send to main thread + this.port.postMessage({ + type: 'audio', + data: combinedBuffer, + timestamp: currentTime + }); + + // Clear the buffer + this.audioBuffer = []; + } +} + +// Register the processor +registerProcessor('audio-processor-worklet', AudioProcessorWorklet); +`; + +/** + * Create a blob URL for the AudioWorklet + */ +export function createAudioWorkletBlobURL(): string { + const blob = new Blob([AUDIO_WORKLET_SOURCE], { type: 'application/javascript' }); + return URL.createObjectURL(blob); +} + +/** + * Clean up blob URL when no longer needed + */ +export function revokeAudioWorkletBlobURL(url: string): void { + URL.revokeObjectURL(url); +} \ No newline at end of file diff --git a/src/core/audio/VADProcessor.ts b/src/core/audio/VADProcessor.ts new file mode 100644 index 0000000..ab71395 --- /dev/null +++ b/src/core/audio/VADProcessor.ts @@ -0,0 +1,521 @@ +import { App, FileSystemAdapter, normalizePath } from 'obsidian'; +import { SpeechSegment } from '../../interfaces'; +import { VAD_CONSTANTS, DEFAULT_VAD_SETTINGS, FILE_CONSTANTS } from '../../config'; +import { Disposable } from '../../interfaces'; +import { FvadModule, FvadModuleFactory, WindowWithFvad, hasFvadModule } from '../../types'; +import { createServiceLogger } from '../../services'; +import { Logger } from '../../utils'; + +export interface VADSegment { + start: number; + end: number; + audioData: Float32Array; +} + +export interface VADProcessorOptions { + vadMode?: 0 | 1 | 2 | 3; + minSpeechDuration?: number; + minSilenceDuration?: number; + speechPadding?: number; +} + +// WebRTC VAD の型定義は別ファイルに移動 + +/** + * WebRTC VAD プロセッサー + * Google WebRTC プロジェクトの VAD アルゴリズムを使用した高精度な音声検出 + * + * CLAUDE.md の哲学に従った実装: + * - 根本的な解決: 実証済みの WebRTC VAD を使用 + * - 汎用的な設計: 将来の拡張を考慮した interface + * - 型安全性: TypeScript の型システムを活用 + * - エラーハンドリング: 適切なエラー処理とリソース管理 + */ +export class VADProcessor extends Disposable { + private fvadModule: FvadModule | null = null; + private vadInstance: number | null = null; + private bufferPtr: number | null = null; + private readonly frameSize: number = VAD_CONSTANTS.FRAME_SIZE; // 30ms at 16kHz (WebRTC VAD requirement) + private readonly sampleRate: number = VAD_CONSTANTS.SAMPLE_RATE; // WebRTC VAD requires 16kHz + private logger: Logger; + + // 設定可能なパラメータ + private vadMode: 0 | 1 | 2 | 3; + private minSpeechDuration: number; // ms + private minSilenceDuration: number; // ms + private speechPadding: number; // ms + + // 自動調整用パラメータ + private autoAdjust: boolean = DEFAULT_VAD_SETTINGS.autoAdjust; + private noiseLevel: number = 0; + private recentFalsePositives: number = 0; + private recentFalseNegatives: number = 0; + private adjustmentCounter: number = 0; + + constructor( + private app: App, + options: VADProcessorOptions = {} + ) { + super(); + this.logger = createServiceLogger('VADProcessor'); + this.vadMode = options.vadMode ?? DEFAULT_VAD_SETTINGS.mode; + this.minSpeechDuration = options.minSpeechDuration ?? DEFAULT_VAD_SETTINGS.minSpeechDuration; + this.minSilenceDuration = options.minSilenceDuration ?? DEFAULT_VAD_SETTINGS.minSilenceDuration; + this.speechPadding = options.speechPadding ?? DEFAULT_VAD_SETTINGS.speechPadding; + } + + async initialize(): Promise { + this.throwIfDisposed(); + + // App インスタンスの検証 + if (!this.app) { + throw new Error('App instance is required for WebRTC VAD'); + } + + try { + this.logger.info('Initializing WebRTC VAD processor', { + vadMode: this.vadMode, + minSpeechDuration: this.minSpeechDuration, + minSilenceDuration: this.minSilenceDuration, + frameSize: this.frameSize, + sampleRate: this.sampleRate + }); + + // WASM ファイルを読み込む + const wasmBuffer = await this.loadWasmFile(); + this.logger.debug('WASM file loaded', { size: wasmBuffer.byteLength }); + + // fvad モジュールを初期化 + await this.initializeFvadModule(wasmBuffer); + + // VAD インスタンスを作成して設定 + await this.createAndConfigureVAD(); + + this.logger.info('WebRTC VAD processor initialized successfully'); + + } catch (error) { + this.logger.error('WebRTC VAD initialization error', error); + // リソースをクリーンアップ + this.destroy(); + throw error; + } + } + + /** + * fvad モジュールを初期化 + */ + private async initializeFvadModule(wasmBuffer: ArrayBuffer): Promise { + // スクリプトタグを使用して fvad.js を読み込む + // これは Obsidian の制限された環境でモジュールを読み込む最も確実な方法 + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) { + throw new Error('WebRTC VAD requires FileSystemAdapter (desktop version)'); + } + + // プラグインID(manifest.json から) + const pluginId = FILE_CONSTANTS.PLUGIN_ID; + const basePath = normalizePath(`${this.app.vault.configDir}/plugins/${pluginId}`); + + // グローバルオブジェクトを準備(型安全に) + const globalWindow = window as WindowWithFvad; + + // fvad.js の内容を読み込んで評価(プラグインルートから) + const fvadJsPath = normalizePath(`${basePath}/fvad.js`); + const fvadJsContent = await adapter.read(fvadJsPath); + + // モジュールを評価するための一時的な環境を作成 + return new Promise((resolve, reject) => { + // スクリプトタグを作成 + const script = document.createElement('script'); + script.type = 'module'; + script.textContent = ` + // import.meta.url のポリフィル + const importMeta = { url: 'file:///${fvadJsPath}' }; + + // fvad モジュールを定義 + ${fvadJsContent} + + // グローバルに公開 + window.__fvadModule = fvad; + `; + + // エラーハンドリング + script.onerror = (error) => { + this.logger.error('WebRTC VAD script loading error', error); + reject(new Error('Failed to load fvad.js')); + }; + + // スクリプトを実行 + document.head.appendChild(script); + + // モジュールが読み込まれるのを待つ + setTimeout(async () => { + try { + if (!hasFvadModule(globalWindow)) { + throw new Error('fvad module not found in global scope'); + } + + const createModule = globalWindow.__fvadModule; + this.logger.debug('fvad module loaded from global scope'); + + // WebAssembly モジュールを初期化 + this.fvadModule = await createModule!({ + wasmBinary: new Uint8Array(wasmBuffer), + instantiateWasm: ( + imports: WebAssembly.Imports, + successCallback: (instance: WebAssembly.Instance) => void + ) => { + WebAssembly.instantiate(new Uint8Array(wasmBuffer), imports) + .then((result) => { + successCallback(result.instance); + }) + .catch((error) => { + this.logger.error('WebRTC VAD WASM instantiation error', error); + reject(error); + }); + return {}; + } + }); + + // クリーンアップ + document.head.removeChild(script); + delete globalWindow.__fvadModule; + + resolve(); + } catch (error) { + reject(error); + } + }, 100); // モジュール読み込みを待つ + }); + } + + /** + * VAD インスタンスを作成して設定 + */ + private async createAndConfigureVAD(): Promise { + if (!this.fvadModule) { + throw new Error('fvad module not initialized'); + } + + // VAD インスタンスを作成 + this.vadInstance = this.fvadModule._fvad_new(); + if (!this.vadInstance) { + throw new Error('Failed to create VAD instance'); + } + this.logger.debug('VAD instance created'); + + // サンプルレートを設定(16kHz 固定) + const sampleRateResult = this.fvadModule._fvad_set_sample_rate(this.vadInstance, this.sampleRate); + if (sampleRateResult !== 0) { + throw new Error('Failed to set sample rate'); + } + + // VAD モードを設定 + const modeResult = this.fvadModule._fvad_set_mode(this.vadInstance, this.vadMode); + if (modeResult !== 0) { + throw new Error(`Failed to set VAD mode: ${this.vadMode}`); + } + + // 処理用バッファを事前確保 + this.bufferPtr = this.fvadModule._malloc(this.frameSize * 2); // 2 bytes per sample (Int16) + if (!this.bufferPtr) { + throw new Error('Failed to allocate buffer'); + } + } + + /** + * WASM ファイルを読み込む + */ + private async loadWasmFile(): Promise { + const adapter = this.app.vault.adapter; + + // FileSystemAdapter が必要 + if (!(adapter instanceof FileSystemAdapter)) { + throw new Error('WebRTC VAD requires FileSystemAdapter (desktop version)'); + } + + // プラグインフォルダからの相対パス(プラグインルートにあるfvad.wasm) + const pluginId = FILE_CONSTANTS.PLUGIN_ID; + const wasmPath = normalizePath(`${this.app.vault.configDir}/plugins/${pluginId}/fvad.wasm`); + + // ファイルの存在確認 + const exists = await adapter.exists(wasmPath); + if (!exists) { + throw new Error(`WASM file not found: ${wasmPath}`); + } + + // バイナリとして読み込む + const wasmBuffer = await adapter.readBinary(wasmPath); + return wasmBuffer; + } + + /** + * ノイズレベルを分析して VAD モードを自動調整 + */ + private analyzeAndAdjustVAD(audioData: Float32Array): void { + if (!this.autoAdjust) return; + + // RMS (Root Mean Square) でノイズレベルを計算 + let sum = 0; + for (let i = 0; i < audioData.length; i++) { + sum += audioData[i] * audioData[i]; + } + const rms = Math.sqrt(sum / audioData.length); + + // 指数移動平均でノイズレベルを更新 + this.noiseLevel = this.noiseLevel * VAD_CONSTANTS.NOISE_LEVEL_EMA.PREVIOUS + rms * VAD_CONSTANTS.NOISE_LEVEL_EMA.CURRENT; + + // 調整カウンターをインクリメント + this.adjustmentCounter++; + if (this.adjustmentCounter >= VAD_CONSTANTS.ADJUSTMENT_INTERVAL) { + this.adjustmentCounter = 0; + + // ノイズレベルに基づいて VAD モードを調整 + if (this.noiseLevel > VAD_CONSTANTS.NOISE_THRESHOLDS.NOISY) { + // 騒音が多い環境 → 低感度 + this.vadMode = 0; + } else if (this.noiseLevel > VAD_CONSTANTS.NOISE_THRESHOLDS.NORMAL) { + // 普通の環境 → 標準感度 + this.vadMode = 2; + } else { + // 静かな環境 → 高感度 + this.vadMode = 3; + } + + // WebRTC VAD のモードを更新 + if (this.fvadModule && this.vadInstance) { + this.fvadModule._fvad_set_mode(this.vadInstance, this.vadMode); + } + } + } + + /** + * 音声セグメントを検出 + */ + async detectSpeechSegments(audioData: Float32Array): Promise { + this.throwIfDisposed(); + if (!this.fvadModule || !this.vadInstance || !this.bufferPtr) { + throw new Error('VAD not initialized'); + } + + // 自動調整を実行 + this.analyzeAndAdjustVAD(audioData); + + // 48kHz → 16kHz にリサンプリング(必要な場合) + const resampledAudio = this.resampleAudio(audioData, VAD_CONSTANTS.INPUT_SAMPLE_RATE, this.sampleRate); + this.logger.trace('Audio resampled', { + inputSampleRate: VAD_CONSTANTS.INPUT_SAMPLE_RATE, + outputSampleRate: this.sampleRate, + inputLength: audioData.length, + outputLength: resampledAudio.length + }); + + // Float32 → Int16 に変換 + const int16Audio = this.float32ToInt16(resampledAudio); + + // VAD でフレームごとに処理 + const frameDuration = (this.frameSize / this.sampleRate) * 1000; // ms + const frameCount = Math.floor(int16Audio.length / this.frameSize); + + // 音声区間の検出結果を格納 + const vadResults: boolean[] = new Array(frameCount); + + // フレームごとに VAD を実行 + for (let i = 0; i < frameCount; i++) { + const frameStart = i * this.frameSize; + const frame = int16Audio.slice(frameStart, frameStart + this.frameSize); + + // バッファにコピー + const bufferView = new Int16Array(this.fvadModule.HEAP16.buffer, this.bufferPtr, this.frameSize); + bufferView.set(frame); + + // VAD 判定(1 = 音声, 0 = 無音) + const isSpeech = this.fvadModule._fvad_process(this.vadInstance, this.bufferPtr, this.frameSize) === 1; + vadResults[i] = isSpeech; + } + + + // 後処理: セグメントの統合とフィルタリング + const processedSegments = this.postProcessSegments(vadResults, frameDuration, resampledAudio); + + this.logger.debug('VAD processing completed', { + inputDuration: (audioData.length / this.sampleRate * 1000).toFixed(1), + totalFrames: vadResults.length, + speechFrames: vadResults.filter(v => v).length, + segments: processedSegments.length + }); + + return processedSegments; + } + + /** + * セグメントの後処理 + * - 短い無音で分離されたセグメントを結合 + * - パディングを追加 + * - 最小セグメント長でフィルタ + */ + private postProcessSegments( + vadResults: boolean[], + frameDuration: number, + audioData: Float32Array + ): VADSegment[] { + const segments: VADSegment[] = []; + let currentSegment: { start: number; end: number } | null = null; + + const minSilenceFrames = Math.ceil(this.minSilenceDuration / frameDuration); + const minSpeechFrames = Math.ceil(this.minSpeechDuration / frameDuration); + const paddingFrames = Math.ceil(this.speechPadding / frameDuration); + + let speechFrames = 0; + let silenceFrames = 0; + + for (let i = 0; i < vadResults.length; i++) { + if (vadResults[i]) { + // 音声フレーム + speechFrames++; + silenceFrames = 0; + + if (!currentSegment && speechFrames >= minSpeechFrames) { + // 新しいセグメントの開始 + const startFrame = Math.max(0, i - speechFrames + 1 - paddingFrames); + currentSegment = { + start: startFrame * frameDuration, + end: 0 + }; + } + + if (currentSegment) { + // セグメントの終了時間を更新 + currentSegment.end = Math.min( + (i + 1 + paddingFrames) * frameDuration, + (audioData.length / this.sampleRate) * 1000 + ); + } + } else { + // 無音フレーム + silenceFrames++; + speechFrames = 0; + + if (currentSegment && silenceFrames >= minSilenceFrames) { + // セグメントの終了 + segments.push(this.createSegment(currentSegment, audioData)); + currentSegment = null; + } + } + } + + // 最後のセグメントを処理 + if (currentSegment) { + segments.push(this.createSegment(currentSegment, audioData)); + } + + return segments; + } + + /** + * Create a segment from current segment data + */ + private createSegment(currentSegment: { start: number; end: number }, audioData: Float32Array): VADSegment { + const startSample = Math.floor((currentSegment.start / 1000) * this.sampleRate); + const endSample = Math.floor((currentSegment.end / 1000) * this.sampleRate); + + return { + start: currentSegment.start, + end: currentSegment.end, + audioData: audioData.slice(startSample, endSample) + }; + } + + /** + * Float32 配列を Int16 配列に変換 + */ + private float32ToInt16(float32Array: Float32Array): Int16Array { + const int16Array = new Int16Array(float32Array.length); + for (let i = 0; i < float32Array.length; i++) { + // クリッピングを含む変換 + const s = Math.max(-1, Math.min(1, float32Array[i])); + int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; + } + return int16Array; + } + + /** + * オーディオデータをリサンプリング + */ + private resampleAudio(audioData: Float32Array, fromRate: number, toRate: number): Float32Array { + if (fromRate === toRate) { + return audioData; + } + + const ratio = fromRate / toRate; + const newLength = Math.floor(audioData.length / ratio); + const resampled = new Float32Array(newLength); + + // 線形補間によるリサンプリング + for (let i = 0; i < newLength; i++) { + const srcIndex = i * ratio; + const srcIndexInt = Math.floor(srcIndex); + const srcIndexFrac = srcIndex - srcIndexInt; + + if (srcIndexInt + 1 < audioData.length) { + // 線形補間 + resampled[i] = audioData[srcIndexInt] * (1 - srcIndexFrac) + + audioData[srcIndexInt + 1] * srcIndexFrac; + } else { + resampled[i] = audioData[srcIndexInt]; + } + } + + return resampled; + } + + /** + * VAD モードを設定 + */ + setMode(mode: 0 | 1 | 2 | 3): void { + this.vadMode = mode; + + if (this.fvadModule && this.vadInstance) { + const result = this.fvadModule._fvad_set_mode(this.vadInstance, mode); + if (result !== 0) { + this.logger.error(`Failed to set VAD mode (mode: ${mode}, result: ${result})`); + } + } + } + + /** + * 無音継続時間を設定 + */ + setSilenceDuration(duration: number): void { + this.minSilenceDuration = duration; + } + + /** + * Disposableパターンの実装 + */ + protected onDispose(): void { + if (this.fvadModule) { + // バッファを解放 + if (this.bufferPtr !== null) { + this.fvadModule._free(this.bufferPtr); + this.bufferPtr = null; + } + + // VAD インスタンスを解放 + if (this.vadInstance !== null) { + this.fvadModule._fvad_free(this.vadInstance); + this.vadInstance = null; + } + + this.fvadModule = null; + } + } + + /** + * 互換性のためのdestroyメソッド + * @deprecated dispose()を使用してください + */ + destroy(): void { + this.dispose(); + } +} \ No newline at end of file diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..7154787 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,10 @@ +// Audio components +export { AudioRecorder } from './audio/AudioRecorder'; +export { AudioRingBuffer } from './audio/AudioRingBuffer'; +export { AudioVisualizer } from './audio/AudioVisualizer'; +// export { AUDIO_WORKLET_SOURCE } from './audio/AudioWorkletSource'; // 未使用: 将来のAudioWorklet実装用 +export { VADProcessor } from './audio/VADProcessor'; + +// Transcription components +export { TranscriptionService } from './transcription/TranscriptionService'; +export { DictionaryCorrector } from './transcription/DictionaryCorrector'; diff --git a/src/core/transcription/DictionaryCorrector.ts b/src/core/transcription/DictionaryCorrector.ts new file mode 100644 index 0000000..272a6aa --- /dev/null +++ b/src/core/transcription/DictionaryCorrector.ts @@ -0,0 +1,141 @@ + +import { CorrectionRule, DictionaryCorrectorOptions, ITextCorrector, SimpleCorrectionDictionary, CorrectionEntry } from '../../interfaces'; +import { TranscriptionError, TranscriptionErrorType } from '../../errors'; +import { API_CONSTANTS, DICTIONARY_CONSTANTS } from '../../config'; +import { requestUrl } from 'obsidian'; + +/** + * 辞書修正設定 + * + * CLAUDE.mdの哲学に従った設計: + * - シンプルな固定置換のみをサポート + */ +export interface DictionarySettings { + enabled: boolean; + customRules: CorrectionRule[]; + correctionDictionary?: SimpleCorrectionDictionary; +} + +export class DictionaryCorrector implements ITextCorrector { + private settings: DictionarySettings; + private correctionDictionary: SimpleCorrectionDictionary; + + // Common Japanese transcription errors (empty by default - users can add their own) + private defaultRules: CorrectionRule[] = []; + + constructor(settings?: Partial) { + this.settings = { + enabled: true, + customRules: [], + ...settings + }; + // Initialize correction dictionary + this.correctionDictionary = settings?.correctionDictionary || { + definiteCorrections: [] + }; + } + + async correct(text: string): Promise { + if (!this.settings.enabled) { + return text; + } + + // 空文字または短すぎるテキストは処理しない + if (!text || text.trim().length < 2) { + return text; + } + + // Apply rule-based corrections + const correctedText = this.applyRules(text); + + return correctedText; + } + + private applyRules(text: string): string { + let result = text; + + // Apply default rules + for (const rule of this.defaultRules) { + result = this.applyRule(result, rule); + } + + // Apply custom rules + for (const rule of this.settings.customRules) { + result = this.applyRule(result, rule); + } + + // Apply dictionary corrections + for (const correction of this.correctionDictionary.definiteCorrections) { + // Handle multiple patterns per correction + for (const pattern of correction.from) { + if (result.includes(pattern)) { + result = result.replace( + new RegExp(this.escapeRegExp(pattern), 'g'), + correction.to + ); + } + } + } + + return result; + } + + private escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + private applyRule(text: string, rule: CorrectionRule): string { + if (typeof rule.pattern === 'string') { + const flags = rule.caseSensitive ? 'g' : 'gi'; + const regex = new RegExp(rule.pattern, flags); + return text.replace(regex, rule.replacement); + } else { + return text.replace(rule.pattern, rule.replacement); + } + } + + updateSettings(settings: Partial): void { + this.settings = { ...this.settings, ...settings }; + if (settings.correctionDictionary) { + this.correctionDictionary = { + ...this.correctionDictionary, + ...settings.correctionDictionary + }; + } + } + + /** + * Update correction dictionary + */ + updateCorrectionDictionary(dictionary: Partial): void { + this.correctionDictionary = { + ...this.correctionDictionary, + ...dictionary + }; + } + + /** + * Add custom correction entry + */ + addCorrectionEntry(entry: CorrectionEntry, contextual: boolean = false): void { + // Contextual corrections are not yet implemented + // For now, all corrections are added as definite corrections + this.correctionDictionary.definiteCorrections.push(entry); + } + + addCustomRule(rule: CorrectionRule): void { + this.settings.customRules.push(rule); + } + + removeCustomRule(index: number): void { + this.settings.customRules.splice(index, 1); + } + + getSettings(): DictionarySettings { + return { ...this.settings }; + } + + getDefaultRules(): CorrectionRule[] { + return [...this.defaultRules]; + } +} \ No newline at end of file diff --git a/src/core/transcription/TranscriptionService.ts b/src/core/transcription/TranscriptionService.ts new file mode 100644 index 0000000..e528dd8 --- /dev/null +++ b/src/core/transcription/TranscriptionService.ts @@ -0,0 +1,308 @@ +import { DictionaryCorrector } from './DictionaryCorrector'; +import { TranscriptionResult, ITranscriptionProvider, SimpleCorrectionDictionary } from '../../interfaces'; +import { TranscriptionError, TranscriptionErrorType } from '../../errors'; +import { SecurityUtils } from '../../security'; +import { API_CONSTANTS, DEFAULT_TRANSCRIPTION_SETTINGS, TRANSCRIPTION_MODEL_COSTS } from '../../config'; +import { ObsidianHttpClient } from '../../utils/ObsidianHttpClient'; +import { createServiceLogger } from '../../services'; +import { Logger } from '../../utils'; + +export class TranscriptionService implements ITranscriptionProvider { + private apiKey: string; + private corrector: DictionaryCorrector; + private logger: Logger; + // 注: gpt-4o-mini-transcribe と gpt-4o-transcribe の両方が日本語音声認識で高精度 + // コスト重視なら mini、わずかな精度向上が必要なら通常版を選択 + private model: 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe' = DEFAULT_TRANSCRIPTION_SETTINGS.model; + private enableTranscriptionCorrection: boolean = DEFAULT_TRANSCRIPTION_SETTINGS.enableTranscriptionCorrection; + private language: string = 'ja'; + + constructor(apiKey: string, dictionary?: SimpleCorrectionDictionary) { + this.apiKey = apiKey; + this.logger = createServiceLogger('TranscriptionService'); + this.corrector = new DictionaryCorrector({ + correctionDictionary: dictionary + }); + } + + async transcribe(audioBlob: Blob, language: string = 'ja'): Promise { + return this.transcribeAudio(audioBlob, language); + } + + async transcribeAudio(audioBlob: Blob, language: string = 'ja'): Promise { + const startTime = Date.now(); + const perfStartTime = performance.now(); + + this.logger.info('Starting transcription', { + audioBlobSize: audioBlob.size, + audioType: audioBlob.type, + language, + model: this.model, + enableTranscriptionCorrection: this.enableTranscriptionCorrection + }); + + try { + // Convert audio blob to proper format for API + const formData = new FormData(); + formData.append('file', audioBlob, 'audio.webm'); + formData.append('model', this.model); + formData.append('response_format', 'json'); + formData.append('temperature', String(API_CONSTANTS.PARAMETERS.TRANSCRIPTION_TEMPERATURE)); // Deterministic output + + // Language setting + if (language !== 'auto') { + formData.append('language', language); + } + + // Build prompt for transcription + const prompt = this.buildTranscriptionPrompt(); + if (prompt) { + formData.append('prompt', prompt); + } + + + // IMPORTANT: OpenAI Audio API implementation + // この部分を変更する場合は、必ずOpenAI APIの仕様を確認すること + // 参照: https://platform.openai.com/docs/api-reference/audio/createTranscription + // + // 要件: + // - multipart/form-data形式 + // - fileフィールドは音声ファイル(Blob) + // - modelフィールドは必須 + // - boundary設定が必要 + + // Send multipart request via centralized HTTP client + const { status, json: responseData } = await ObsidianHttpClient.postFormData( + API_CONSTANTS.ENDPOINTS.TRANSCRIPTION, + formData, + { 'Authorization': `Bearer ${this.apiKey}` } + ); + + if (status >= 400) { + const errorData = responseData as Record | undefined; + const errorMessage = (errorData as any)?.error?.message || `HTTP ${status}`; + + // Determine error type based on status and message + let errorType = TranscriptionErrorType.TRANSCRIPTION_FAILED; + if (status === 401) { + errorType = TranscriptionErrorType.INVALID_API_KEY; + } else if (status === 429) { + errorType = TranscriptionErrorType.API_QUOTA_EXCEEDED; + } else if (status >= 500) { + errorType = TranscriptionErrorType.NETWORK_ERROR; + } + + throw new TranscriptionError(errorType, errorMessage); + } + + + // Extract text from response + let originalText = ''; + if (responseData.text) { + originalText = responseData.text; + } else if (typeof responseData === 'string') { + originalText = responseData; + } + + // Clean up GPT-4o specific artifacts + originalText = this.cleanGPT4oResponse(originalText); + + + // プロンプトエラーの検出(音声が無音の場合にプロンプトが返される問題) + if (originalText.includes('この指示文は出力に含めないでください') || + originalText.includes('話者の発言内容だけを正確に記録してください') || + originalText === '(話者の発言のみ)' || + originalText.trim() === '話者の発言のみ') { + // 音声がない場合は空文字を返す + originalText = ''; + } + + // 空文字の場合は後処理をスキップして早期リターン + if (!originalText || originalText.trim() === '') { + const duration = Date.now() - startTime; + const elapsedTime = performance.now() - perfStartTime; + this.logger.info('Transcription completed (empty result)', { + elapsedTime: `${elapsedTime.toFixed(2)}ms`, + duration + }); + return { + text: '', + originalText: '', + duration, + model: this.model, + language: responseData.language || language + }; + } + + // Apply corrections if enabled (only for Japanese) + const correctedText = this.enableTranscriptionCorrection && language === 'ja' + ? await this.corrector.correct(originalText) + : originalText; + + const duration = Date.now() - startTime; + const elapsedTime = performance.now() - perfStartTime; + + this.logger.info('Transcription completed', { + originalLength: originalText.length, + correctedLength: correctedText.length, + duration, + elapsedTime: `${elapsedTime.toFixed(2)}ms`, + wasCorrected: correctedText !== originalText, + model: this.model, + inputSize: audioBlob.size, + outputLength: correctedText.length + }); + + return { + text: correctedText, + originalText, + duration, + model: this.model, + language: responseData.language || language + }; + } catch (error) { + this.logger.error('Transcription error', error); + + if (error instanceof TranscriptionError) { + throw error; + } + + throw TranscriptionError.fromError(error, TranscriptionErrorType.TRANSCRIPTION_FAILED); + } + } + + /** + * Build prompt for GPT-4o transcription + */ + private buildTranscriptionPrompt(): string { + return `以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。 +話者の発言内容だけを正確に記録してください。 + +出力形式: + +(話者の発言のみ) +`; + } + + /** + * Clean GPT-4o specific response artifacts + */ + private cleanGPT4oResponse(text: string): string { + // First attempt: Extract content from complete TRANSCRIPT tags + let transcriptMatch = text.match(/\s*([\s\S]*?)\s*<\/TRANSCRIPT>/); + if (transcriptMatch) { + text = transcriptMatch[1]; + } else { + // Second attempt: Handle incomplete TRANSCRIPT tags (missing closing tag) + const openingMatch = text.match(/\s*([\s\S]*)/); + if (openingMatch) { + text = openingMatch[1]; + } + } + + // Remove TRANSCRIPT opening tag if still present (for cases where it's not properly extracted) + text = text.replace(/<\/?TRANSCRIPT[^>]*>/g, ''); + + // Remove specific meta instruction patterns (both at line start and anywhere in text) + const metaPatterns = [ + /^以下の音声内容.*?$/gm, + /^この指示文.*?$/gm, + /^話者の発言内容だけを正確に記録してください.*?$/gm, + /^話者の発言.*?$/gm, + /^出力形式.*?$/gm, + /(話者の発言のみ)/g, // Remove this specific phrase anywhere in the text + ]; + + // Apply all cleaning patterns + for (const pattern of metaPatterns) { + text = text.replace(pattern, ''); + } + + // Clean up extra whitespace and empty lines + text = text.trim(); + text = text.replace(/\n{3,}/g, '\n\n'); + text = text.replace(/^\s*\n/gm, ''); // Remove lines with only whitespace + text = text.trim(); + + return text; + } + + /** + * Estimate transcription cost + */ + estimateCost(audioLengthSeconds: number): number { + const audioLengthMinutes = audioLengthSeconds / 60; + + // Get cost per minute from configuration + const costPerMinute = TRANSCRIPTION_MODEL_COSTS[this.model]; + + return audioLengthMinutes * costPerMinute; + } + + /** + * Convert audio blob to WAV format if needed + */ + private async convertToWav(audioBlob: Blob): Promise { + // If already WAV, return as-is + if (audioBlob.type === 'audio/wav') { + return audioBlob; + } + + // For now, we'll use webm directly as GPT-4o supports it + // In the future, we might want to implement proper conversion + return audioBlob; + } + + /** + * Update settings + */ + updateCorrectorSettings(settings: { enabled: boolean }) { + this.enableTranscriptionCorrection = settings.enabled; + this.corrector.updateSettings({ + enabled: settings.enabled + }); + } + + /** + * Set transcription correction enabled/disabled + */ + setTranscriptionCorrection(enabled: boolean) { + this.enableTranscriptionCorrection = enabled; + } + + /** + * Update API key + */ + updateApiKey(apiKey: string) { + this.apiKey = apiKey; + // API key is no longer needed for the simplified corrector + // Preserve existing dictionary settings when updating API key + const currentDict = this.corrector.getSettings().correctionDictionary; + this.corrector = new DictionaryCorrector({ + correctionDictionary: currentDict + }); + } + + /** + * Set transcription model + */ + setModel(model: 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe') { + this.model = model; + } + + + /** + * Update custom correction dictionary + */ + setCustomDictionary(dictionary: SimpleCorrectionDictionary) { + this.corrector.updateCorrectionDictionary(dictionary); + } + + /** + * Get corrector instance for dictionary cleanup + */ + getCorrector(): DictionaryCorrector { + return this.corrector; + } + +} diff --git a/src/errors/ErrorHandler.ts b/src/errors/ErrorHandler.ts new file mode 100644 index 0000000..9a2ad66 --- /dev/null +++ b/src/errors/ErrorHandler.ts @@ -0,0 +1,469 @@ +/** + * エラーハンドリングの体系化 + * + * CLAUDE.mdの哲学に従った実装: + * - 根本的な解決: エラー処理の一元化 + * - 汎用的な設計: あらゆるエラーパターンに対応 + * - 型安全性: TypeScriptの型システムを活用 + * - エラーリカバリー: 適切な復旧処理 + */ + +import { Notice } from 'obsidian'; +import { TranscriptionError, TranscriptionErrorType } from './TranscriptionError'; +import type { IDisposable } from '../interfaces'; +import { getI18n } from '../services'; +import type { I18nService } from '../interfaces'; + +/** + * エラーの重要度レベル + */ +export enum ErrorSeverity { + /** 致命的 - プラグインの動作を停止すべき */ + FATAL = 'fatal', + /** エラー - 機能は動作しないが、プラグインは継続可能 */ + ERROR = 'error', + /** 警告 - 機能は動作するが、問題がある */ + WARNING = 'warning', + /** 情報 - ユーザーに通知すべき情報 */ + INFO = 'info' +} + +/** + * エラーコンテキスト情報 + */ +export interface ErrorContext { + /** エラーが発生したコンポーネント */ + component: string; + /** エラー発生時の操作 */ + operation: string; + /** 追加のメタデータ */ + metadata?: Record; + /** エラー発生時刻 */ + timestamp: Date; +} + +/** + * エラーハンドラーのオプション + */ +export interface ErrorHandlerOptions { + /** 開発モードかどうか */ + isDevelopment: boolean; + /** エラーログの最大保持数 */ + maxErrorLogs: number; + /** リトライの最大回数 */ + maxRetries: number; + /** リトライの基本待機時間 (ms) */ + retryBaseDelay: number; +} + +/** + * エラーログエントリ + */ +interface ErrorLogEntry { + error: Error; + context: ErrorContext; + severity: ErrorSeverity; + timestamp: Date; + handled: boolean; +} + +/** + * リトライ可能な操作の定義 + */ +export interface RetryableOperation { + /** 実行する操作 */ + operation: () => Promise; + /** リトライ可能かどうかを判定する関数 */ + isRetryable?: (error: Error) => boolean; + /** リトライ前のクリーンアップ処理 */ + onRetry?: (attempt: number, error: Error) => Promise; +} + +/** + * グローバルエラーハンドラー + */ +export class ErrorHandler implements IDisposable { + private static instance: ErrorHandler; + private errorLogs: ErrorLogEntry[] = []; + private options: ErrorHandlerOptions; + private _isDisposed = false; + private unhandledRejectionHandler?: (event: PromiseRejectionEvent) => void; + private errorHandler?: (event: ErrorEvent) => void; + private i18n?: I18nService; + + private constructor(options: Partial = {}) { + this.options = { + isDevelopment: false, + maxErrorLogs: 100, + maxRetries: 3, + retryBaseDelay: 1000, + ...options + }; + + // i18n service might not be available yet during initialization + try { + this.i18n = getI18n(); + } catch (error) { + // i18n service will be available later + } + + this.setupGlobalHandlers(); + } + + /** + * シングルトンインスタンスを取得 + */ + static getInstance(options?: Partial): ErrorHandler { + if (!ErrorHandler.instance) { + ErrorHandler.instance = new ErrorHandler(options); + } + return ErrorHandler.instance; + } + + /** + * グローバルエラーハンドラーの設定 + */ + private setupGlobalHandlers(): void { + // Unhandled promise rejections + this.unhandledRejectionHandler = (event: PromiseRejectionEvent) => { + // Filter out errors that are not from our plugin + const error = event.reason; + const errorString = error?.toString?.() || String(error); + + // Ignore errors from other plugins/sources + if (errorString.includes('inputEl') || + errorString.includes('Cannot read properties of undefined') || + !errorString.includes('voice-input')) { + // Log in development mode only + if (this.options.isDevelopment) { + // eslint-disable-next-line no-console + console.debug('[VoiceInput] Ignored external error:', error); + } + return; + } + + this.handleError( + new Error(`Unhandled Promise Rejection: ${event.reason}`), + { + component: 'Global', + operation: 'UnhandledRejection', + metadata: { reason: event.reason }, + timestamp: new Date() + }, + ErrorSeverity.ERROR + ); + event.preventDefault(); + }; + + // Global error events + this.errorHandler = (event: ErrorEvent) => { + this.handleError( + event.error || new Error(event.message), + { + component: 'Global', + operation: 'UncaughtError', + metadata: { + filename: event.filename, + lineno: event.lineno, + colno: event.colno + }, + timestamp: new Date() + }, + ErrorSeverity.ERROR + ); + }; + + window.addEventListener('unhandledrejection', this.unhandledRejectionHandler); + window.addEventListener('error', this.errorHandler); + } + + /** + * エラーを処理する + */ + handleError( + error: Error, + context: ErrorContext, + severity: ErrorSeverity = ErrorSeverity.ERROR + ): void { + if (this._isDisposed) return; + + // エラーログに記録 + const logEntry: ErrorLogEntry = { + error, + context, + severity, + timestamp: new Date(), + handled: true + }; + + this.addErrorLog(logEntry); + + // コンソールにログ出力 + this.logError(error, context, severity); + + // ユーザーへの通知 + if (severity !== ErrorSeverity.INFO) { + this.notifyUser(error, severity); + } + } + + /** + * リトライ可能な操作を実行 + */ + async executeWithRetry( + retryableOp: RetryableOperation, + context: ErrorContext + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < this.options.maxRetries; attempt++) { + try { + return await retryableOp.operation(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // リトライ可能かチェック + const isRetryable = retryableOp.isRetryable?.(lastError) ?? true; + if (!isRetryable || attempt === this.options.maxRetries - 1) { + throw lastError; + } + + // リトライ前のクリーンアップ + if (retryableOp.onRetry) { + await retryableOp.onRetry(attempt + 1, lastError); + } + + // 指数バックオフで待機 + const delay = this.options.retryBaseDelay * Math.pow(2, attempt); + await this.delay(delay); + + // リトライをログに記録 + this.logRetry(attempt + 1, lastError, context); + } + } + + let contextSummary: string; + try { + contextSummary = JSON.stringify(context); + } catch (e) { + contextSummary = '[Unserializable context]'; + } + throw lastError || new Error(`Retry operation failed after ${this.options.maxRetries} attempts in context: ${contextSummary}`); + } + + /** + * エラーログに追加 + */ + private addErrorLog(entry: ErrorLogEntry): void { + this.errorLogs.push(entry); + + // 最大数を超えたら古いものから削除 + if (this.errorLogs.length > this.options.maxErrorLogs) { + this.errorLogs = this.errorLogs.slice(-this.options.maxErrorLogs); + } + } + + /** + * エラーをコンソールにログ出力 + */ + private logError(error: Error, context: ErrorContext, severity: ErrorSeverity): void { + const logData = { + severity, + component: context.component, + operation: context.operation, + message: error.message, + metadata: context.metadata, + timestamp: context.timestamp.toISOString() + }; + + if (this.options.isDevelopment) { + // 開発モードでは詳細情報を出力 + // eslint-disable-next-line no-console + console.group(`[${severity.toUpperCase()}] ${context.component}.${context.operation}`); + // eslint-disable-next-line no-console + console.table(logData); + // eslint-disable-next-line no-console + console.trace(); + // eslint-disable-next-line no-console + console.groupEnd(); + } else { + // 本番モードでは簡潔に + switch (severity) { + case ErrorSeverity.FATAL: + case ErrorSeverity.ERROR: + console.error(`[${context.component}] ${error.message}`, logData); + break; + case ErrorSeverity.WARNING: + console.warn(`[${context.component}] ${error.message}`, logData); + break; + case ErrorSeverity.INFO: + // eslint-disable-next-line no-console + console.info(`[${context.component}] ${error.message}`, logData); + break; + } + } + } + + /** + * リトライをログに記録 + */ + private logRetry(attempt: number, error: Error, context: ErrorContext): void { + if (this.options.isDevelopment) { + console.warn( + `[RETRY] Attempt ${attempt}/${this.options.maxRetries} for ${context.component}.${context.operation}`, + { error: error.message } + ); + } + } + + /** + * ユーザーに通知 + */ + private notifyUser(error: Error, severity: ErrorSeverity): void { + let message: string; + + if (error instanceof TranscriptionError) { + message = error.getLocalizedMessage(); + } else { + // 一般的なエラーメッセージ + message = this.getGenericErrorMessage(severity); + } + + new Notice(message, severity === ErrorSeverity.FATAL ? 0 : 5000); + } + + /** + * 一般的なエラーメッセージを取得 + */ + private getGenericErrorMessage(severity: ErrorSeverity): string { + // Try to get i18n service if not available yet + if (!this.i18n) { + try { + this.i18n = getI18n(); + } catch (error) { + // Fallback to English messages + switch (severity) { + case ErrorSeverity.FATAL: + return 'A fatal error occurred'; + case ErrorSeverity.ERROR: + return 'An error occurred'; + case ErrorSeverity.WARNING: + return 'Warning'; + default: + return 'Unknown error'; + } + } + } + + switch (severity) { + case ErrorSeverity.FATAL: + return this.i18n.t('error.general.fatal'); + case ErrorSeverity.ERROR: + return this.i18n.t('error.general.error'); + case ErrorSeverity.WARNING: + return this.i18n.t('error.general.warning'); + default: + return this.i18n.t('error.general.default'); + } + } + + /** + * 遅延処理 + */ + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * エラーログを取得 + */ + getErrorLogs(): ReadonlyArray { + return [...this.errorLogs]; + } + + /** + * エラーログをクリア + */ + clearErrorLogs(): void { + this.errorLogs = []; + } + + /** + * リソースの解放 + */ + dispose(): void { + if (this._isDisposed) return; + + if (this.unhandledRejectionHandler) { + window.removeEventListener('unhandledrejection', this.unhandledRejectionHandler); + } + + if (this.errorHandler) { + window.removeEventListener('error', this.errorHandler); + } + + this.errorLogs = []; + this._isDisposed = true; + } + + get isDisposed(): boolean { + return this._isDisposed; + } +} + +/** + * エラーハンドリングのヘルパー関数 + */ +export const ErrorHandlerUtils = { + /** + * TranscriptionErrorに変換 + */ + toTranscriptionError( + error: unknown, + defaultType: TranscriptionErrorType = TranscriptionErrorType.TRANSCRIPTION_FAILED + ): TranscriptionError { + if (error instanceof TranscriptionError) { + return error; + } + + return TranscriptionError.fromError(error, defaultType); + }, + + /** + * エラーが特定のタイプかチェック + */ + isErrorType(error: unknown, type: TranscriptionErrorType): boolean { + return error instanceof TranscriptionError && error.type === type; + }, + + /** + * ネットワークエラーかチェック + */ + isNetworkError(error: unknown): boolean { + if (error instanceof TranscriptionError) { + return error.type === TranscriptionErrorType.NETWORK_ERROR; + } + + if (error instanceof Error) { + return error.message.toLowerCase().includes('network') || + error.message.toLowerCase().includes('fetch'); + } + + return false; + }, + + /** + * APIエラーかチェック + */ + isApiError(error: unknown): boolean { + if (error instanceof TranscriptionError) { + return [ + TranscriptionErrorType.API_ERROR, + TranscriptionErrorType.API_QUOTA_EXCEEDED, + TranscriptionErrorType.INVALID_API_KEY + ].includes(error.type); + } + + return false; + } +}; diff --git a/src/errors/TranscriptionError.ts b/src/errors/TranscriptionError.ts new file mode 100644 index 0000000..b1b1dd1 --- /dev/null +++ b/src/errors/TranscriptionError.ts @@ -0,0 +1,62 @@ +import { getI18n } from '../services'; +import type { I18nService } from '../interfaces'; + +export enum TranscriptionErrorType { + PERMISSION_DENIED = 'PERMISSION_DENIED', + NETWORK_ERROR = 'NETWORK_ERROR', + API_QUOTA_EXCEEDED = 'API_QUOTA_EXCEEDED', + INVALID_API_KEY = 'INVALID_API_KEY', + AUDIO_DEVICE_ERROR = 'AUDIO_DEVICE_ERROR', + AUDIO_INITIALIZATION_FAILED = 'AUDIO_INITIALIZATION_FAILED', + VAD_INITIALIZATION_ERROR = 'VAD_INITIALIZATION_ERROR', + TRANSCRIPTION_FAILED = 'TRANSCRIPTION_FAILED', + AUDIO_PROCESSING_ERROR = 'AUDIO_PROCESSING_ERROR', + SERVICE_NOT_INITIALIZED = 'SERVICE_NOT_INITIALIZED', + API_ERROR = 'API_ERROR' +} + +export class TranscriptionError extends Error { + constructor( + public type: TranscriptionErrorType, + message: string, + public recoverable: boolean = false, + public originalError?: Error + ) { + super(message); + this.name = 'TranscriptionError'; + } + + static fromError(error: unknown, type: TranscriptionErrorType): TranscriptionError { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + const originalError = error instanceof Error ? error : undefined; + return new TranscriptionError(type, message, false, originalError); + } + + getLocalizedMessage(): string { + let i18n: I18nService | null = null; + + try { + i18n = getI18n(); + } catch (error) { + // Fallback to original message if i18n is not available + return this.message; + } + + const errorTypeToI18nKey: Record = { + [TranscriptionErrorType.PERMISSION_DENIED]: 'error.audio.micPermission', + [TranscriptionErrorType.NETWORK_ERROR]: 'error.general.network', + [TranscriptionErrorType.API_QUOTA_EXCEEDED]: 'error.api.quotaExceeded', + [TranscriptionErrorType.INVALID_API_KEY]: 'error.api.unauthorized', + [TranscriptionErrorType.AUDIO_DEVICE_ERROR]: 'error.audio.micNotFound', + [TranscriptionErrorType.AUDIO_INITIALIZATION_FAILED]: 'error.audio.audioContextFailed', + [TranscriptionErrorType.VAD_INITIALIZATION_ERROR]: 'error.file.wasmLoadFailed', + [TranscriptionErrorType.TRANSCRIPTION_FAILED]: 'error.transcription.failed', + [TranscriptionErrorType.AUDIO_PROCESSING_ERROR]: 'error.audio.audioContextFailed', + [TranscriptionErrorType.SERVICE_NOT_INITIALIZED]: 'error.transcription.serviceInitFailed', + [TranscriptionErrorType.API_ERROR]: 'error.api.connectionFailed' + }; + + const i18nKey = errorTypeToI18nKey[this.type]; + return i18nKey ? i18n.t(i18nKey) : this.message; + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..85bbb74 --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,3 @@ +export { ErrorHandler } from './ErrorHandler'; +// export { ErrorSeverity, ErrorHandlerUtils } from './ErrorHandler'; // 未使用: 将来のエラー処理拡張用 +export { TranscriptionError, TranscriptionErrorType } from './TranscriptionError'; diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..aa2f836 --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,200 @@ +/** + * English translation resource + */ +import { TranslationResource } from '../interfaces'; + +export const en: TranslationResource = { + error: { + api: { + noKey: 'OpenAI API key is not set', + invalidKey: 'Invalid API key format', + invalidKeyDetail: 'Invalid API key format. Please enter an API key starting with sk-', + connectionFailed: 'Failed to connect to API', + quotaExceeded: 'API quota exceeded', + rateLimited: 'API rate limit reached. Please wait and try again.', + unauthorized: 'Invalid API key. Please check your settings.' + }, + audio: { + micPermission: 'Microphone access permission required', + micNotFound: 'Microphone not found', + micInitFailed: 'Failed to initialize microphone', + recordingFailed: 'Failed to start recording', + audioContextFailed: 'Failed to create audio context' + }, + transcription: { + failed: 'Transcription failed', + emptyResult: 'Transcription result is empty', + serviceInitFailed: 'Failed to initialize service' + }, + file: { + createFailed: 'Failed to create file', + notFound: 'File not found', + wasmLoadFailed: 'Failed to load WebAssembly module' + }, + general: { + unknown: 'An unknown error occurred', + network: 'Network error occurred', + timeout: 'Request timed out', + fatal: 'A fatal error occurred in the plugin. Please restart.', + default: 'A problem occurred during processing.' + } + }, + status: { + idle: 'Status: Idle', + memoCleared: 'Status: Memo cleared', + clipboardCopied: 'Status: Copied to clipboard', + noteInserted: 'Status: Inserted into note', + noteAppended: 'Status: Appended to end of note', + cleanupInProgress: 'Status: Processing...', + cleanupCompleted: 'Status: Processing completed', + draftRestored: 'Status: Previous draft restored', + recording: { + preparing: 'Status: Preparing to record...', + micInit: 'Status: Initializing microphone...', + recording: 'Status: Recording...', + stopped: 'Status: Stopped', + cancelled: 'Status: Cancelled' + }, + processing: { + transcribing: 'Status: Transcribing...', + correcting: 'Status: Correcting...', + completed: 'Status: Completed' + }, + transcription: { + vadAutoStopped: 'Status: Auto-stopped due to silence', + maxDurationReached: 'Status: Maximum duration reached', + audioTooShort: 'Status: Audio is too short', + noAudioDetected: 'Status: No audio detected' + }, + warning: { + noTextToClear: 'Status: No text to clear', + noTextToCopy: 'Status: No text to copy', + noTextToCleanup: 'Status: No text to process', + noTextToInsert: 'Status: No text to insert', + clearConfirm: 'Status: Press again to clear' + }, + error: 'Status: Error' + }, + notification: { + success: { + copied: 'Copied to clipboard', + inserted: 'Text inserted into note', + cleared: 'Memo cleared', + cleanupDone: 'Processing completed', + newNoteCreated: 'New note created', + dictionaryExported: 'Dictionary exported successfully', + dictionaryImported: 'Dictionary imported successfully', + apiKeyValid: 'Success: API key validation successful' + }, + warning: { + noTextToCopy: 'No text to copy', + noTextToInsert: 'No text to insert', + noTextToClear: 'No text to clear', + noTextToCleanup: 'No text to process', + noEditorFound: 'Editor not found. Copied to clipboard.', + enterApiKey: 'Please enter API key', + serviceInitFailed: 'Service initialization failed', + audioTooShort: 'Audio is too short', + noAudioDetected: 'No audio detected' + }, + error: { + clipboardFailed: 'Failed to copy to clipboard', + noteCreateFailed: 'Failed to create note. Copied to clipboard.', + apiKeyInvalid: 'Error: API key validation failed', + testError: 'Error occurred during test', + cleanupFailed: 'Processing failed: {error}', + dictionaryParseFailed: 'Failed to parse dictionary: {error}', + dictionaryImportFailed: 'Import failed: ', + noDictionaryData: 'No dictionary data to export', + dictionaryExportFailed: 'Failed to export dictionary' + } + }, + ui: { + buttons: { + recordStart: 'Start Voice Input', + recordStop: 'Stop Voice Input', + recordPushToTalk: 'Keep speaking...', + recordStopPushToTalk: 'Release to stop', + recordPreparing: 'Preparing mic...', + cleanup: 'Clean Up', + copy: 'Copy', + insert: 'Insert to Note', + insertAtCursor: 'Insert at Cursor', + append: 'Append to End', + clear: 'Clear', + cancel: 'Cancel', + connectionTest: 'Test Connection', + testing: 'Testing...', + testSuccess: 'Success', + testFailed: 'Failed', + openSettings: 'Open Settings', + reset: 'Reset to default', + preview: 'Preview JSON', + export: 'Export', + import: 'Import' + }, + placeholders: { + textarea: 'Voice transcription will appear here...', + apiKey: 'sk-...', + language: 'en' + }, + titles: { + main: 'Voice Input', + settings: 'Voice Input Settings', + visualizer: 'Audio Level' + }, + settings: { + apiKey: 'OpenAI API Key', + apiKeyDesc: 'Your OpenAI API key for transcription', + aiPostProcessing: 'Dictionary Correction', + aiPostProcessingDesc: 'Apply dictionary-based corrections to transcription results', + transcriptionCorrection: 'Transcription Correction', + transcriptionCorrectionDesc: 'Apply dictionary corrections for more accurate text', + transcriptionModel: 'Transcription Model', + transcriptionModelDesc: 'Select the model for voice recognition', + maxRecordingDuration: 'Max Recording Duration', + maxRecordingDurationDesc: 'Maximum recording time in seconds (30s - 10min)', + pluginLanguage: 'Plugin Language', + pluginLanguageDesc: 'Set language for UI, voice processing, and correction dictionary', + customDictionary: 'Custom Dictionary', + customDictionaryDesc: 'Manage corrections used for post-processing', + dictionaryDefinite: 'Definite Corrections (max 50)', + dictionaryContextual: 'Contextual Corrections (max 150)', + dictionaryImportExport: 'Dictionary Import/Export', + dictionaryImportExportDesc: 'Import or export your correction dictionary as JSON' + }, + options: { + modelMini: 'GPT-4o mini Transcribe', + modelFull: 'GPT-4o Transcribe' + }, + tooltips: { + copy: 'Copy to clipboard', + insert: 'Insert at cursor', + insertAtCursor: 'Insert at cursor position', + append: 'Append to end of note', + clear: 'Press twice to clear text area', + settingsButton: 'Open settings' + }, + units: { + seconds: 's', + minutes: 'min' + }, + labels: { + from: 'From', + fromMultiple: 'From (comma-separated)', + to: 'To', + category: 'Category', + context: 'Context Keywords' + }, + categories: { + noun: 'Noun', + person: 'Person Name', + place: 'Place Name', + org: 'Organization', + proper: 'Other Proper Noun', + technical: 'Technical Term', + spoken: 'Spoken Form', + symbol: 'Symbol/Unit' + } + } +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..3526a8a --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,17 @@ +/** + * 翻訳リソースのエクスポート + */ +import { ja } from './ja'; +import { en } from './en'; +import { zh } from './zh'; +import { ko } from './ko'; +import { Locale, TranslationResource } from '../interfaces'; + +export const translations: Record = { + ja, + en, + zh, + ko +}; + +export { ja, en, zh, ko }; diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts new file mode 100644 index 0000000..6a9790a --- /dev/null +++ b/src/i18n/ja.ts @@ -0,0 +1,200 @@ +/** + * 日本語翻訳リソース + */ +import { TranslationResource } from '../interfaces'; + +export const ja: TranslationResource = { + error: { + api: { + noKey: 'OpenAI APIキーが設定されていません', + invalidKey: '無効なAPIキー形式です', + invalidKeyDetail: '無効なAPIキー形式です。sk-で始まるAPIキーを入力してください。', + connectionFailed: 'APIへの接続に失敗しました', + quotaExceeded: 'APIの利用上限に達しました', + rateLimited: 'APIのレート制限に達しました。しばらく待ってから再試行してください。', + unauthorized: 'APIキーが無効です。設定を確認してください。' + }, + audio: { + micPermission: 'マイクへのアクセス許可が必要です', + micNotFound: 'マイクが見つかりません', + micInitFailed: 'マイクの初期化に失敗しました', + recordingFailed: '録音の開始に失敗しました', + audioContextFailed: 'オーディオコンテキストの作成に失敗しました' + }, + transcription: { + failed: '文字起こしに失敗しました', + emptyResult: '文字起こし結果が空です', + serviceInitFailed: 'サービスの初期化に失敗しました' + }, + file: { + createFailed: 'ファイルの作成に失敗しました', + notFound: 'ファイルが見つかりません', + wasmLoadFailed: 'WebAssemblyモジュールの読み込みに失敗しました' + }, + general: { + unknown: '不明なエラーが発生しました', + network: 'ネットワークエラーが発生しました', + timeout: 'タイムアウトしました', + fatal: 'プラグインで致命的なエラーが発生しました。再起動してください。', + default: '処理中に問題が発生しました。' + } + }, + status: { + idle: 'ステータス: 待機中', + memoCleared: 'ステータス: メモをクリアしました', + clipboardCopied: 'ステータス: クリップボードにコピーしました', + noteInserted: 'ステータス: ノートに挿入しました', + noteAppended: 'ステータス: ノートの末尾に挿入しました', + cleanupInProgress: 'ステータス: 処理中...', + cleanupCompleted: 'ステータス: 処理完了', + draftRestored: 'ステータス: 前回の下書きを復元しました', + recording: { + preparing: 'ステータス: 録音準備中...', + micInit: 'ステータス: マイク初期化中...', + recording: 'ステータス: 録音中...', + stopped: 'ステータス: 停止済み', + cancelled: 'ステータス: キャンセルされました' + }, + processing: { + transcribing: 'ステータス: 文字起こし中...', + correcting: 'ステータス: 清書中...', + completed: 'ステータス: 完了' + }, + transcription: { + vadAutoStopped: 'ステータス: 無音検出により自動停止しました', + maxDurationReached: 'ステータス: 最大録音時間に達しました', + audioTooShort: 'ステータス: 音声が短すぎます', + noAudioDetected: 'ステータス: 音声が検出されませんでした' + }, + warning: { + noTextToClear: 'ステータス: クリアするテキストがありません', + noTextToCopy: 'ステータス: コピーするテキストがありません', + noTextToCleanup: 'ステータス: 処理するテキストがありません', + noTextToInsert: 'ステータス: 挿入するテキストがありません', + clearConfirm: 'ステータス: もう一度押してクリア' + }, + error: 'ステータス: エラー' + }, + notification: { + success: { + copied: 'クリップボードにコピーしました', + inserted: 'テキストをノートに挿入しました', + cleared: 'メモをクリアしました', + cleanupDone: '処理が完了しました', + newNoteCreated: '新しいノートを作成しました', + dictionaryExported: '辞書を正常にエクスポートしました', + dictionaryImported: '辞書を正常にインポートしました', + apiKeyValid: '成功: APIキーの検証に成功しました' + }, + warning: { + noTextToCopy: 'コピーするテキストがありません', + noTextToInsert: '挿入するテキストがありません', + noTextToClear: 'クリアするテキストがありません', + noTextToCleanup: '処理するテキストがありません', + noEditorFound: 'エディタが見つかりません。クリップボードにコピーしました。', + enterApiKey: 'APIキーを入力してください', + serviceInitFailed: 'サービスの初期化に失敗しました', + audioTooShort: '音声が短すぎます', + noAudioDetected: '音声が検出されませんでした' + }, + error: { + clipboardFailed: 'クリップボードへのコピーに失敗しました', + noteCreateFailed: 'ノートの作成に失敗しました。クリップボードにコピーしました。', + apiKeyInvalid: 'エラー: APIキーの検証に失敗しました', + testError: 'テスト中にエラーが発生しました', + cleanupFailed: '処理に失敗しました: {error}', + dictionaryParseFailed: '辞書の読み込みに失敗しました: {error}', + dictionaryImportFailed: 'インポートに失敗しました: ', + noDictionaryData: 'エクスポートする辞書データがありません', + dictionaryExportFailed: '辞書のエクスポートに失敗しました' + } + }, + ui: { + buttons: { + recordStart: '音声入力開始', + recordStop: '音声入力停止', + recordPushToTalk: '話し続けてください...', + recordStopPushToTalk: '離すと停止', + recordPreparing: 'マイク準備中...', + cleanup: '清書', + copy: 'コピー', + insert: 'ノートに挿入', + insertAtCursor: 'カーソル位置に挿入', + append: '末尾に挿入', + clear: 'クリア', + cancel: 'キャンセル', + connectionTest: '接続テスト', + testing: 'テスト中...', + testSuccess: '成功', + testFailed: '失敗', + openSettings: '設定を開く', + reset: 'デフォルトに戻す', + preview: 'JSONを表示', + export: 'エクスポート', + import: 'インポート' + }, + placeholders: { + textarea: 'ここに音声の文字起こしが表示されます...', + apiKey: 'sk-...', + language: 'ja' + }, + titles: { + main: 'Voice Input', + settings: 'Voice Input 設定', + visualizer: '音声レベル' + }, + settings: { + apiKey: 'OpenAI API キー', + apiKeyDesc: '文字起こし用のOpenAI APIキー', + aiPostProcessing: '辞書補正', + aiPostProcessingDesc: '文字起こし結果を辞書で補正', + transcriptionCorrection: '文字起こし補正', + transcriptionCorrectionDesc: '辞書補正を適用して、より正確なテキストを生成', + transcriptionModel: '文字起こしモデル', + transcriptionModelDesc: '音声認識に使用するモデルを選択', + maxRecordingDuration: '最大録音時間', + maxRecordingDurationDesc: '最大録音時間(秒)(30秒〜10分)', + pluginLanguage: 'プラグイン言語', + pluginLanguageDesc: 'UI表示、音声認識処理、補正辞書の言語を設定', + customDictionary: 'カスタム辞書', + customDictionaryDesc: '補正辞書を管理', + dictionaryDefinite: '固定補正(最大50個)', + dictionaryContextual: '文脈補正(最大150個)', + dictionaryImportExport: '辞書のインポート/エクスポート', + dictionaryImportExportDesc: '補正辞書をJSONファイルとしてインポート・エクスポート' + }, + options: { + modelMini: 'GPT-4o mini Transcribe', + modelFull: 'GPT-4o Transcribe' + }, + tooltips: { + copy: 'クリップボードにコピー', + insert: 'カーソル位置に挿入', + insertAtCursor: 'カーソル位置に挿入', + append: 'ノートの末尾に挿入', + clear: '2回押してテキストエリアをクリア', + settingsButton: '設定画面を開く' + }, + units: { + seconds: '秒', + minutes: '分' + }, + labels: { + from: '入力語', + fromMultiple: '入力語(カンマ区切り)', + to: '修正語', + category: 'カテゴリ', + context: '文脈キーワード' + }, + categories: { + noun: '名詞', + person: '人名', + place: '地名', + org: '組織名', + proper: 'その他固有名詞', + technical: '専門用語', + spoken: '話し言葉', + symbol: '記号・単位' + } + } +}; diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts new file mode 100644 index 0000000..89255c7 --- /dev/null +++ b/src/i18n/ko.ts @@ -0,0 +1,200 @@ +/** + * Korean translation resource + */ +import { TranslationResource } from '../interfaces'; + +export const ko: TranslationResource = { + error: { + api: { + noKey: 'OpenAI API 키가 설정되지 않았습니다', + invalidKey: '유효하지 않은 API 키 형식입니다', + invalidKeyDetail: '유효하지 않은 API 키 형식입니다. sk-로 시작하는 API 키를 입력해주세요.', + connectionFailed: 'API 연결에 실패했습니다', + quotaExceeded: 'API 사용량 한도에 도달했습니다', + rateLimited: 'API 요청 제한에 도달했습니다. 잠시 후 다시 시도해주세요.', + unauthorized: 'API 키가 유효하지 않습니다. 설정을 확인해주세요.' + }, + audio: { + micPermission: '마이크 액세스 권한이 필요합니다', + micNotFound: '마이크를 찾을 수 없습니다', + micInitFailed: '마이크 초기화에 실패했습니다', + recordingFailed: '녹음 시작에 실패했습니다', + audioContextFailed: '오디오 컨텍스트 생성에 실패했습니다' + }, + transcription: { + failed: '음성 텍스트 변환에 실패했습니다', + emptyResult: '음성 텍스트 변환 결과가 비어있습니다', + serviceInitFailed: '서비스 초기화에 실패했습니다' + }, + file: { + createFailed: '파일 생성에 실패했습니다', + notFound: '파일을 찾을 수 없습니다', + wasmLoadFailed: 'WebAssembly 모듈 로드에 실패했습니다' + }, + general: { + unknown: '알 수 없는 오류가 발생했습니다', + network: '네트워크 오류가 발생했습니다', + timeout: '요청 시간이 초과되었습니다', + fatal: '플러그인에서 치명적인 오류가 발생했습니다. 재시작해주세요.', + default: '처리 중 문제가 발생했습니다.' + } + }, + status: { + idle: '상태: 대기 중', + memoCleared: '상태: 메모를 지웠습니다', + clipboardCopied: '상태: 클립보드에 복사했습니다', + noteInserted: '상태: 노트에 삽입했습니다', + noteAppended: '상태: 노트 끝에 추가했습니다', + cleanupInProgress: '상태: 처리 중...', + cleanupCompleted: '상태: 처리 완료', + draftRestored: '상태: 이전 초안을 복원했습니다', + recording: { + preparing: '상태: 녹음 준비 중...', + micInit: '상태: 마이크 초기화 중...', + recording: '상태: 녹음 중...', + stopped: '상태: 중지됨', + cancelled: '상태: 취소됨' + }, + processing: { + transcribing: '상태: 음성 텍스트 변환 중...', + correcting: '상태: 텍스트 정리 중...', + completed: '상태: 완료' + }, + transcription: { + vadAutoStopped: '상태: 무음 감지로 자동 중지됨', + maxDurationReached: '상태: 최대 녹음 시간에 도달했습니다', + audioTooShort: '상태: 오디오가 너무 짧습니다', + noAudioDetected: '상태: 오디오가 감지되지 않았습니다' + }, + warning: { + noTextToClear: '상태: 지울 텍스트가 없습니다', + noTextToCopy: '상태: 복사할 텍스트가 없습니다', + noTextToCleanup: '상태: 처리할 텍스트가 없습니다', + noTextToInsert: '상태: 삽입할 텍스트가 없습니다', + clearConfirm: '상태: 다시 눌러서 지우기' + }, + error: '상태: 오류' + }, + notification: { + success: { + copied: '클립보드에 복사했습니다', + inserted: '텍스트를 노트에 삽입했습니다', + cleared: '메모를 지웠습니다', + cleanupDone: '처리가 완료되었습니다', + newNoteCreated: '새 노트를 생성했습니다', + dictionaryExported: '사전을 성공적으로 내보냈습니다', + dictionaryImported: '사전을 성공적으로 가져왔습니다', + apiKeyValid: '성공: API 키 검증 성공' + }, + warning: { + noTextToCopy: '복사할 텍스트가 없습니다', + noTextToInsert: '삽입할 텍스트가 없습니다', + noTextToClear: '지울 텍스트가 없습니다', + noTextToCleanup: '처리할 텍스트가 없습니다', + noEditorFound: '에디터를 찾을 수 없습니다. 클립보드에 복사했습니다.', + enterApiKey: 'API 키를 입력해주세요', + serviceInitFailed: '서비스 초기화에 실패했습니다', + audioTooShort: '오디오가 너무 짧습니다', + noAudioDetected: '오디오가 감지되지 않았습니다' + }, + error: { + clipboardFailed: '클립보드 복사에 실패했습니다', + noteCreateFailed: '노트 생성에 실패했습니다. 클립보드에 복사했습니다.', + apiKeyInvalid: '오류: API 키 검증 실패', + testError: '테스트 중 오류가 발생했습니다', + cleanupFailed: '처리에 실패했습니다: {error}', + dictionaryParseFailed: '사전 파싱에 실패했습니다: {error}', + dictionaryImportFailed: '가져오기에 실패했습니다: ', + noDictionaryData: '내보낼 사전 데이터가 없습니다', + dictionaryExportFailed: '사전 내보내기에 실패했습니다' + } + }, + ui: { + buttons: { + recordStart: '음성 입력 시작', + recordStop: '음성 입력 중지', + recordPushToTalk: '계속 말해주세요...', + recordStopPushToTalk: '놓으면 중지', + recordPreparing: '마이크 준비 중...', + cleanup: '텍스트 정리', + copy: '복사', + insert: '노트에 삽입', + insertAtCursor: '커서 위치에 삽입', + append: '끝에 추가', + clear: '지우기', + cancel: '취소', + connectionTest: '연결 테스트', + testing: '테스트 중...', + testSuccess: '성공', + testFailed: '실패', + openSettings: '설정 열기', + reset: '기본값으로 재설정', + preview: 'JSON 미리보기', + export: '내보내기', + import: '가져오기' + }, + placeholders: { + textarea: '음성 텍스트 변환 결과가 여기에 표시됩니다...', + apiKey: 'sk-...', + language: 'ko' + }, + titles: { + main: 'Voice Input', + settings: 'Voice Input 설정', + visualizer: '오디오 레벨' + }, + settings: { + apiKey: 'OpenAI API 키', + apiKeyDesc: '음성 텍스트 변환용 OpenAI API 키', + aiPostProcessing: '사전 교정', + aiPostProcessingDesc: '음성 텍스트 변환 결과를 사전으로 교정', + transcriptionCorrection: '음성 텍스트 변환 교정', + transcriptionCorrectionDesc: '사전 교정을 적용하여 더 정확한 텍스트 생성', + transcriptionModel: '음성 텍스트 변환 모델', + transcriptionModelDesc: '음성 인식에 사용할 모델 선택', + maxRecordingDuration: '최대 녹음 시간', + maxRecordingDurationDesc: '최대 녹음 시간(초) (30초~10분)', + pluginLanguage: '플러그인 언어', + pluginLanguageDesc: 'UI 표시, 음성 처리 및 교정 사전의 언어 설정', + customDictionary: '사용자 정의 사전', + customDictionaryDesc: '후처리에 사용되는 교정 사전 관리', + dictionaryDefinite: '고정 교정 (최대 50개)', + dictionaryContextual: '문맥 교정 (최대 150개)', + dictionaryImportExport: '사전 가져오기/내보내기', + dictionaryImportExportDesc: '교정 사전을 JSON 파일로 가져오기 또는 내보내기' + }, + options: { + modelMini: 'GPT-4o mini Transcribe', + modelFull: 'GPT-4o Transcribe' + }, + tooltips: { + copy: '클립보드에 복사', + insert: '커서 위치에 삽입', + insertAtCursor: '커서 위치에 삽입', + append: '노트 끝에 추가', + clear: '두 번 눌러서 텍스트 영역 지우기', + settingsButton: '설정 화면 열기' + }, + units: { + seconds: '초', + minutes: '분' + }, + labels: { + from: '입력어', + fromMultiple: '입력어(쉼표구분)', + to: '교정어', + category: '카테고리', + context: '문맥 키워드' + }, + categories: { + noun: '명사', + person: '인명', + place: '지명', + org: '기관명', + proper: '기타 고유명사', + technical: '전문용어', + spoken: '구어체', + symbol: '기호/단위' + } + } +}; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts new file mode 100644 index 0000000..0b524d5 --- /dev/null +++ b/src/i18n/zh.ts @@ -0,0 +1,200 @@ +/** + * Chinese translation resource + */ +import { TranslationResource } from '../interfaces'; + +export const zh: TranslationResource = { + error: { + api: { + noKey: 'OpenAI API密钥未设置', + invalidKey: 'API密钥格式无效', + invalidKeyDetail: 'API密钥格式无效。请输入以sk-开头的API密钥。', + connectionFailed: '连接API失败', + quotaExceeded: 'API配额已达上限', + rateLimited: 'API请求频率受限。请稍后重试。', + unauthorized: 'API密钥无效。请检查设置。' + }, + audio: { + micPermission: '需要麦克风访问权限', + micNotFound: '未找到麦克风', + micInitFailed: '麦克风初始化失败', + recordingFailed: '录音启动失败', + audioContextFailed: '音频上下文创建失败' + }, + transcription: { + failed: '语音转文字失败', + emptyResult: '语音转文字结果为空', + serviceInitFailed: '服务初始化失败' + }, + file: { + createFailed: '文件创建失败', + notFound: '文件未找到', + wasmLoadFailed: 'WebAssembly模块加载失败' + }, + general: { + unknown: '发生未知错误', + network: '发生网络错误', + timeout: '请求超时', + fatal: '插件发生致命错误。请重启。', + default: '处理过程中发生问题。' + } + }, + status: { + idle: '状态:待机中', + memoCleared: '状态:备忘录已清除', + clipboardCopied: '状态:已复制到剪贴板', + noteInserted: '状态:已插入到笔记', + noteAppended: '状态:已添加到笔记末尾', + cleanupInProgress: '状态:处理中...', + cleanupCompleted: '状态:处理完成', + draftRestored: '状态:已恢复上次草稿', + recording: { + preparing: '状态:录音准备中...', + micInit: '状态:麦克风初始化中...', + recording: '状态:录音中...', + stopped: '状态:已停止', + cancelled: '状态:已取消' + }, + processing: { + transcribing: '状态:语音转文字中...', + correcting: '状态:文本处理中...', + completed: '状态:完成' + }, + transcription: { + vadAutoStopped: '状态:检测到静音自动停止', + maxDurationReached: '状态:已达最大录音时间', + audioTooShort: '状态:音频过短', + noAudioDetected: '状态:未检测到音频' + }, + warning: { + noTextToClear: '状态:没有要清除的文本', + noTextToCopy: '状态:没有要复制的文本', + noTextToCleanup: '状态:没有要处理的文本', + noTextToInsert: '状态:没有要插入的文本', + clearConfirm: '状态:再次按下以清除' + }, + error: '状态:错误' + }, + notification: { + success: { + copied: '已复制到剪贴板', + inserted: '文本已插入到笔记', + cleared: '备忘录已清除', + cleanupDone: '处理完成', + newNoteCreated: '新笔记已创建', + dictionaryExported: '词典导出成功', + dictionaryImported: '词典导入成功', + apiKeyValid: '成功:API密钥验证成功' + }, + warning: { + noTextToCopy: '没有要复制的文本', + noTextToInsert: '没有要插入的文本', + noTextToClear: '没有要清除的文本', + noTextToCleanup: '没有要处理的文本', + noEditorFound: '未找到编辑器。已复制到剪贴板。', + enterApiKey: '请输入API密钥', + serviceInitFailed: '服务初始化失败', + audioTooShort: '音频过短', + noAudioDetected: '未检测到音频' + }, + error: { + clipboardFailed: '复制到剪贴板失败', + noteCreateFailed: '笔记创建失败。已复制到剪贴板。', + apiKeyInvalid: '错误:API密钥验证失败', + testError: '测试过程中发生错误', + cleanupFailed: '处理失败:{error}', + dictionaryParseFailed: '词典解析失败:{error}', + dictionaryImportFailed: '导入失败:', + noDictionaryData: '没有要导出的词典数据', + dictionaryExportFailed: '词典导出失败' + } + }, + ui: { + buttons: { + recordStart: '开始语音输入', + recordStop: '停止语音输入', + recordPushToTalk: '请继续说话...', + recordStopPushToTalk: '松开停止', + recordPreparing: '麦克风准备中...', + cleanup: '文本整理', + copy: '复制', + insert: '插入到笔记', + insertAtCursor: '在光标处插入', + append: '添加到末尾', + clear: '清除', + cancel: '取消', + connectionTest: '连接测试', + testing: '测试中...', + testSuccess: '成功', + testFailed: '失败', + openSettings: '打开设置', + reset: '重置为默认值', + preview: '预览JSON', + export: '导出', + import: '导入' + }, + placeholders: { + textarea: '语音转文字结果将显示在这里...', + apiKey: 'sk-...', + language: 'zh' + }, + titles: { + main: 'Voice Input', + settings: 'Voice Input 设置', + visualizer: '音频级别' + }, + settings: { + apiKey: 'OpenAI API 密钥', + apiKeyDesc: '用于语音转文字的OpenAI API密钥', + aiPostProcessing: '词典校正', + aiPostProcessingDesc: '使用词典对语音转文字结果进行校正', + transcriptionCorrection: '语音转文字校正', + transcriptionCorrectionDesc: '应用词典校正以生成更准确的文本', + transcriptionModel: '语音转文字模型', + transcriptionModelDesc: '选择用于语音识别的模型', + maxRecordingDuration: '最大录音时长', + maxRecordingDurationDesc: '最大录音时间(秒)(30秒~10分钟)', + pluginLanguage: '插件语言', + pluginLanguageDesc: '设置UI显示、语音处理和校正词典的语言', + customDictionary: '自定义词典', + customDictionaryDesc: '管理用于后处理的校正词典', + dictionaryDefinite: '固定校正(最多50个)', + dictionaryContextual: '语境校正(最多150个)', + dictionaryImportExport: '词典导入/导出', + dictionaryImportExportDesc: '将校正词典作为JSON文件导入或导出' + }, + options: { + modelMini: 'GPT-4o mini Transcribe', + modelFull: 'GPT-4o Transcribe' + }, + tooltips: { + copy: '复制到剪贴板', + insert: '在光标处插入', + insertAtCursor: '在光标处插入', + append: '添加到笔记末尾', + clear: '按两次清除文本区域', + settingsButton: '打开设置' + }, + units: { + seconds: '秒', + minutes: '分钟' + }, + labels: { + from: '输入词', + fromMultiple: '输入词(逗号分隔)', + to: '校正词', + category: '类别', + context: '语境关键词' + }, + categories: { + noun: '名词', + person: '人名', + place: '地名', + org: '机构名', + proper: '其他专有名词', + technical: '专业术语', + spoken: '口语', + symbol: '符号/单位' + } + } +}; diff --git a/src/interfaces/audio.ts b/src/interfaces/audio.ts new file mode 100644 index 0000000..cdeffb7 --- /dev/null +++ b/src/interfaces/audio.ts @@ -0,0 +1,95 @@ +import { App } from 'obsidian'; + +/** + * 共通のオプション + * + * CLAUDE.mdの哲学に従った設計: + * - 必須とオプションの明確な分離 + * - onSpeechEndはコア機能のため必須 + */ +interface BaseAudioRecorderOptions { + /** 音声録音が終了した際のコールバック(必須) */ + onSpeechEnd: (audioBlob: Blob) => void; + + /** マイクの状態変化を通知するコールバック */ + onMicrophoneStatusChange?: (status: 'initializing' | 'ready' | 'error') => void; + + /** ビジュアライザーを表示するコンテナ要素 */ + visualizerContainer?: HTMLElement; + + /** シンプルなレベルインジケーターを使用するか */ + useSimpleVisualizer?: boolean; + + /** 最大録音時間(秒) */ + maxRecordingSeconds?: number; +} + +// VADを使用しない場合のオプション +export interface AudioRecorderOptionsWithoutVAD extends BaseAudioRecorderOptions { + useVAD: false; +} + +/** + * VADを使用する場合のオプション + * + * CLAUDE.mdの哲学に従った設計: + * - VAD使用時に必要なプロパティを必須化 + * - 型システムで安全性を保証 + */ +export interface AudioRecorderOptionsWithVAD extends BaseAudioRecorderOptions { + useVAD: true; + + /** Obsidian Appインスタンス(VAD使用時は必須) */ + app: App; + + /** VADの状態変化を通知するコールバック(VAD使用時は必須) */ + onVADStatusChange: (status: 'speech' | 'silence') => void; + + /** 無音継続時の自動停止時間 (ms) - デフォルト: 2000 */ + autoStopSilenceDuration?: number; + + /** VADモード (0-3: 0=低感度, 3=高感度) - デフォルト: 3 */ + vadMode?: 0 | 1 | 2 | 3; + + /** 最小音声継続時間 (ms) - デフォルト: 100 */ + minSpeechDuration?: number; + + /** エネルギー闾値 - デフォルト: 0.02 */ + energyThreshold?: number; + + /** 最小無音継続時間 (ms) - デフォルト: 1000 */ + minSilenceDuration?: number; +} + +// ユニオン型で両方のケースを表現 +export type AudioRecorderOptions = AudioRecorderOptionsWithoutVAD | AudioRecorderOptionsWithVAD; + +export interface IAudioProcessor { + initialize(): Promise; + process(data: Float32Array): Promise; + destroy(): void; +} + +export interface ProcessedAudio { + data: Float32Array; + isSpeech: boolean; + timestamp: number; +} + +export interface IAudioVisualizer { + initialize(audioContext: AudioContext, source: MediaStreamAudioSourceNode): void; + updateVADStatus(isActive: boolean): void; + destroy(): void; +} + +export interface IVADProcessor { + initialize(sampleRate: number): Promise; + process(audioData: Float32Array): Promise; + destroy(): void; +} + +export interface SpeechSegment { + audioData: Float32Array; + startTime: number; + endTime: number; +} diff --git a/src/interfaces/disposable.ts b/src/interfaces/disposable.ts new file mode 100644 index 0000000..5045f3a --- /dev/null +++ b/src/interfaces/disposable.ts @@ -0,0 +1,224 @@ +/** + * Disposableパターンのインターフェース + * + * CLAUDE.md の哲学に従った実装: + * - 根本的な解決: リソース管理の体系化 + * - 汎用的な設計: あらゆるリソースに適用可能 + * - 型安全性: TypeScriptの型システムを活用 + * - エラーハンドリング: 適切なクリーンアップ保証 + */ + +import { Logger } from '../utils'; + +/** + * リソースの解放を保証するインターフェース + */ +export interface IDisposable { + /** + * リソースを解放する + * - 複数回呼ばれても安全であること(冪等性) + * - エラーを投げないこと + */ + dispose(): void; + + /** + * リソースが既に解放されているかどうか + */ + readonly isDisposed: boolean; +} + +/** + * 非同期でリソースを解放するインターフェース + */ +export interface IAsyncDisposable { + /** + * リソースを非同期で解放する + * - 複数回呼ばれても安全であること(冪等性) + * - エラーを投げないこと + */ + disposeAsync(): Promise; + + /** + * リソースが既に解放されているかどうか + */ + readonly isDisposed: boolean; +} + +/** + * Disposableパターンの基本実装 + */ +export abstract class Disposable implements IDisposable { + private _isDisposed = false; + + /** + * リソースが既に解放されているかどうか + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * リソースを解放する + */ + dispose(): void { + if (this._isDisposed) { + return; + } + + try { + this.onDispose(); + } finally { + this._isDisposed = true; + } + } + + /** + * 派生クラスでオーバーライドして、実際のリソース解放処理を実装する + */ + protected abstract onDispose(): void; + + /** + * リソースが解放されていることを確認する + * @throws Error リソースが既に解放されている場合 + */ + protected throwIfDisposed(): void { + if (this._isDisposed) { + throw new Error(`${this.constructor.name} has been disposed`); + } + } +} + +/** + * 非同期Disposableパターンの基本実装 + */ +export abstract class AsyncDisposable implements IAsyncDisposable { + private _isDisposed = false; + + /** + * リソースが既に解放されているかどうか + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * リソースを非同期で解放する + */ + async disposeAsync(): Promise { + if (this._isDisposed) { + return; + } + + try { + await this.onDisposeAsync(); + } finally { + this._isDisposed = true; + } + } + + /** + * 派生クラスでオーバーライドして、実際のリソース解放処理を実装する + */ + protected abstract onDisposeAsync(): Promise; + + /** + * リソースが解放されていることを確認する + * @throws Error リソースが既に解放されている場合 + */ + protected throwIfDisposed(): void { + if (this._isDisposed) { + throw new Error(`${this.constructor.name} has been disposed`); + } + } +} + +/** + * 複数のDisposableを管理するクラス + */ +export class CompositeDisposable implements IDisposable { + private readonly disposables: IDisposable[] = []; + private _isDisposed = false; + private static logger = Logger.getLogger('CompositeDisposable'); + + /** + * リソースが既に解放されているかどうか + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Disposableを追加する + */ + add(disposable: IDisposable): void { + if (this._isDisposed) { + disposable.dispose(); + return; + } + + this.disposables.push(disposable); + } + + /** + * 全てのリソースを解放する + */ + dispose(): void { + if (this._isDisposed) { + return; + } + + // 逆順で解放(依存関係を考慮) + for (let i = this.disposables.length - 1; i >= 0; i--) { + try { + this.disposables[i].dispose(); + } catch (error) { + // エラーをログに記録するが、処理は継続 + CompositeDisposable.logger.error('Error disposing resource', error); + } + } + + this.disposables.length = 0; + this._isDisposed = true; + } +} + +/** + * Disposableのユーティリティ関数 + */ +export const DisposableUtils = { + /** + * 関数をDisposableとしてラップする + */ + fromFunction(fn: () => void): IDisposable { + return new FunctionDisposable(fn); + }, + + /** + * 複数のDisposableを安全に解放する + */ + disposeAll(...disposables: (IDisposable | undefined | null)[]): void { + for (const disposable of disposables) { + if (disposable && !disposable.isDisposed) { + try { + disposable.dispose(); + } catch (error) { + // Use logger for error reporting - will be controlled by debugMode in production + Logger.getInstance().error('Error disposing resource:', error); + } + } + } + } +}; + +/** + * 関数をラップするDisposable + */ +class FunctionDisposable extends Disposable { + constructor(private readonly fn: () => void) { + super(); + } + + protected onDispose(): void { + this.fn(); + } +} diff --git a/src/interfaces/i18n.ts b/src/interfaces/i18n.ts new file mode 100644 index 0000000..991fb26 --- /dev/null +++ b/src/interfaces/i18n.ts @@ -0,0 +1,268 @@ +/** + * 国際化(i18n)関連のインターフェース定義 + * + * CLAUDE.md の哲学に従った実装: + * - 型安全性: すべての翻訳キーを型で定義 + * - 拡張性: 新しい言語の追加が容易 + * - 一貫性: プラグイン全体で統一されたi18n使用 + */ + +/** + * サポートする言語コード + */ +export const SUPPORTED_LOCALES = ['ja', 'en', 'zh', 'ko'] as const; +export type Locale = typeof SUPPORTED_LOCALES[number]; + +/** + * デフォルト言語 + */ +export const DEFAULT_LOCALE: Locale = 'ja'; + +/** + * 翻訳リソースの型定義 + * ネストされたオブジェクト構造をサポート + */ +export type TranslationResource = { + error: { + api: { + noKey: string; + invalidKey: string; + invalidKeyDetail: string; + connectionFailed: string; + quotaExceeded: string; + rateLimited: string; + unauthorized: string; + }; + audio: { + micPermission: string; + micNotFound: string; + micInitFailed: string; + recordingFailed: string; + audioContextFailed: string; + }; + transcription: { + failed: string; + emptyResult: string; + serviceInitFailed: string; + }; + file: { + createFailed: string; + notFound: string; + wasmLoadFailed: string; + }; + general: { + unknown: string; + network: string; + timeout: string; + fatal: string; + default: string; + }; + }; + status: { + idle: string; + memoCleared: string; + clipboardCopied: string; + noteInserted: string; + noteAppended: string; + cleanupInProgress: string; + cleanupCompleted: string; + draftRestored: string; + recording: { + preparing: string; + micInit: string; + recording: string; + stopped: string; + cancelled: string; + }; + processing: { + transcribing: string; + correcting: string; + completed: string; + }; + transcription: { + vadAutoStopped: string; + maxDurationReached: string; + audioTooShort: string; + noAudioDetected: string; + }; + warning: { + noTextToClear: string; + noTextToCopy: string; + noTextToCleanup: string; + noTextToInsert: string; + clearConfirm: string; + }; + error: string; + }; + notification: { + success: { + copied: string; + inserted: string; + cleared: string; + cleanupDone: string; + newNoteCreated: string; + apiKeyValid: string; + dictionaryExported: string; + dictionaryImported: string; + }; + warning: { + noTextToCopy: string; + noTextToInsert: string; + noTextToClear: string; + noTextToCleanup: string; + noEditorFound: string; + enterApiKey: string; + serviceInitFailed: string; + audioTooShort: string; + noAudioDetected: string; + }; + error: { + clipboardFailed: string; + noteCreateFailed: string; + apiKeyInvalid: string; + testError: string; + cleanupFailed: string; + dictionaryParseFailed: string; + dictionaryImportFailed: string; + noDictionaryData: string; + dictionaryExportFailed: string; + }; + }; + ui: { + buttons: { + recordStart: string; + recordStop: string; + recordPushToTalk: string; + recordStopPushToTalk: string; + recordPreparing: string; + cleanup: string; + copy: string; + insert: string; + insertAtCursor: string; + append: string; + clear: string; + cancel: string; + connectionTest: string; + testing: string; + testSuccess: string; + testFailed: string; + openSettings: string; + reset: string; + preview: string; + export: string; + import: string; + }; + placeholders: { + textarea: string; + apiKey: string; + language: string; + }; + titles: { + main: string; + settings: string; + visualizer: string; + }; + settings: { + apiKey: string; + apiKeyDesc: string; + aiPostProcessing: string; + aiPostProcessingDesc: string; + transcriptionCorrection: string; + transcriptionCorrectionDesc: string; + transcriptionModel: string; + transcriptionModelDesc: string; + maxRecordingDuration: string; + maxRecordingDurationDesc: string; + pluginLanguage: string; + pluginLanguageDesc: string; + customDictionary: string; + customDictionaryDesc: string; + dictionaryDefinite: string; + dictionaryContextual: string; + dictionaryImportExport: string; + dictionaryImportExportDesc: string; + }; + options: { + modelMini: string; + modelFull: string; + }; + tooltips: { + copy: string; + insert: string; + insertAtCursor: string; + append: string; + clear: string; + settingsButton: string; + }; + units: { + seconds: string; + minutes: string; + }; + labels: { + from: string; + fromMultiple: string; + to: string; + category: string; + context: string; + }; + categories: { + noun: string; + person: string; + place: string; + org: string; + proper: string; + technical: string; + spoken: string; + symbol: string; + }; + }; +}; + +/** + * 翻訳キーのパス型 + * ドット記法でネストされたキーにアクセス + */ +export type TranslationKey = string; + +/** + * i18nサービスのインターフェース + */ +export interface I18nService { + /** + * 現在の言語を取得 + */ + getCurrentLocale(): Locale; + + /** + * 言語を設定 + * @param locale 設定する言語コード + */ + setLocale(locale: Locale): void; + + /** + * 翻訳を取得 + * @param key 翻訳キー(ドット記法) + * @param params パラメータ置換用のオブジェクト + */ + t(key: TranslationKey, params?: Record): string; + + /** + * 利用可能な言語のリストを取得 + */ + getAvailableLocales(): readonly Locale[]; + + /** + * 言語変更時のコールバックを登録 + */ + onLocaleChange(callback: (locale: Locale) => void): void; + + /** + * 言語変更時のコールバックを解除 + */ + offLocaleChange(callback: (locale: Locale) => void): void; +} + +/** + * 翻訳関数の型 + */ +export type TranslateFunction = (key: TranslationKey, params?: Record) => string; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts new file mode 100644 index 0000000..90e601c --- /dev/null +++ b/src/interfaces/index.ts @@ -0,0 +1,18 @@ +// Settings +export type { VoiceInputSettings } from './settings'; +export { DEFAULT_SETTINGS } from './settings'; + +// Audio interfaces +export * from './audio'; + +// Recording interfaces +export * from './recording'; + +// Transcription interfaces +export * from './transcription'; + +// Disposable interface +export * from './disposable'; + +// i18n interfaces +export * from './i18n'; diff --git a/src/interfaces/recording.ts b/src/interfaces/recording.ts new file mode 100644 index 0000000..96a459b --- /dev/null +++ b/src/interfaces/recording.ts @@ -0,0 +1,31 @@ +/** + * Recording stop reason types + */ +export type StopReasonType = 'manual' | 'vad-auto' | 'max-duration' | 'error'; + +/** + * Recording stop reason interface + */ +export interface StopReason { + type: StopReasonType; + message?: string; +} + +/** + * Audio processing queue item + */ +export interface AudioQueueItem { + audioBlob: Blob; + timestamp: number; + stopReason: StopReason; +} + +/** + * Recording state interface + */ +export interface RecordingState { + isRecording: boolean; + isPushToTalkMode: boolean; + lastStopReason?: StopReason; + processingQueue: AudioQueueItem[]; +} diff --git a/src/interfaces/settings.ts b/src/interfaces/settings.ts new file mode 100644 index 0000000..045221f --- /dev/null +++ b/src/interfaces/settings.ts @@ -0,0 +1,31 @@ +import { Locale } from './i18n'; +import { SimpleCorrectionDictionary } from './transcription'; +import { LogLevel } from '../utils'; + +export interface VoiceInputSettings { + openaiApiKey: string; + enableTranscriptionCorrection: boolean; // 文字起こし補正を有効化(辞書補正) + transcriptionModel: 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe'; + // 録音設定 + maxRecordingSeconds: number; // 最大録音時間(秒) + // プラグイン言語設定 + pluginLanguage: Locale; // プラグイン全体の言語(UI、音声認識、補正辞書) + customDictionary: SimpleCorrectionDictionary; + // デバッグ設定 + debugMode: boolean; // デバッグモード + logLevel: LogLevel; // ログレベル +} + +export const DEFAULT_SETTINGS: VoiceInputSettings = { + openaiApiKey: '', + enableTranscriptionCorrection: true, // デフォルトで補正を有効化 + transcriptionModel: 'gpt-4o-transcribe', + // 録音設定 + maxRecordingSeconds: 300, // 5分(300秒) + // プラグイン言語はObsidianの設定に従う(loadSettingsで設定) + pluginLanguage: 'en', // 初期値、実際はObsidianの設定に従う + customDictionary: { definiteCorrections: [] }, + // デバッグ設定 + debugMode: false, // 本番環境ではデフォルトでオフ + logLevel: LogLevel.INFO // 通常レベル +}; diff --git a/src/interfaces/transcription.ts b/src/interfaces/transcription.ts new file mode 100644 index 0000000..63b8ef6 --- /dev/null +++ b/src/interfaces/transcription.ts @@ -0,0 +1,69 @@ +/** + * 文字起こし結果 + * + * CLAUDE.mdの哲学に従った設計: + * - 重要なメトリクスを必須化 + * - デバッグとトラッキングに必要な情報を保証 + */ +export interface TranscriptionResult { + /** 最終的な文字起こしテキスト */ + text: string; + + /** 使用されたモデル */ + model: string; + + /** 検出された言語 */ + language: string; + + /** 処理時間 (ms) */ + duration: number; + + /** オリジナルの文字起こしテキスト(修正前) */ + originalText?: string; + + /** 信頼度スコア (0-1) */ + confidence?: number; +} + +export interface ITranscriptionProvider { + transcribe(audio: Blob, language: string): Promise; +} + +export interface ITextCorrector { + correct(text: string): Promise; +} + +export interface CorrectionRule { + pattern: RegExp; + replacement: string; + description?: string; + caseSensitive?: boolean; +} + +/** + * 辞書修正オプション + * + * CLAUDE.mdの哲学に従った設計: + * - シンプルな固定置換のみをサポート + */ +export interface DictionaryCorrectorOptions { + enabled: boolean; +} + +// Simple correction dictionary for speech recognition errors +export interface CorrectionEntry { + from: string[]; // 誤認識パターン(複数対応) + to: string; // 正しい表記 +} + +// Legacy type for backward compatibility +export type CorrectionEntryLegacy = { + from: string; + to: string; + category?: string; +} + +export interface SimpleCorrectionDictionary { + // 確実に修正すべきパターン + definiteCorrections: CorrectionEntry[]; +} diff --git a/src/lib/fvad-wasm/fvad.js b/src/lib/fvad-wasm/fvad.js new file mode 100644 index 0000000..59711cb --- /dev/null +++ b/src/lib/fvad-wasm/fvad.js @@ -0,0 +1,19 @@ +// NOTE: Distribution asset (WASM loader) +// This file is not imported by TypeScript sources during bundling. +// It is copied into build outputs by scripts/post-build.mjs and shipped alongside main.js. +// Do not convert to TypeScript or bundle it; keep as-is to match the WASM binary. +var fvad = (() => { + var _scriptName = import.meta.url; + + return ( +async function(moduleArg = {}) { + var moduleRtn; + +var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");let dirname=import.meta.url;if(dirname.startsWith("data:")){dirname="/"}var require=createRequire(dirname)}var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("fs");var nodePath=require("path");if(!import.meta.url.startsWith("data:")){scriptDirectory=nodePath.dirname(require("url").fileURLToPath(import.meta.url))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);var ret=fs.readFileSync(filename);return ret};readAsync=(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);return new Promise((resolve,reject)=>{fs.readFile(filename,binary?undefined:"utf8",(err,data)=>{if(err)reject(err);else resolve(binary?data.buffer:data)})})};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>{if(isFileURI(url)){return new Promise((resolve,reject)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){resolve(xhr.response);return}reject(xhr.status)};xhr.onerror=reject;xhr.send(null)})}return fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);var isFileURI=filename=>filename.startsWith("file://");function findWasmBinary(){if(Module["locateFile"]){var f="fvad.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("fvad.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&!isFileURI(binaryFile)&&!ENVIRONMENT_IS_NODE&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["c"];updateMemoryViews();addOnInit(wasmExports["d"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr];case"i8":return HEAP8[ptr];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":abort("to do getValue(i64) use WASM_BIGINT");case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}var noExitRuntime=Module["noExitRuntime"]||true;function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr]=value;break;case"i8":HEAP8[ptr]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":abort("to do setValue(i64) use WASM_BIGINT");case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ___assert_fail=(condition,filename,line,func)=>{abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])};var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};var wasmImports={a:___assert_fail,b:_emscripten_resize_heap};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["d"])();var _fvad_new=Module["_fvad_new"]=()=>(_fvad_new=Module["_fvad_new"]=wasmExports["e"])();var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["f"])(a0);var _fvad_reset=Module["_fvad_reset"]=a0=>(_fvad_reset=Module["_fvad_reset"]=wasmExports["g"])(a0);var _fvad_free=Module["_fvad_free"]=a0=>(_fvad_free=Module["_fvad_free"]=wasmExports["h"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["i"])(a0);var _fvad_set_mode=Module["_fvad_set_mode"]=(a0,a1)=>(_fvad_set_mode=Module["_fvad_set_mode"]=wasmExports["j"])(a0,a1);var _fvad_set_sample_rate=Module["_fvad_set_sample_rate"]=(a0,a1)=>(_fvad_set_sample_rate=Module["_fvad_set_sample_rate"]=wasmExports["k"])(a0,a1);var _fvad_process=Module["_fvad_process"]=(a0,a1,a2)=>(_fvad_process=Module["_fvad_process"]=wasmExports["l"])(a0,a1,a2);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["n"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["o"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["p"])();Module["cwrap"]=cwrap;Module["setValue"]=setValue;Module["getValue"]=getValue;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise; + + + return moduleRtn; +} +); +})(); +export default fvad; diff --git a/src/lib/fvad-wasm/fvad.wasm b/src/lib/fvad-wasm/fvad.wasm new file mode 100644 index 0000000..2969c2c Binary files /dev/null and b/src/lib/fvad-wasm/fvad.wasm differ diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..598b425 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,2 @@ +// Main entry point for Voice Memo Plugin +export { default } from './plugin'; \ No newline at end of file diff --git a/src/managers/DraftManager.ts b/src/managers/DraftManager.ts new file mode 100644 index 0000000..96736bc --- /dev/null +++ b/src/managers/DraftManager.ts @@ -0,0 +1,113 @@ +import type { App } from 'obsidian'; +import { normalizePath } from 'obsidian'; +import { getLogger } from '../utils'; +import type { Logger } from '../utils'; +import { FILE_CONSTANTS } from '../config'; + +/** + * Manages draft saving and restoration for Voice Input + * Ensures consistent behavior across different save scenarios + */ +export class DraftManager { + private static readonly MAX_DRAFT_SIZE = 1024 * 1024; // 1MB + private static logger: Logger | null = null; + private static lastSaveTime = 0; + private static readonly MIN_SAVE_INTERVAL = 1000; // 1秒の最小間隔 + + /** + * Get the draft file path using the vault's config directory + */ + private static getDraftPath(app: App): string { + const path = `${app.vault.configDir}/plugins/${FILE_CONSTANTS.PLUGIN_ID}/draft.txt`; + return normalizePath(path); + } + + /** + * Initialize logger (called once when plugin loads) + */ + static initLogger(): void { + this.logger = getLogger('DraftManager'); + } + + /** + * Save draft text to disk + * @param app - Obsidian app instance + * @param text - Text to save + * @param source - Where the save was triggered from (for logging) + * @returns true if saved successfully + */ + static async saveDraft(app: App, text: string, source: string): Promise { + // Skip if text is empty + if (!text?.trim()) { + return false; + } + + // Prevent too frequent saves (except for critical saves) + const now = Date.now(); + const criticalSources = ['plugin-unload', 'view-close']; + if (!criticalSources.includes(source)) { + if (now - this.lastSaveTime < this.MIN_SAVE_INTERVAL) { + this.logger?.debug('Draft save skipped (too frequent)', { source, timeSinceLastSave: now - this.lastSaveTime }); + return false; + } + } + this.lastSaveTime = now; + + try { + // Truncate if too large + const textToSave = text.length > this.MAX_DRAFT_SIZE + ? text.substring(0, this.MAX_DRAFT_SIZE) + : text; + + const draftPath = this.getDraftPath(app); + await app.vault.adapter.write(draftPath, textToSave); + + this.logger?.info('Draft saved successfully', { + source, + textLength: textToSave.length, + truncated: text.length > this.MAX_DRAFT_SIZE + }); + return true; + } catch (error) { + this.logger?.error('Failed to save draft', error); + return false; + } + } + + /** + * Load draft text from disk + * @param app - Obsidian app instance + * @returns Draft text or null if not found/error + */ + static async loadDraft(app: App): Promise { + try { + const draftPath = this.getDraftPath(app); + if (!await app.vault.adapter.exists(draftPath)) { + return null; + } + + const text = await app.vault.adapter.read(draftPath); + this.logger?.info('Draft loaded successfully', { textLength: text?.length }); + return text; + } catch (error) { + this.logger?.error('Failed to load draft', error); + return null; + } + } + + /** + * Clear draft file + * @param app - Obsidian app instance + */ + static async clearDraft(app: App): Promise { + try { + const draftPath = this.getDraftPath(app); + if (await app.vault.adapter.exists(draftPath)) { + await app.vault.adapter.remove(draftPath); + this.logger?.info('Draft cleared successfully'); + } + } catch (error) { + this.logger?.error('Failed to clear draft', error); + } + } +} diff --git a/src/managers/ViewManager.ts b/src/managers/ViewManager.ts new file mode 100644 index 0000000..42b317a --- /dev/null +++ b/src/managers/ViewManager.ts @@ -0,0 +1,147 @@ +import { App, MarkdownView, WorkspaceLeaf } from 'obsidian'; +import { VIEW_TYPE_VOICE_INPUT } from '../views'; +import { Disposable } from '../interfaces'; +import { Logger } from '../utils'; +import { createServiceLogger } from '../services'; +import { DeferredViewHelper } from '../utils'; + +/** + * ビューの管理を専門に行うマネージャークラス + * プラグインからビュー管理の責任を分離 + */ +export class ViewManager extends Disposable { + private app: App; + private lastActiveMarkdownView: MarkdownView | null = null; + private logger: Logger; + + constructor(app: App) { + super(); + this.app = app; + + // ServiceLocatorからLoggerを取得 + try { + this.logger = createServiceLogger('ViewManager'); + } catch (error) { + // フォールバック: ServiceLocatorが初期化されていない場合 + // 基本のLoggerインスタンスを作成 + this.logger = Logger.getLogger('ViewManager'); + this.logger.warn('ServiceLocator not initialized, using fallback logger'); + } + } + + /** + * 最後にアクティブだったマークダウンビューを記録 + */ + recordActiveMarkdownView(): void { + const currentActiveView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (currentActiveView) { + this.lastActiveMarkdownView = currentActiveView; + } + } + + /** + * 最後にアクティブだったマークダウンビューを取得 + */ + getLastActiveMarkdownView(): MarkdownView | null { + // ビューがまだ有効かチェック + if (this.lastActiveMarkdownView && this.lastActiveMarkdownView.leaf) { + const leaves = this.app.workspace.getLeavesOfType('markdown'); + if (leaves.some(leaf => leaf === this.lastActiveMarkdownView?.leaf)) { + return this.lastActiveMarkdownView; + } + } + return null; + } + + /** + * Voice Inputビューをアクティブ化 + */ + async activateVoiceInputView(): Promise { + const { workspace } = this.app; + + // 現在のマークダウンビューを記録 + this.recordActiveMarkdownView(); + + let leaf: WorkspaceLeaf | null = null; + const leaves = workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT); + + if (leaves.length > 0) { + // 既存のビューを使用(最初のもののみ) + leaf = leaves[0]; + // 複数のビューがある場合は警告(通常は発生しないはず) + if (leaves.length > 1) { + this.logger.warn('Multiple Voice Input views detected, using the first one', { + viewCount: leaves.length + }); + } + } else { + // 新しいビューを作成 + const rightLeaf = workspace.getRightLeaf(false); + if (rightLeaf) { + leaf = rightLeaf; + await leaf.setViewState({ type: VIEW_TYPE_VOICE_INPUT, active: true }); + } + } + + // ビューを表示(DeferredViews対応) + if (leaf) { + await DeferredViewHelper.safeRevealLeaf(leaf, workspace); + } + } + + /** + * 適切なマークダウンビューを見つける + */ + findTargetMarkdownView(): MarkdownView | null { + // 1. 最後にアクティブだったビューを試す + const lastActive = this.getLastActiveMarkdownView(); + if (lastActive) { + return lastActive; + } + + // 2. 任意のマークダウンビューを探す + const leaves = this.app.workspace.getLeavesOfType('markdown'); + for (const leaf of leaves) { + const view = leaf.view; + if (view instanceof MarkdownView) { + return view; + } + } + + // 3. No suitable existing markdown view found + return null; + } + + /** + * すべてのVoice Inputビューをクリーンアップ + * Note: According to Obsidian plugin guidelines, we don't detach leaves in onunload + * as this is an antipattern. We only clean up references and let Obsidian handle view lifecycle. + */ + cleanupVoiceInputViews(): void { + // Following Obsidian plugin guidelines - we don't detach leaves in cleanup + // Individual views will handle their own cleanup in their onClose methods + // This method is kept for consistency but doesn't perform detachment + } + + /** + * Disposableパターンの実装 + */ + protected onDispose(): void { + // Clean up references to existing views without detaching them + // (following Obsidian plugin guidelines - don't detach leaves in onunload) + this.cleanupVoiceInputViews(); + + // 参照をクリア + this.lastActiveMarkdownView = null; + + this.logger?.info('ViewManager disposed'); + } + + /** + * 互換性のためのdestroyメソッド + * @deprecated dispose()を使用してください + */ + destroy(): void { + this.dispose(); + } +} diff --git a/src/managers/index.ts b/src/managers/index.ts new file mode 100644 index 0000000..c038570 --- /dev/null +++ b/src/managers/index.ts @@ -0,0 +1,2 @@ +export { ViewManager } from './ViewManager'; +export { DraftManager } from './DraftManager'; diff --git a/src/plugin/VoiceInputPlugin.ts b/src/plugin/VoiceInputPlugin.ts new file mode 100644 index 0000000..eccd5a9 --- /dev/null +++ b/src/plugin/VoiceInputPlugin.ts @@ -0,0 +1,380 @@ +import { + Plugin +} from 'obsidian'; +import { VoiceInputSettings, DEFAULT_SETTINGS } from '../interfaces'; +import { VoiceInputView, VIEW_TYPE_VOICE_INPUT } from '../views'; +import { VoiceInputSettingTab } from '../settings'; +import { ViewManager, DraftManager } from '../managers'; +import { ErrorHandler } from '../errors'; +import { Logger, getLogger, mergeSettings, hasSettingsKey, migrateCorrectionEntries, DeferredViewHelper } from '../utils'; +import { serviceLocator, ServiceKeys, getI18nService } from '../services'; +import { getObsidianLocale } from '../types'; +import { SafeStorageService } from '../security'; + +export default class VoiceInputPlugin extends Plugin { + settings: VoiceInputSettings; + private viewManager: ViewManager; + private errorHandler: ErrorHandler; + private logger: Logger; + + async onload() { + // 初期化順序の依存関係: + // 1. ServiceLocatorに基本サービスを登録 + // 2. ErrorHandlerとLoggerの初期化 + // 3. Settingsの読み込み + // 4. ViewManagerの初期化 + // 5. ViewとCommandの登録 + + // Step 1: Register basic services + // In Obsidian, we determine development mode based on the presence of a .git folder + const isDevelopment = await this.app.vault.adapter.exists('.git'); + serviceLocator.register(ServiceKeys.IS_DEVELOPMENT, isDevelopment); + + // Step 2: Load settings first to get debug mode configuration + await this.loadSettings(); + + // Step 3: Initialize error handling and logging with debug mode from settings + this.errorHandler = ErrorHandler.getInstance({ isDevelopment }); + serviceLocator.register(ServiceKeys.ERROR_HANDLER, this.errorHandler); + + this.logger = Logger.getInstance({ + debugMode: this.settings.debugMode || isDevelopment, + logLevel: this.settings.logLevel + }); + serviceLocator.register(ServiceKeys.LOGGER_FACTORY, (component: string) => getLogger(component)); + + this.logger.info('Voice Input Plugin loading...', { + debugMode: this.settings.debugMode, + isDevelopment, + logLevel: this.settings.logLevel + }); + + // Initialize DraftManager logger + DraftManager.initLogger(); + this.logger.debug('DraftManager logger initialized'); + + try { + // Step 4: Initialize i18n with user's preferred language + const i18nService = getI18nService(); + i18nService.setLocale(this.settings.pluginLanguage); + serviceLocator.register(ServiceKeys.I18N_SERVICE, i18nService); + this.logger.debug('I18n service initialized', { locale: this.settings.pluginLanguage }); + + // Step 5: Initialize ViewManager (requires app instance only) + this.viewManager = new ViewManager(this.app); + this.logger.debug('ViewManager initialized'); + + // Initialize UI when workspace layout is ready + this.app.workspace.onLayoutReady(() => { + this.initializeUI(); + }); + + this.logger.info('Voice Input Plugin loaded successfully'); + } catch (error) { + this.logger.error('Failed to load Voice Input Plugin', error); + this.errorHandler.handleError( + error instanceof Error ? error : new Error(String(error)), + { + component: 'VoiceInputPlugin', + operation: 'onload', + timestamp: new Date() + } + ); + throw error; + } + } + + /** + * Initialize UI components when workspace layout is ready + */ + private initializeUI(): void { + try { + this.logger.debug('Initializing UI components...'); + + // Register the view + this.registerView( + VIEW_TYPE_VOICE_INPUT, + (leaf) => new VoiceInputView(leaf, this) + ); + this.logger.debug('VoiceInputView registered'); + + // Add ribbon icon + this.addRibbonIcon('microphone', 'Voice Input', () => { + this.logger.debug('Ribbon icon clicked'); + this.viewManager.activateVoiceInputView(); + }); + this.logger.debug('Ribbon icon added'); + + // Add command + this.addCommand({ + id: 'open-voice-input', + name: 'Open Voice Input', + callback: () => { + this.logger.debug('Command executed: open-voice-input'); + this.viewManager.activateVoiceInputView(); + } + }); + this.logger.debug('Commands registered'); + + // Add settings tab + this.addSettingTab(new VoiceInputSettingTab(this.app, this)); + this.logger.debug('Settings tab added'); + + this.logger.info('UI initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize UI', error); + this.errorHandler.handleError( + error instanceof Error ? error : new Error(String(error)), + { + component: 'VoiceInputPlugin', + operation: 'initializeUI', + timestamp: new Date() + } + ); + throw error; + } + } + + async onunload() { + // Logger might not be initialized if onload failed + if (this.logger) { + this.logger.info('Voice Input Plugin unloading...'); + } + + try { + // Save draft text from any open Voice Input views + if (this.viewManager) { + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT); + for (const leaf of leaves) { + const view = await DeferredViewHelper.safeGetVoiceInputView(leaf); + if (view?.ui?.textArea?.value) { + await DraftManager.saveDraft(this.app, view.ui.textArea.value, 'plugin-unload'); + } + } + + // Stop any active recordings before cleanup + const stopPromises = leaves.map(async leaf => { + const view = await DeferredViewHelper.safeGetVoiceInputView(leaf); + if (view?.actions?.audioRecorder?.isActive()) { + this.logger?.info('Stopping active recording before unload'); + // Add timeout to prevent hanging + return Promise.race([ + view.actions.stopRecording(), + new Promise(resolve => setTimeout(resolve, 1000)) // 1 second timeout + ]).catch(error => { + this.logger.error('Error stopping recording during unload', error); + }); + } + return Promise.resolve(); + }); + + // Wait for all recordings to stop + await Promise.all(stopPromises); + + this.viewManager.cleanupVoiceInputViews(); + this.viewManager.dispose(); + this.logger?.debug('ViewManager cleaned up'); + } + if (this.errorHandler) { + this.errorHandler.dispose(); + this.logger?.debug('ErrorHandler cleaned up'); + } + + // ServiceLocatorをクリア + serviceLocator.clear(); + this.logger?.debug('ServiceLocator cleared'); + + this.logger?.info('Voice Input Plugin unloaded successfully'); + } catch (error) { + if (this.logger) { + this.logger.error('Error during plugin unload', error); + } else { + // Fallback: Only use console if it exists (for extreme edge cases) + // This should rarely happen as logger is initialized early + if (typeof console !== 'undefined' && console.error) { + console.error('Error during plugin unload', error); + } + } + } + } + + async loadSettings() { + const data = await this.loadData(); + + // まずデフォルト設定から開始 + this.settings = { ...DEFAULT_SETTINGS }; + + // 保存が必要かどうかを追跡 + let needsSave = false; + + if (data) { + // 設定のマイグレーション処理 + const migratedData = { ...data }; + + // interfaceLanguageからpluginLanguageへの移行 + if ('interfaceLanguage' in data && !('pluginLanguage' in data)) { + migratedData.pluginLanguage = data.interfaceLanguage; + delete migratedData.interfaceLanguage; + needsSave = true; + this.logger?.info('Migrating interfaceLanguage to pluginLanguage'); + } + + // languageからpluginLanguageへの移行 + if ('language' in data && !('pluginLanguage' in data)) { + // 言語コードを正規化(ja → ja、en → en、その他 → en) + const langCode = data.language as string; + migratedData.pluginLanguage = (langCode === 'ja' || langCode === 'en') ? langCode : 'en'; + delete migratedData.language; + needsSave = true; + this.logger?.info(`Migrating language (${data.language}) to pluginLanguage (${migratedData.pluginLanguage})`); + } else if ('language' in data) { + // pluginLanguageが既に存在する場合は、languageフィールドを削除 + delete migratedData.language; + needsSave = true; + this.logger?.info('Removing redundant language field'); + } + + // VAD関連設定の削除(連続録音のみサポートするため) + if ('recordingMode' in data || 'autoStopSilenceDuration' in data || 'minSpeechDuration' in data) { + if ('recordingMode' in data) { + delete migratedData.recordingMode; + this.logger?.info(`Removing recordingMode setting (was: ${data.recordingMode})`); + } + if ('autoStopSilenceDuration' in data) { + delete migratedData.autoStopSilenceDuration; + this.logger?.info(`Removing autoStopSilenceDuration setting (was: ${data.autoStopSilenceDuration})`); + } + if ('minSpeechDuration' in data) { + delete migratedData.minSpeechDuration; + this.logger?.info(`Removing minSpeechDuration setting (was: ${data.minSpeechDuration})`); + } + needsSave = true; + this.logger?.info('Migrated to continuous recording only mode'); + } + + // 保存されたデータをデフォルト設定に上書き(undefinedの値は除外) + mergeSettings(this.settings, migratedData); + + // APIキーの復号化 + if (this.settings.openaiApiKey) { + try { + this.settings.openaiApiKey = SafeStorageService.decryptFromStore(this.settings.openaiApiKey); + } catch (error) { + this.logger?.error('Failed to decrypt API key:', error); + // 復号化に失敗した場合は、暗号化されていない可能性があるのでそのまま使用 + } + } + + // customDictionaryが存在しない場合はデフォルトを使用 + if (!this.settings.customDictionary) { + this.settings.customDictionary = DEFAULT_SETTINGS.customDictionary; + needsSave = true; + this.logger?.info('Initialized customDictionary with default values'); + } + + // CorrectionEntry の旧形式から新形式への移行 + if (this.settings.customDictionary.definiteCorrections && + this.settings.customDictionary.definiteCorrections.length > 0) { + const originalEntries = this.settings.customDictionary.definiteCorrections; + const migratedEntries = migrateCorrectionEntries(originalEntries); + + // 変更があった場合のみ保存 + if (JSON.stringify(originalEntries) !== JSON.stringify(migratedEntries)) { + this.settings.customDictionary.definiteCorrections = migratedEntries; + needsSave = true; + this.logger?.info('Migrated CorrectionEntry format from single pattern to multiple patterns'); + } + } + + // 重要な設定が欠落している場合の検出 + // enableTranscriptionCorrectionが保存データに存在しない場合 + if (!hasSettingsKey(data, 'enableTranscriptionCorrection')) { + needsSave = true; + this.logger?.info('enableTranscriptionCorrection not found, using default: true'); + } + + // pluginLanguageが設定されていない場合 + if (!hasSettingsKey(data, 'pluginLanguage') && + !hasSettingsKey(data, 'interfaceLanguage')) { + const obsidianLocale = this.getObsidianLocale(); + this.settings.pluginLanguage = obsidianLocale.startsWith('ja') ? 'ja' : 'en'; + needsSave = true; + this.logger?.info(`Auto-detected language: ${this.settings.pluginLanguage} (from Obsidian: ${obsidianLocale})`); + } + } else { + // 保存データが存在しない場合(初回起動) + const obsidianLocale = this.getObsidianLocale(); + this.settings.pluginLanguage = obsidianLocale.startsWith('ja') ? 'ja' : 'en'; + needsSave = true; + this.logger?.info(`First run - auto-detected language: ${this.settings.pluginLanguage}`); + } + + // 必要に応じて設定を保存 + if (needsSave) { + await this.saveSettings(); + this.logger?.info('Settings saved after migration/initialization'); + } + + // i18nの言語設定は、サービスが登録された後にonload内で行う + + this.logger?.info('Settings loaded:', { + pluginLanguage: this.settings.pluginLanguage, + enableTranscriptionCorrection: this.settings.enableTranscriptionCorrection, + hasApiKey: !!this.settings.openaiApiKey + }); + } + + /** + * Obsidianの言語設定を取得 + */ + private getObsidianLocale(): string { + return getObsidianLocale(this.app); + } + + async saveSettings() { + // APIキーを暗号化して保存 + const dataToSave = { + ...this.settings, + openaiApiKey: this.settings.openaiApiKey ? SafeStorageService.encryptForStore(this.settings.openaiApiKey) : '' + }; + await this.saveData(dataToSave); + + // Update logger configuration based on new settings + Logger.getInstance().updateConfig({ + debugMode: this.settings.debugMode, + logLevel: this.settings.logLevel + }); + + // Update all open voice input views + const updatePromises = this.app.workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT).map(async leaf => { + const view = await DeferredViewHelper.safeGetVoiceInputView(leaf); + if (view) { + // actions.updateTranscriptionServiceを呼び出す + if (view.actions?.updateTranscriptionService) { + view.actions.updateTranscriptionService(); + } + // UIコンポーネントも更新して同期を保つ + if (view.ui) { + view.ui.updateSettingsUI(); + } + } + }); + + // Wait for all updates to complete + await Promise.all(updatePromises); + } + + /** + * ViewManagerのゲッター(他のコンポーネントからアクセス可能) + */ + getViewManager(): ViewManager { + return this.viewManager; + } + + /** + * Get logger instance + */ + getLogger(): Logger | null { + return this.logger; + } +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts new file mode 100644 index 0000000..58ffdb3 --- /dev/null +++ b/src/plugin/index.ts @@ -0,0 +1 @@ +export { default } from './VoiceInputPlugin'; diff --git a/src/security/SafeStorageService.ts b/src/security/SafeStorageService.ts new file mode 100644 index 0000000..0128375 --- /dev/null +++ b/src/security/SafeStorageService.ts @@ -0,0 +1,184 @@ +import { Platform } from 'obsidian'; +import { getLogger, Logger } from '../utils'; + +const PREFIX = 'SAFE_V1::'; +const LEGACY_XOR = 'XOR_V1::'; +const LEGACY_PLAIN = 'PLAIN::'; +const FIXED_KEY = 'voice-input-obsidian-2025'; + +interface ElectronSafeStorage { + isEncryptionAvailable(): boolean; + encryptString(plainText: string): Buffer; + decryptString(encrypted: Buffer): string; +} + +interface ElectronRemote { + safeStorage?: ElectronSafeStorage; +} + +interface ElectronRenderer { + safeStorage?: ElectronSafeStorage; + remote?: ElectronRemote; +} + +interface ElectronWindow extends Window { + require?: (moduleName: string) => ElectronRenderer; + electron?: ElectronRenderer; +} + +interface ElectronGlobal { + electron?: ElectronRenderer; +} + +const isElectronWindow = (win: Window): win is ElectronWindow => { + return 'require' in win && typeof (win as ElectronWindow).require === 'function'; +}; + +export class SafeStorageService { + private static safeStorage: ElectronSafeStorage | null = null; + private static logger: Logger = getLogger('SafeStorageService'); + + /** safeStorageの遅延初期化 */ + private static getSafeStorage() { + if (!this.safeStorage) { + try { + // モバイル環境チェック + if (Platform.isMobileApp) { + this.logger.debug('Running on mobile, safeStorage not available'); + return null; + } + + // Obsidianのelectron環境からsafeStorageを取得 + this.logger.debug('Checking for Electron environment'); + this.logger.debug(`window.require exists: ${'require' in window}`); + this.logger.debug(`window.require is function: ${typeof (window as ElectronWindow).require === 'function'}`); + + const electron = isElectronWindow(window) ? window.require?.('electron') : null; + + if (!electron) { + // Alternative access method + const globalElectron = (window as ElectronWindow).electron || (global as ElectronGlobal).electron; + if (globalElectron) { + this.logger.debug('Found electron via global access'); + this.safeStorage = globalElectron.remote?.safeStorage || globalElectron.safeStorage || null; + } + } else { + this.logger.debug(`Electron object exists: ${!!electron}`); + this.logger.debug(`Electron.remote exists: ${!!electron?.remote}`); + this.logger.debug(`Electron.safeStorage exists: ${!!electron?.safeStorage}`); + this.logger.debug(`Electron.remote.safeStorage exists: ${!!electron?.remote?.safeStorage}`); + + this.safeStorage = electron?.remote?.safeStorage || electron?.safeStorage || null; + } + + this.logger.debug(`SafeStorage initialized: ${!!this.safeStorage}`); + } catch (e) { + this.logger.error('Error during safeStorage initialization', e instanceof Error ? e : new Error(String(e))); + } + } + return this.safeStorage; + } + + /** 暗号化して保存用文字列へ変換 */ + static encryptForStore(apiKey: string): string { + if (!apiKey) return ''; + + // Trim the API key before storing + const trimmedKey = apiKey.trim(); + if (!trimmedKey) return ''; + + this.logger.debug('Encrypting API key for storage'); + + const safeStorage = this.getSafeStorage(); + if (safeStorage?.isEncryptionAvailable?.()) { + try { + const buf = safeStorage.encryptString(trimmedKey); + const encrypted = PREFIX + buf.toString('base64'); + this.logger.debug('API key encrypted using SafeStorage'); + return encrypted; + } catch (e) { + this.logger.warn('SafeStorage encryption failed, using fallback', { error: e instanceof Error ? e.message : String(e) }); + } + } else { + this.logger.debug('SafeStorage not available, using XOR fallback'); + } + // フォールバック + const encrypted = this.xorEncrypt(trimmedKey, FIXED_KEY); + const result = LEGACY_XOR + encrypted; + this.logger.debug('API key encrypted using XOR fallback'); + return result; + } + + /** 保存文字列 -> 平文 API キー */ + static decryptFromStore(stored: string): string { + if (!stored) return ''; + + this.logger.debug('Decrypting stored API key'); + + // 新方式 + if (stored.startsWith(PREFIX)) { + const safeStorage = this.getSafeStorage(); + if (safeStorage?.decryptString) { + const b64 = stored.slice(PREFIX.length); + try { + const decrypted = safeStorage.decryptString(Buffer.from(b64, 'base64')); + this.logger.debug('API key decrypted using SafeStorage'); + return decrypted; + } catch (e) { + this.logger.error('SafeStorage decryption failed', e instanceof Error ? e : new Error(String(e))); + return ''; + } + } + } + + if (stored.startsWith(LEGACY_XOR)) { + const encrypted = stored.substring(LEGACY_XOR.length); + try { + const decrypted = this.xorDecrypt(encrypted, FIXED_KEY); + this.logger.debug('API key decrypted using XOR fallback'); + return decrypted; + } catch (e) { + this.logger.error('XOR decryption failed', e instanceof Error ? e : new Error(String(e))); + return ''; + } + } + + // 平文 + if (stored.startsWith(LEGACY_PLAIN)) { + return stored.replace(LEGACY_PLAIN, ''); + } + + // 平文のAPIキー(sk-で始まる)をそのまま返す(後方互換性) + if (stored.startsWith('sk-') && stored.length > 40) { + return stored; + } + + return ''; + } + + private static xorEncrypt(text: string, key: string): string { + let result = ''; + for (let i = 0; i < text.length; i++) { + result += String.fromCharCode( + text.charCodeAt(i) ^ key.charCodeAt(i % key.length) + ); + } + return btoa(result); + } + + private static xorDecrypt(encoded: string, key: string): string { + try { + const text = atob(encoded); + let result = ''; + for (let i = 0; i < text.length; i++) { + result += String.fromCharCode( + text.charCodeAt(i) ^ key.charCodeAt(i % key.length) + ); + } + return result; + } catch (e) { + this.logger.error('XOR decryption failed', e instanceof Error ? e : new Error(String(e))); + return ''; + } + } +} diff --git a/src/security/SecurityUtils.ts b/src/security/SecurityUtils.ts new file mode 100644 index 0000000..03420da --- /dev/null +++ b/src/security/SecurityUtils.ts @@ -0,0 +1,87 @@ +import { ObsidianHttpClient } from '../utils/ObsidianHttpClient'; + +export class SecurityUtils { + /** + * Mask API key for display purposes + */ + static maskAPIKey(key: string): string { + if (!key || key.length < 10) return ''; + return key.substring(0, 7) + '*'.repeat(40); + } + + /** + * Validate OpenAI API key format + */ + static validateOpenAIAPIKey(key: string): boolean { + // OpenAI API keys start with 'sk-' followed by alphanumeric characters + // More flexible pattern to accommodate format changes + return /^sk-[a-zA-Z0-9\-_]{20,}$/.test(key); + } + + /** + * Sanitize user input to prevent injection attacks + */ + static sanitizeInput(input: string): string { + // Remove potentially dangerous characters + return input + .replace(/[<>]/g, '') + .replace(/javascript:/gi, '') + .replace(/on\w+\s*=/gi, '') + .trim(); + } + + /** + * Validate URL format + */ + static isValidURL(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.protocol === 'https:' || urlObj.protocol === 'http:'; + } catch { + return false; + } + } + + /** + * Remove sensitive information from error messages + */ + static sanitizeErrorMessage(message: string): string { + // Remove API keys, tokens, and URLs that might contain sensitive data + return message + .replace(/sk-[a-zA-Z0-9]{48}/g, '[API_KEY_REMOVED]') + .replace(/Bearer\s+[a-zA-Z0-9\-._~+/]+=*/g, '[TOKEN_REMOVED]') + .replace(/https?:\/\/[^\s]+/g, '[URL_REMOVED]'); + } + + /** + * Check if running in secure context (HTTPS or localhost) + */ + static isSecureContext(): boolean { + return window.isSecureContext; + } + + /** + * Test OpenAI API key by making a simple API call + */ + static async testOpenAIAPIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> { + try { + const { status } = await ObsidianHttpClient.request({ + url: 'https://api.openai.com/v1/models', + method: 'GET', + headers: { 'Authorization': `Bearer ${apiKey}` } + }); + + if (status >= 200 && status < 300) { + return { valid: true }; + } else if (status === 401) { + return { valid: false, error: 'APIキーが無効です' }; + } else if (status === 429) { + return { valid: false, error: 'レート制限に達しています' }; + } else { + return { valid: false, error: `エラー: HTTP ${status}` }; + } + } catch (_error) { + return { valid: false, error: 'ネットワークエラー' }; + } + } +} diff --git a/src/security/index.ts b/src/security/index.ts new file mode 100644 index 0000000..d506aef --- /dev/null +++ b/src/security/index.ts @@ -0,0 +1,2 @@ +export { SafeStorageService } from './SafeStorageService'; +export { SecurityUtils } from './SecurityUtils'; diff --git a/src/services/I18nService.ts b/src/services/I18nService.ts new file mode 100644 index 0000000..4207fac --- /dev/null +++ b/src/services/I18nService.ts @@ -0,0 +1,166 @@ +/** + * 国際化(i18n)サービスの実装 + * + * CLAUDE.md の哲学に従った実装: + * - 型安全な翻訳キーアクセス + * - パフォーマンスを考慮したキャッシング + * - 拡張可能な設計 + */ +import { + I18nService, + Locale, + TranslationKey, + DEFAULT_LOCALE, + SUPPORTED_LOCALES +} from '../interfaces'; +import { translations } from '../i18n'; +import { createServiceLogger } from './ServiceLocator'; +import { Logger } from '../utils'; + +export class I18nServiceImpl implements I18nService { + private currentLocale: Locale = DEFAULT_LOCALE; + private localeChangeCallbacks: Set<(locale: Locale) => void> = new Set(); + private logger: Logger; + private translationCache: Map = new Map(); + + constructor(initialLocale?: Locale) { + this.logger = createServiceLogger('I18nService'); + if (initialLocale && SUPPORTED_LOCALES.includes(initialLocale)) { + this.currentLocale = initialLocale; + } + } + + getCurrentLocale(): Locale { + return this.currentLocale; + } + + setLocale(locale: Locale): void { + if (!SUPPORTED_LOCALES.includes(locale)) { + this.logger.warn('Unsupported locale, falling back to default', { + requestedLocale: locale, + fallbackLocale: DEFAULT_LOCALE, + supportedLocales: SUPPORTED_LOCALES + }); + locale = DEFAULT_LOCALE; + } + + if (this.currentLocale !== locale) { + this.currentLocale = locale; + this.translationCache.clear(); // Clear cache on locale change + this.notifyLocaleChange(locale); + } + } + + t(key: TranslationKey, params?: Record): string { + // Check cache first + const cacheKey = `${this.currentLocale}:${key}:${JSON.stringify(params || {})}`; + if (this.translationCache.has(cacheKey)) { + const cached = this.translationCache.get(cacheKey); + return cached !== undefined ? cached : key; + } + + // Get translation + let translation = this.getTranslation(key, this.currentLocale); + + // Fallback to English if not found + if (!translation && this.currentLocale !== 'en') { + translation = this.getTranslation(key, 'en'); + } + + // Fallback to key if still not found + if (!translation) { + this.logger.warn('Translation not found for key', { + key, + currentLocale: this.currentLocale, + hasEnglishFallback: this.currentLocale !== 'en' + }); + translation = key; + } + + // Apply parameter substitution + if (params) { + translation = this.substituteParams(translation, params); + } + + // Cache the result + this.translationCache.set(cacheKey, translation); + + return translation; + } + + getAvailableLocales(): readonly Locale[] { + return SUPPORTED_LOCALES; + } + + onLocaleChange(callback: (locale: Locale) => void): void { + this.localeChangeCallbacks.add(callback); + } + + offLocaleChange(callback: (locale: Locale) => void): void { + this.localeChangeCallbacks.delete(callback); + } + + /** + * Get translation from resource by key path + */ + private getTranslation(key: string, locale: Locale): string | undefined { + const resource = translations[locale]; + if (!resource) return undefined; + + // Navigate through the object using the key path + const keys = key.split('.'); + let current: unknown = resource; + + for (const k of keys) { + if (current && typeof current === 'object' && current !== null && k in current) { + current = (current as Record)[k]; + } else { + return undefined; + } + } + + return typeof current === 'string' ? current : undefined; + } + + /** + * Substitute parameters in translation string + * Supports {paramName} syntax + */ + private substituteParams(translation: string, params: Record): string { + return translation.replace(/{(\w+)}/g, (match, paramName) => { + return params[paramName]?.toString() || match; + }); + } + + /** + * Notify all registered callbacks about locale change + */ + private notifyLocaleChange(locale: Locale): void { + this.localeChangeCallbacks.forEach(callback => { + try { + callback(locale); + } catch (error) { + this.logger.error('Error in locale change callback', error); + } + }); + } +} + +/** + * Create a singleton instance + */ +let i18nServiceInstance: I18nServiceImpl | null = null; + +export function getI18nService(): I18nServiceImpl { + if (!i18nServiceInstance) { + i18nServiceInstance = new I18nServiceImpl(); + } + return i18nServiceInstance; +} + +/** + * Helper function for translations + */ +export function t(key: TranslationKey, params?: Record): string { + return getI18nService().t(key, params); +} diff --git a/src/services/ServiceLocator.ts b/src/services/ServiceLocator.ts new file mode 100644 index 0000000..e5902f3 --- /dev/null +++ b/src/services/ServiceLocator.ts @@ -0,0 +1,164 @@ +/** + * サービスロケーターパターン + * + * CLAUDE.mdの哲学に従った実装: + * - 根本的な解決: 初期化順序の依存関係を管理 + * - 汎用的な設計: あらゆるサービスに対応 + * - 型安全性: TypeScriptの型システムを活用 + * - 単一責任: サービスの登録と取得のみを担当 + */ + +import { ErrorHandler } from '../errors'; +import { Logger } from '../utils'; +import { I18nService } from '../interfaces'; + +/** + * サービスのキー + */ +export const ServiceKeys = { + ERROR_HANDLER: 'ErrorHandler', + LOGGER_FACTORY: 'LoggerFactory', + IS_DEVELOPMENT: 'IsDevelopment', + I18N_SERVICE: 'I18nService' +} as const; + +/** + * サービスタイプのマッピング + */ +interface ServiceTypeMap { + 'ErrorHandler': ErrorHandler; + 'LoggerFactory': (component: string) => Logger; + 'IsDevelopment': boolean; + 'I18nService': I18nService; +} + +/** + * サービスロケーター + * + * 初期化順序の問題を解決するためのシングルトン + */ +export class ServiceLocator { + private static instance: ServiceLocator; + private services: Map = new Map(); + private serviceInitializers: Map unknown> = new Map(); + + private constructor() { + // Private constructor for singleton pattern + } + + /** + * シングルトンインスタンスを取得 + */ + static getInstance(): ServiceLocator { + if (!ServiceLocator.instance) { + ServiceLocator.instance = new ServiceLocator(); + } + return ServiceLocator.instance; + } + + /** + * サービスを登録 + */ + register( + key: K, + service: ServiceTypeMap[K] + ): void { + this.services.set(key as string, service); + } + + /** + * 遅延初期化サービスを登録 + */ + registerLazy( + key: K, + initializer: () => ServiceTypeMap[K] + ): void { + this.serviceInitializers.set(key as string, initializer); + } + + /** + * サービスを取得 + */ + get( + key: K + ): ServiceTypeMap[K] { + const keyStr = key as string; + + // すでに初期化されている場合 + if (this.services.has(keyStr)) { + return this.services.get(keyStr) as ServiceTypeMap[K]; + } + + // 遅延初期化 + if (this.serviceInitializers.has(keyStr)) { + const initializer = this.serviceInitializers.get(keyStr); + if (initializer) { + const service = initializer(); + this.services.set(keyStr, service); + this.serviceInitializers.delete(keyStr); + return service as ServiceTypeMap[K]; + } + } + + throw new Error(`Service not found: ${keyStr}`); + } + + /** + * サービスが登録されているかチェック + */ + has(key: K): boolean { + const keyStr = key as string; + return this.services.has(keyStr) || this.serviceInitializers.has(keyStr); + } + + /** + * すべてのサービスをクリア + */ + clear(): void { + this.services.clear(); + this.serviceInitializers.clear(); + } + + /** + * 特定のサービスを削除 + */ + remove(key: K): void { + const keyStr = key as string; + this.services.delete(keyStr); + this.serviceInitializers.delete(keyStr); + } +} + +/** + * グローバルサービスロケーター + */ +export const serviceLocator = ServiceLocator.getInstance(); + +/** + * ヘルパー関数: ErrorHandlerを取得 + */ +export function getErrorHandler(): ErrorHandler { + return serviceLocator.get(ServiceKeys.ERROR_HANDLER); +} + +/** + * ヘルパー関数: Loggerを作成 + */ +export function createServiceLogger(component: string): Logger { + const loggerFactory = serviceLocator.get(ServiceKeys.LOGGER_FACTORY); + return loggerFactory(component); +} + +/** + * ヘルパー関数: 開発モードかチェック + */ +export function isDevelopmentMode(): boolean { + return serviceLocator.get(ServiceKeys.IS_DEVELOPMENT); +} + +/** + * ヘルパー関数: I18nServiceを取得 + */ +export function getI18n(): I18nService { + return serviceLocator.get(ServiceKeys.I18N_SERVICE); +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..202827c --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,3 @@ +export { serviceLocator, ServiceKeys, getI18n, createServiceLogger } from './ServiceLocator'; +export { getI18nService } from './I18nService'; +// export { I18nServiceImpl } from './I18nService'; // 未使用: 実装クラスは内部使用のみ diff --git a/src/settings/VoiceInputSettingTab.ts b/src/settings/VoiceInputSettingTab.ts new file mode 100644 index 0000000..466dedf --- /dev/null +++ b/src/settings/VoiceInputSettingTab.ts @@ -0,0 +1,519 @@ +import { + App, + PluginSettingTab, + Setting, + Notice +} from 'obsidian'; +import VoiceInputPlugin from '../plugin'; +import { DEFAULT_SETTINGS } from '../interfaces'; +import { CorrectionEntry } from '../interfaces'; +import { SecurityUtils } from '../security'; +import { getI18n, createServiceLogger } from '../services'; +import type { I18nService, Locale } from '../interfaces'; +import { SUPPORTED_LOCALES } from '../interfaces'; +import { VIEW_TYPE_VOICE_INPUT } from '../views'; +import { Logger } from '../utils'; +import { patternsToString, stringToPatterns, migrateCorrectionEntries } from '../utils'; +import { DeferredViewHelper } from '../utils'; + +export class VoiceInputSettingTab extends PluginSettingTab { + plugin: VoiceInputPlugin; + private i18n: I18nService; + private logger: Logger; + private blurHandler: (() => void) | null = null; + private apiKeyInput: HTMLInputElement | null = null; + + constructor(app: App, plugin: VoiceInputPlugin) { + super(app, plugin); + this.plugin = plugin; + this.i18n = getI18n(); + this.logger = createServiceLogger('VoiceInputSettingTab'); + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + // Use Obsidian's setHeading API instead of createEl('h2') + new Setting(containerEl) + .setHeading() + .setName(this.i18n.t('ui.titles.settings')); + + // Plugin Language Setting + new Setting(containerEl) + .setName(this.i18n.t('ui.settings.pluginLanguage')) + .setDesc(this.i18n.t('ui.settings.pluginLanguageDesc')) + .addDropdown(dropdown => dropdown + .addOptions(Object.fromEntries( + SUPPORTED_LOCALES.map(locale => [ + locale, + locale === 'ja' ? 'Japanese' : + locale === 'en' ? 'English' : + locale === 'zh' ? 'Chinese' : + locale === 'ko' ? 'Korean' : locale + ]) + )) + .setValue(this.plugin.settings.pluginLanguage) + .onChange(async (value: Locale) => { + this.plugin.settings.pluginLanguage = value; + await this.plugin.saveSettings(); + this.i18n.setLocale(value); + + // Refresh all open Voice Input views to show new language + const refreshPromises = this.app.workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT).map(async leaf => { + const view = await DeferredViewHelper.safeGetVoiceInputView(leaf); + if (view) { + view.refreshUI(); + } + }); + await Promise.all(refreshPromises); + + // Refresh the settings tab to show new language + this.display(); + })); + + // OpenAI API Key + const apiKeySetting = new Setting(containerEl) + .setName(this.i18n.t('ui.settings.apiKey')) + .setDesc(this.i18n.t('ui.settings.apiKeyDesc')); + + apiKeySetting.addText(text => { + // Retrieve stored API key (now stored in plain text internally) + const decryptedApiKey = this.plugin.settings.openaiApiKey; + + const textComponent = text + .setPlaceholder(this.i18n.t('ui.placeholders.apiKey')) + .setValue(decryptedApiKey ? SecurityUtils.maskAPIKey(decryptedApiKey) : '') + .onChange(async (value) => { + // マスクされた値は無視 + if (value && !value.includes('*')) { + if (!SecurityUtils.validateOpenAIAPIKey(value)) { + apiKeySetting.descEl.addClass('setting-error'); + apiKeySetting.setDesc(this.i18n.t('error.api.invalidKeyDetail')); + } else { + apiKeySetting.descEl.removeClass('setting-error'); + apiKeySetting.setDesc(this.i18n.t('ui.settings.apiKeyDesc')); + // Store API key (will be encrypted on save) + this.plugin.settings.openaiApiKey = value; + await this.plugin.saveSettings(); + } + } + }); + + // Store input element reference + this.apiKeyInput = textComponent.inputEl; + + // Create blur handler to mask the API key display in the input field when focus is lost, + // while preserving the actual stored value. This avoids exposing the key in the UI but does not modify the stored key. + this.blurHandler = () => { + const decryptedApiKey = this.plugin.settings.openaiApiKey; + if (decryptedApiKey) { + textComponent.setValue(SecurityUtils.maskAPIKey(decryptedApiKey)); + } + }; + + // Add blur event listener only - no focus handler for security + this.apiKeyInput.addEventListener('blur', this.blurHandler); + + return textComponent; + }) + .addButton(button => button + .setButtonText(this.i18n.t('ui.buttons.connectionTest')) + .onClick(async () => { + const decryptedApiKey = this.plugin.settings.openaiApiKey; + if (!decryptedApiKey) { + new Notice(this.i18n.t('notification.warning.enterApiKey')); + return; + } + + button.setButtonText(this.i18n.t('ui.buttons.testing')); + button.setDisabled(true); + + try { + const result = await SecurityUtils.testOpenAIAPIKey(decryptedApiKey); + + if (result.valid) { + new Notice(this.i18n.t('notification.success.apiKeyValid')); + button.setButtonText(this.i18n.t('ui.buttons.testSuccess')); + button.setCta(); + + // 3秒後に元に戻す + setTimeout(() => { + button.setButtonText(this.i18n.t('ui.buttons.connectionTest')); + button.removeCta(); + button.setDisabled(false); + }, 3000); + } else { + new Notice(`${result.error || this.i18n.t('notification.error.apiKeyInvalid')}`); + button.setButtonText(this.i18n.t('ui.buttons.testFailed')); + + // 3秒後に元に戻す + setTimeout(() => { + button.setButtonText(this.i18n.t('ui.buttons.connectionTest')); + button.setDisabled(false); + }, 3000); + } + } catch (error) { + new Notice(this.i18n.t('notification.error.testError')); + button.setButtonText(this.i18n.t('ui.buttons.connectionTest')); + button.setDisabled(false); + } + })); + + // Transcription Model + new Setting(containerEl) + .setName(this.i18n.t('ui.settings.transcriptionModel')) + .setDesc(this.i18n.t('ui.settings.transcriptionModelDesc')) + .addDropdown(dropdown => dropdown + .addOption('gpt-4o-transcribe', this.i18n.t('ui.options.modelFull')) + .addOption('gpt-4o-mini-transcribe', this.i18n.t('ui.options.modelMini')) + .setValue(this.plugin.settings.transcriptionModel) + .onChange(async (value) => { + this.plugin.settings.transcriptionModel = value as 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe'; + await this.plugin.saveSettings(); + })); + + // AI Post-processing Toggle and Model Selection + new Setting(containerEl) + .setName(this.i18n.t('ui.settings.aiPostProcessing')) + .setDesc(this.i18n.t('ui.settings.aiPostProcessingDesc')) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableTranscriptionCorrection) + .onChange(async (value) => { + this.plugin.settings.enableTranscriptionCorrection = value; + await this.plugin.saveSettings(); + })); + + // Maximum Recording Duration + const maxRecordingSetting = new Setting(containerEl) + .setName(this.i18n.t('ui.settings.maxRecordingDuration')) + .setDesc(this.i18n.t('ui.settings.maxRecordingDurationDesc')); + + // Add text display for current value + const durationText = maxRecordingSetting.controlEl.createEl('span', { + cls: 'setting-duration-text', + text: this.formatDuration(this.plugin.settings.maxRecordingSeconds) + }); + + maxRecordingSetting + .addSlider(slider => slider + .setLimits(30, 600, 30) // 30秒〜10分、30秒刻み + .setValue(this.plugin.settings.maxRecordingSeconds) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.maxRecordingSeconds = value; + durationText.setText(this.formatDuration(value)); + await this.plugin.saveSettings(); + })) + .addExtraButton(button => button + .setIcon('reset') + .setTooltip(this.i18n.t('ui.buttons.reset')) + .onClick(async () => { + this.plugin.settings.maxRecordingSeconds = DEFAULT_SETTINGS.maxRecordingSeconds; + await this.plugin.saveSettings(); + this.display(); // Refresh UI + })); + + // Dictionary (Unified table editor) - Only show for Japanese + if (this.plugin.settings.pluginLanguage === 'ja') { + new Setting(containerEl) + .setName(this.i18n.t('ui.settings.customDictionary')) + .setDesc(this.i18n.t('ui.settings.customDictionaryDesc')); + + // Create table container for dictionary tables + const tableContainer = containerEl.createDiv('dictionary-table-container'); + + // Definite Corrections Section + tableContainer.createEl('h4', { text: this.i18n.t('ui.settings.dictionaryDefinite') }); + this.createCorrectionTable( + tableContainer, + this.plugin.settings.customDictionary.definiteCorrections, + false + ); + + // Import/Export buttons + new Setting(containerEl) + .setName(this.i18n.t('ui.settings.dictionaryImportExport')) + .setDesc(this.i18n.t('ui.settings.dictionaryImportExportDesc')) + .addButton(button => button + .setButtonText(this.i18n.t('ui.buttons.export')) + .onClick(() => this.exportDictionary())) + .addButton(button => button + .setButtonText(this.i18n.t('ui.buttons.import')) + .onClick(() => this.importDictionary())); + } + + // Debug Settings Section - Removed as per ai-transcriber pattern + // containerEl.createEl('h3', { text: this.i18n.t('ui.titles.debugSettings') || 'Debug Settings' }); + + // Debug Mode Toggle - Removed as per ai-transcriber pattern (controlled via data.json only) + // new Setting(containerEl) + // .setName(this.i18n.t('ui.settings.debugMode') || 'Debug Mode') + // .setDesc(this.i18n.t('ui.settings.debugModeDesc') || 'Enable detailed logging for development and troubleshooting') + // .addToggle(toggle => toggle + // .setValue(this.plugin.settings.debugMode) + // .onChange(async (value) => { + // this.plugin.settings.debugMode = value; + // await this.plugin.saveSettings(); + // this.logger.info('Debug mode changed', { debugMode: value }); + // })); + + // Log Level Dropdown (only shown when debug mode is enabled) - Removed as per ai-transcriber pattern + // if (this.plugin.settings.debugMode) { + // new Setting(containerEl) + // .setName(this.i18n.t('ui.settings.logLevel') || 'Log Level') + // .setDesc(this.i18n.t('ui.settings.logLevelDesc') || 'Set the minimum log level to display') + // .addDropdown(dropdown => dropdown + // .addOption('0', 'ERROR') + // .addOption('1', 'WARN') + // .addOption('2', 'INFO') + // .addOption('3', 'DEBUG') + // .addOption('4', 'TRACE') + // .setValue(String(this.plugin.settings.logLevel)) + // .onChange(async (value) => { + // this.plugin.settings.logLevel = parseInt(value) as LogLevel; + // await this.plugin.saveSettings(); + // this.logger.info('Log level changed', { logLevel: this.plugin.settings.logLevel }); + // })); + // } + + } + + hide() { + // Clean up event listeners + if (this.apiKeyInput && this.blurHandler) { + this.apiKeyInput.removeEventListener('blur', this.blurHandler); + this.apiKeyInput = null; + this.blurHandler = null; + } + } + + /** + * Format duration in seconds to human-readable string + */ + private formatDuration(seconds: number): string { + if (seconds < 60) { + return `${seconds}${this.i18n.t('ui.units.seconds')}`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (remainingSeconds === 0) { + return `${minutes}${this.i18n.t('ui.units.minutes')}`; + } + return `${minutes}${this.i18n.t('ui.units.minutes')} ${remainingSeconds}${this.i18n.t('ui.units.seconds')}`; + } + + /** + * Format duration in milliseconds to human-readable string + */ + private formatDurationMs(milliseconds: number): string { + const seconds = milliseconds / 1000; + if (seconds < 1) { + return `${(Math.round(seconds * 10) / 10).toFixed(1)}${this.i18n.t('ui.units.seconds')}`; + } + return `${seconds.toFixed(1)}${this.i18n.t('ui.units.seconds')}`; + } + + /** + * Create correction table for dictionary entries + */ + private createCorrectionTable( + container: HTMLElement, + entries: CorrectionEntry[], + isContextual: boolean, + isReadOnly = false + ): HTMLTableElement { + const table = container.createEl('table', { cls: 'dictionary-table' }); + + // Create header + const thead = table.createEl('thead'); + const headerRow = thead.createEl('tr'); + headerRow.createEl('th', { text: this.i18n.t('ui.labels.from') }); + headerRow.createEl('th', { text: this.i18n.t('ui.labels.to') }); + if (isContextual) { + headerRow.createEl('th', { text: this.i18n.t('ui.labels.context') }); + } + headerRow.createEl('th', { text: '', cls: 'action-column' }); + + // Create body + const tbody = table.createEl('tbody'); + this.renderTableRows(tbody, entries, isContextual, isReadOnly); + + // Add new entry button (only for editable tables) + if (!isReadOnly) { + const addButton = container.createEl('button', { + text: '+', + cls: 'dictionary-add-button' + }); + addButton.onclick = () => { + const newEntry = { from: [''], to: '' }; + entries.push(newEntry); + this.renderTableRows(tbody, entries, isContextual, isReadOnly); + this.saveDictionary(); + }; + } + + return table; + } + + /** + * Render table rows + */ + private renderTableRows(tbody: HTMLElement, entries: CorrectionEntry[], isContextual: boolean, isReadOnly = false) { + tbody.empty(); + + entries.forEach((entry, index) => { + const row = tbody.createEl('tr'); + + // From field (multiple patterns, comma-separated) + const fromCell = row.createEl('td'); + if (isReadOnly) { + fromCell.textContent = patternsToString(entry.from); + } else { + const fromInput = fromCell.createEl('input', { + type: 'text', + value: patternsToString(entry.from), + placeholder: this.i18n.t('ui.labels.fromMultiple') || 'パターン1, パターン2, ...' + }); + fromInput.onchange = () => { + entry.from = stringToPatterns(fromInput.value); + this.saveDictionary(); + }; + } + + // To field + const toCell = row.createEl('td'); + if (isReadOnly) { + toCell.textContent = entry.to; + } else { + const toInput = toCell.createEl('input', { + type: 'text', + value: entry.to, + placeholder: this.i18n.t('ui.labels.to') + }); + toInput.onchange = () => { + entry.to = toInput.value; + this.saveDictionary(); + }; + } + + // Delete button + const actionCell = row.createEl('td', { cls: 'action-column' }); + if (!isReadOnly) { + const deleteButton = actionCell.createEl('button', { + text: '×', + cls: 'dictionary-delete-button' + }); + deleteButton.onclick = () => { + entries.splice(index, 1); + this.renderTableRows(tbody, entries, isContextual, isReadOnly); + this.saveDictionary(); + }; + } + }); + } + + /** + * Save dictionary changes + */ + private async saveDictionary() { + await this.plugin.saveSettings(); + + // Update all open Voice Input views + const updatePromises = this.app.workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT).map(async leaf => { + const view = await DeferredViewHelper.safeGetVoiceInputView(leaf); + if (view?.actions?.updateTranscriptionService) { + view.actions.updateTranscriptionService(); + } + }); + await Promise.all(updatePromises); + } + + /** + * Export dictionary as JSON + */ + private exportDictionary() { + try { + // Check if dictionary has data + const hasData = this.plugin.settings.customDictionary.definiteCorrections.length > 0; + + if (!hasData) { + new Notice(this.i18n.t('notification.error.noDictionaryData')); + return; + } + + const data = { + version: '1.0', + definiteCorrections: this.plugin.settings.customDictionary.definiteCorrections, + exportedAt: new Date().toISOString() + }; + + const jsonStr = JSON.stringify(data, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `voice-input-dictionary-${new Date().toISOString().split('T')[0]}.json`; + + // Add link to document temporarily + document.body.appendChild(a); + a.click(); + + // Clean up + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + + // Note: We cannot detect if the user cancelled the save dialog + // So we don't show a success message here + } catch (error) { + this.logger.error('Failed to export dictionary', error); + new Notice(this.i18n.t('notification.error.dictionaryExportFailed')); + } + } + + /** + * Import dictionary from JSON + */ + private importDictionary() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const data = JSON.parse(text); + + // Validate structure + if (!data.definiteCorrections || !Array.isArray(data.definiteCorrections)) { + throw new Error('Invalid dictionary format: missing definiteCorrections'); + } + + // Map old categories to new ones (no longer needed, but kept for reference) + + // Migrate to new format + const migratedEntries = migrateCorrectionEntries(data.definiteCorrections); + + // Update settings with migrated data + this.plugin.settings.customDictionary = { + definiteCorrections: migratedEntries + }; + + await this.plugin.saveSettings(); + this.display(); // Refresh UI + + new Notice(this.i18n.t('notification.success.dictionaryImported')); + } catch (error) { + new Notice(this.i18n.t('notification.error.dictionaryImportFailed') + error.message); + } + }; + + input.click(); + } +} diff --git a/src/settings/index.ts b/src/settings/index.ts new file mode 100644 index 0000000..9e21d4e --- /dev/null +++ b/src/settings/index.ts @@ -0,0 +1 @@ +export { VoiceInputSettingTab } from './VoiceInputSettingTab'; diff --git a/src/styles/voice-input.css b/src/styles/voice-input.css new file mode 100644 index 0000000..7dcccce --- /dev/null +++ b/src/styles/voice-input.css @@ -0,0 +1,450 @@ +.voice-input-view { + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.voice-input-title { + margin-bottom: 16px; +} + +/* Title bar with settings button */ +.voice-input-title-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.voice-input-settings-button { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + color: var(--text-muted); + transition: background-color 0.2s, color 0.2s; +} + +.voice-input-settings-button:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.voice-input-visualizer { + margin-bottom: 8px; + height: 60px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +/* ステータス表示エリア */ +.voice-input-status-container { + margin-bottom: 16px; + text-align: center; + padding: 8px; + min-height: 36px; + display: flex; + align-items: center; + justify-content: center; +} + +.voice-input-status-text { + font-size: 16px; + font-weight: 500; + color: var(--text-normal); +} + +.voice-input-status-text.processing { + color: var(--interactive-accent); + animation: pulse 1.5s infinite; +} + +.voice-input-status-text.error { + color: var(--text-error); +} + +.voice-input-text-container { + flex: 1; + margin-bottom: 16px; + min-height: 200px; +} + +.voice-input-textarea { + width: 100%; + height: 100%; + padding: 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary-alt); + color: var(--text-normal); + font-family: var(--font-text); + font-size: 14px; + resize: none; +} + +.voice-input-record-container { + margin-bottom: 12px; +} + +.voice-input-record-button { + width: 100%; + padding: 32px 20px; + font-size: 20px; + font-weight: 600; + background: var(--interactive-accent); + color: var(--text-on-accent); + min-height: 80px; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +/* Full-width cancel button - matches height of 2 button rows */ +.voice-input-cancel-button-full { + width: 100%; + /* Matching 2 button rows with exact calculation: + Button height = padding(24*2) + line-height(~20px) = 68px + 2 rows + gap = 68px + 12px + 68px = 148px + Cancel button = padding needed for 148px total height */ + padding: 54px 12px; + font-size: 20px; + font-weight: 600; + background: #f9e79f; /* Soft yellow */ + color: var(--text-normal); + border: 1px solid #f4d03f; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; + margin-bottom: 12px; + display: none; /* Default hidden, controlled by CSS classes */ + align-items: center; + justify-content: center; +} + +.voice-input-cancel-button-full:hover { + background: #f4d03f; /* Slightly darker yellow on hover */ +} + + +.voice-input-record-button:hover { + background: var(--interactive-accent-hover); +} + +.voice-input-record-button.recording { + background: var(--text-error); + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.8; } + 100% { opacity: 1; } +} + +.voice-input-buttons { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.voice-input-button-group { + margin-bottom: 12px; +} + +.voice-input-action-buttons { + display: flex; + gap: 8px; + align-items: stretch; +} + +.voice-input-secondary-actions { + display: flex; + gap: 6px; +} + +.voice-input-button-large { + flex: 1; +} + +.voice-input-button { + flex: 1 1 0; + min-width: 0; + padding: 24px 12px; + border: none; + border-radius: 4px; + background: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Removed hover effects from regular buttons - only recording and primary buttons should have hover effects */ + +.voice-input-button-primary { + background: var(--interactive-accent); + color: var(--text-on-accent); +} + +.voice-input-button-primary:hover { + background: var(--interactive-accent-hover); +} + +.voice-input-button.recording { + background: var(--text-error); + color: var(--text-on-accent); +} + +.voice-input-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.voice-input-button-medium { + padding: 24px 16px; + font-size: 14px; +} + +.voice-input-button-icon { + display: inline-block; + font-size: 16px; + margin-right: 6px; + vertical-align: -2px; +} + +.voice-input-button-text { + display: inline-block; + vertical-align: middle; +} + + +.setting-error { + color: var(--text-error); +} + +.setting-duration-text { + display: inline-block; + min-width: 3em; + text-align: right; + margin-left: 8px; + color: var(--text-muted); + font-size: 14px; +} + +.setting-item-description { + padding: 8px 0; + font-size: 13px; + color: var(--text-muted); + margin-bottom: 16px; +} + +/* Settings panel in right pane */ +.voice-input-settings-panel { + margin-bottom: 16px; + border-bottom: 1px solid var(--background-modifier-border); + padding-bottom: 16px; +} + +.voice-input-inline-setting { + padding: 8px 0; +} + +.voice-input-inline-setting:last-child { + padding-bottom: 0; +} + +/* Dictionary Table Styles */ +.dictionary-table-container { + margin: 1em 0; +} + +.dictionary-table-container h4 { + margin: 1em 0 0.5em; + font-size: 0.9em; + font-weight: 600; +} + +.dictionary-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1em; + font-size: 0.85em; +} + +.dictionary-table th, +.dictionary-table td { + border: 1px solid var(--background-modifier-border); + padding: 6px 8px; + text-align: left; +} + +.dictionary-table th { + background-color: var(--background-secondary); + font-weight: 600; +} + +.dictionary-table input[type="text"], +.dictionary-table input[type="number"], +.dictionary-table select { + width: 100%; + border: none; + background: transparent; + padding: 2px; +} + +.dictionary-table input[type="text"]:focus, +.dictionary-table input[type="number"]:focus, +.dictionary-table select:focus { + outline: 1px solid var(--interactive-accent); + background: var(--background-primary); +} + +.dictionary-table input[type="number"] { + max-width: 60px; +} + +.dictionary-table .action-column { + width: 40px; + text-align: center; +} + +.dictionary-delete-button { + background: none; + border: none; + color: var(--text-error); + cursor: pointer; + font-size: 1.2em; + padding: 0; + width: 24px; + height: 24px; + line-height: 1; +} + +.dictionary-delete-button:hover { + background: var(--background-modifier-error); + border-radius: 3px; +} + +.dictionary-add-button { + background: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + border-radius: 3px; + padding: 4px 12px; + cursor: pointer; + font-size: 0.9em; +} + +.dictionary-add-button:hover { + background: var(--interactive-accent-hover); +} + +/* Dictionary Tabs */ +.dictionary-tabs { + display: flex; + gap: 4px; + margin-bottom: 1em; + border-bottom: 2px solid var(--background-modifier-border); +} + +.dictionary-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + cursor: pointer; + padding: 8px 16px; + font-size: 0.9em; + font-weight: 500; + transition: all 0.2s; + margin-bottom: -2px; +} + +.dictionary-tab:hover { + color: var(--text-normal); +} + +.dictionary-tab.active { + color: var(--text-normal); + border-bottom-color: var(--interactive-accent); +} + +/* Read-only table styles */ +.dictionary-table tr.read-only td { + background: var(--background-secondary-alt); + color: var(--text-muted); +} + +/* Utility classes for JavaScript style replacements */ +.voice-input-hidden { + display: none !important; +} + +.voice-input-flex { + display: flex !important; +} + +/* Audio Visualizer Styles */ +.audio-visualizer-canvas { + width: calc(100% - 4px); + height: 56px; + background: transparent; + border-radius: 4px; + display: block; + margin: 2px; +} + +.audio-level-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + height: 60px; + background: transparent; + box-sizing: border-box; +} + +.audio-level-container { + flex: 1; + height: 6px; + background: var(--background-modifier-border); + border-radius: 3px; + overflow: hidden; +} + +.audio-level-bar { + height: 100%; + width: var(--audio-level-width, 0%); + background: linear-gradient(90deg, var(--interactive-accent), var(--interactive-accent-hover)); + transition: width 0.05s ease-out; + border-radius: 3px; +} + +.audio-vad-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); + transition: background-color 0.2s ease; +} + +.audio-vad-indicator.speech { + background: var(--color-green); +} + +/* Form element styles */ +.voice-input-textarea-wide { + width: 100% !important; + min-height: 100px !important; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..ea258d1 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,4 @@ +export { getObsidianLocale, hasInternalSettingAPI, hasWebkitAudioContext } from './obsidian-internal'; +export type { AppInternal, VaultInternal, WindowWithWebkitAudio } from './obsidian-internal'; +export type { FvadModule, FvadModuleFactory, WindowWithFvad } from './webrtc-vad'; +export { hasFvadModule } from './webrtc-vad'; diff --git a/src/types/obsidian-internal.ts b/src/types/obsidian-internal.ts new file mode 100644 index 0000000..08e7d14 --- /dev/null +++ b/src/types/obsidian-internal.ts @@ -0,0 +1,62 @@ +/** + * Type definitions for Obsidian internal APIs + * These extend the official Obsidian API types to include internal/undocumented APIs + */ + +import { App, Vault, moment } from 'obsidian'; + +/** + * Extended App interface with internal properties + */ +export interface AppInternal extends App { + setting?: { + open(): void; + openTabById(id: string): void; + }; + locale?: string; + vault: VaultInternal; +} + +/** + * Extended Vault interface with internal config + */ +export interface VaultInternal extends Vault { + config?: { + locale?: string; + }; +} + +/** + * WebKit Audio Context for cross-browser compatibility + */ +export interface WindowWithWebkitAudio extends Window { + webkitAudioContext?: new (options?: AudioContextOptions) => AudioContext; +} + +/** + * Type guard to check if app has internal setting API + */ +export function hasInternalSettingAPI(app: App): app is AppInternal { + return 'setting' in app && typeof (app as AppInternal).setting?.open === 'function'; +} + +/** + * Type guard to check if window has webkit audio context + */ +export function hasWebkitAudioContext(window: Window): window is WindowWithWebkitAudio { + return 'webkitAudioContext' in window; +} + +/** + * Safely get Obsidian locale with fallbacks + */ +export function getObsidianLocale(app: App): string { + const appInternal = app as AppInternal; + + // Try to get locale from app configuration first + const locale = appInternal.vault?.config?.locale || + appInternal.locale || + moment.locale() || // Use Obsidian's provided moment instance + 'en'; + return locale.toLowerCase(); +} diff --git a/src/types/webrtc-vad.ts b/src/types/webrtc-vad.ts new file mode 100644 index 0000000..d3dfcbb --- /dev/null +++ b/src/types/webrtc-vad.ts @@ -0,0 +1,42 @@ +/** + * Type definitions for WebRTC VAD WebAssembly module + */ + +/** + * WebRTC VAD module interface + */ +export interface FvadModule { + _fvad_new(): number; + _fvad_free(handle: number): void; + _fvad_set_mode(handle: number, mode: number): number; + _fvad_set_sample_rate(handle: number, sampleRate: number): number; + _fvad_process(handle: number, audioPtr: number, frameSize: number): number; + _malloc(size: number): number; + _free(ptr: number): void; + HEAP16: { buffer: ArrayBuffer }; +} + +/** + * WebAssembly module factory function + */ +export type FvadModuleFactory = (options?: { + wasmBinary?: Uint8Array; + instantiateWasm?: ( + imports: WebAssembly.Imports, + successCallback: (instance: WebAssembly.Instance) => void + ) => void; +}) => Promise; + +/** + * Global window interface with fvad module + */ +export interface WindowWithFvad extends Window { + __fvadModule?: FvadModuleFactory; +} + +/** + * Type guard to check if window has fvad module + */ +export function hasFvadModule(window: Window): window is WindowWithFvad { + return '__fvadModule' in window && typeof (window as WindowWithFvad).__fvadModule === 'function'; +} diff --git a/src/utils/DeferredViewHelper.ts b/src/utils/DeferredViewHelper.ts new file mode 100644 index 0000000..15cc8f2 --- /dev/null +++ b/src/utils/DeferredViewHelper.ts @@ -0,0 +1,81 @@ +import { WorkspaceLeaf, View } from 'obsidian'; +import type { VoiceInputView } from '../views'; +import { Logger } from './Logger'; + +/** + * Helper utility for safely working with DeferredViews in Obsidian 1.7.2+ + * Provides backward compatibility with older Obsidian versions + */ +export class DeferredViewHelper { + /** + * Safely get a view from a leaf, handling deferred views if present + * @param leaf The workspace leaf to get the view from + * @returns The view if available, null if deferred and couldn't be loaded + */ + static async safeGetView(leaf: WorkspaceLeaf): Promise { + try { + // Check if the leaf has the isDeferred property (Obsidian 1.7.2+) + if ('isDeferred' in leaf && typeof leaf.isDeferred === 'boolean') { + if (leaf.isDeferred && 'loadIfDeferred' in leaf && typeof leaf.loadIfDeferred === 'function') { + // Load the deferred view + await leaf.loadIfDeferred(); + } + } + + // Return the view if it exists and is of the expected type + return leaf.view as T | null; + } catch (error) { + // Use logger for warning - will be controlled by debugMode in production + Logger.getInstance().warn('DeferredViewHelper: Failed to load deferred view', error); + return null; + } + } + + /** + * Safely get a VoiceInputView from a leaf + * @param leaf The workspace leaf to get the view from + * @returns The VoiceInputView if available, null otherwise + */ + static async safeGetVoiceInputView(leaf: WorkspaceLeaf): Promise { + const view = await this.safeGetView(leaf); + // Narrow by constructor name to the concrete VoiceInputView + return (view && view.constructor?.name === 'VoiceInputView') + ? (view as unknown as VoiceInputView) + : null; + } + + /** + * Check if a leaf is deferred (Obsidian 1.7.2+ only) + * @param leaf The workspace leaf to check + * @returns true if deferred, false otherwise (including older Obsidian versions) + */ + static isDeferred(leaf: WorkspaceLeaf): boolean { + return 'isDeferred' in leaf && typeof leaf.isDeferred === 'boolean' && leaf.isDeferred; + } + + /** + * Safely reveal a leaf and ensure it's loaded + * @param leaf The workspace leaf to reveal + * @param workspace The workspace instance + */ + static async safeRevealLeaf(leaf: WorkspaceLeaf, workspace: { revealLeaf: (leaf: WorkspaceLeaf) => unknown }): Promise { + try { + // Use revealLeaf and await it if it returns a promise (Obsidian 1.7.2+) + const result = workspace.revealLeaf(leaf); + // Handle both sync and async revealLeaf + if (result && typeof (result as Promise).then === 'function') { + await (result as Promise); + } + } catch (error) { + // Use logger for warning - will be controlled by debugMode in production + Logger.getInstance().warn('DeferredViewHelper: Failed to reveal leaf', error); + // Fallback: try calling revealLeaf without awaiting (older versions) + try { + workspace.revealLeaf(leaf); + } catch (fallbackError) { + // If even the fallback fails, just log and continue + Logger.getInstance().warn('DeferredViewHelper: Fallback reveal also failed', fallbackError); + } + } + } +} diff --git a/src/utils/Logger.ts b/src/utils/Logger.ts new file mode 100644 index 0000000..882ebcd --- /dev/null +++ b/src/utils/Logger.ts @@ -0,0 +1,245 @@ +/** + * Centralized logging utility for debug output + * Provides consistent formatting and conditional output based on debugMode + */ + +export enum LogLevel { + ERROR = 0, + WARN = 1, + INFO = 2, + DEBUG = 3, + TRACE = 4 +} + +export interface LoggerConfig { + debugMode: boolean; + logLevel?: LogLevel; + prefix?: string; +} + +export class Logger { + private static instance: Logger | null = null; + private config: LoggerConfig = { + debugMode: false, + logLevel: LogLevel.INFO, + prefix: '[Voice Transcription]' + }; + private moduleLoggers: Map = new Map(); + private static readonly MAX_MODULE_LOGGERS = 100; // Prevent unbounded growth + + private constructor(config?: Partial) { + if (config) { + this.updateConfig(config); + } + } + + /** + * Get or create the singleton logger instance + */ + static getInstance(config?: Partial): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(config); + } else if (config) { + Logger.instance.updateConfig(config); + } + return Logger.instance; + } + + /** + * Create a logger for a specific module/component + */ + static getLogger(moduleName: string): Logger { + const mainLogger = Logger.getInstance(); + const existing = mainLogger.moduleLoggers.get(moduleName); + if (!existing) { + // Check size limit to prevent memory leaks + if (mainLogger.moduleLoggers.size >= Logger.MAX_MODULE_LOGGERS) { + // Clear oldest entries (first 10) when limit is reached + const entries = Array.from(mainLogger.moduleLoggers.entries()); + entries.slice(0, 10).forEach(([key]) => { + mainLogger.moduleLoggers.delete(key); + }); + } + + const moduleLogger = new Logger({ + debugMode: mainLogger.config.debugMode, + logLevel: mainLogger.config.logLevel, + prefix: `${mainLogger.config.prefix} [${moduleName}]` + }); + mainLogger.moduleLoggers.set(moduleName, moduleLogger); + return moduleLogger; + } + return existing; + } + + /** + * Update logger configuration + */ + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + // Update all module loggers, preserving their prefixes + this.moduleLoggers.forEach(logger => { + logger.updateConfig({ + debugMode: this.config.debugMode, + logLevel: this.config.logLevel + // Note: prefix is intentionally not updated to preserve module-specific prefixes + }); + }); + } + + /** + * Check if logging is enabled for a given level + */ + private shouldLog(level: LogLevel): boolean { + if (!this.config.debugMode) { + // In production mode, only show errors and warnings + return level <= LogLevel.WARN; + } + return level <= (this.config.logLevel ?? LogLevel.INFO); + } + + /** + * Format the log message with timestamp and prefix + */ + private formatMessage(level: LogLevel, message: string): string { + const timestamp = new Date().toISOString().split('T')[1].slice(0, 12); + return `${timestamp} ${LogLevel[level].padEnd(5)} ${this.config.prefix} ${message}`; + } + + /** + * Log an error message + */ + error(message: string, error?: unknown): void { + if (this.shouldLog(LogLevel.ERROR)) { + const formattedMsg = this.formatMessage(LogLevel.ERROR, message); + if (error) { + console.error(formattedMsg, error); + // Always show stack trace for errors, regardless of debug mode + if (error instanceof Error && error.stack) { + console.error('Stack trace:', error.stack); + } + } else { + console.error(formattedMsg); + } + } + } + + /** + * Log a warning message + */ + warn(message: string, data?: unknown): void { + if (this.shouldLog(LogLevel.WARN)) { + const formattedMsg = this.formatMessage(LogLevel.WARN, message); + if (data !== undefined) { + console.warn(formattedMsg, data); + } else { + console.warn(formattedMsg); + } + } + } + + /** + * Log an info message + */ + info(message: string, data?: unknown): void { + if (this.shouldLog(LogLevel.INFO)) { + const formattedMsg = this.formatMessage(LogLevel.INFO, message); + if (data !== undefined) { + console.log(formattedMsg, data); + } else { + console.log(formattedMsg); + } + } + } + + /** + * Log a debug message + */ + debug(message: string, data?: unknown): void { + if (this.shouldLog(LogLevel.DEBUG)) { + const formattedMsg = this.formatMessage(LogLevel.DEBUG, message); + if (data !== undefined) { + console.log(formattedMsg, data); + } else { + console.log(formattedMsg); + } + } + } + + /** + * Log a trace message (most verbose) + */ + trace(message: string, data?: unknown): void { + if (this.shouldLog(LogLevel.TRACE)) { + const formattedMsg = this.formatMessage(LogLevel.TRACE, message); + if (data !== undefined) { + console.log(formattedMsg, data); + } else { + console.log(formattedMsg); + } + } + } + + /** + * Log method entry (for tracing execution flow) + */ + enter(methodName: string, params?: unknown): void { + if (this.shouldLog(LogLevel.TRACE)) { + const message = `→ ${methodName}`; + if (params !== undefined) { + this.trace(message, params); + } else { + this.trace(message); + } + } + } + + /** + * Log method exit (for tracing execution flow) + */ + exit(methodName: string, result?: unknown): void { + if (this.shouldLog(LogLevel.TRACE)) { + const message = `← ${methodName}`; + if (result !== undefined) { + this.trace(message, result); + } else { + this.trace(message); + } + } + } + + /** + * Log performance timing + */ + time(label: string): void { + if (this.shouldLog(LogLevel.DEBUG)) { + // eslint-disable-next-line no-console + console.time(`${this.config.prefix} ${label}`); + } + } + + /** + * End performance timing + */ + timeEnd(label: string): void { + if (this.shouldLog(LogLevel.DEBUG)) { + // eslint-disable-next-line no-console + console.timeEnd(`${this.config.prefix} ${label}`); + } + } + + /** + * Create a scoped logger for a specific operation + */ + scope(scopeName: string): Logger { + return new Logger({ + debugMode: this.config.debugMode, + logLevel: this.config.logLevel, + prefix: `${this.config.prefix} [${scopeName}]` + }); + } +} + +// Export convenience functions +export const logger = Logger.getInstance(); +export const getLogger = Logger.getLogger; diff --git a/src/utils/ObsidianHttpClient.ts b/src/utils/ObsidianHttpClient.ts new file mode 100644 index 0000000..548aaa7 --- /dev/null +++ b/src/utils/ObsidianHttpClient.ts @@ -0,0 +1,118 @@ +import { requestUrl } from 'obsidian'; + +/** + * Obsidian環境でのHTTPクライアント + * + * CLAUDE.mdの哲学に従った設計: + * - CORS制限を回避するObsidian APIを使用 + * - 共通のエラーハンドリング + * - 型安全性の確保 + */ +export class ObsidianHttpClient { + /** + * Low-level request helper returning status and parsed body + */ + static async request(opts: { + url: string; + method?: string; + headers?: Record; + body?: ArrayBuffer | string; + }): Promise<{ status: number; json: unknown; text: string }> { + const { url, method = 'GET', headers = {}, body } = opts; + const res = await requestUrl({ url, method, headers, body, throw: false }); + return { status: res.status, json: res.json, text: res.text }; + } + + /** + * GET JSON helper + */ + static async getJson(url: string, headers: Record = {}) { + return this.request({ url, method: 'GET', headers }); + } + /** + * JSONデータをPOSTリクエスト + */ + static async postJson(url: string, data: unknown, headers: Record = {}) { + const res = await requestUrl({ + url, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify(data), + throw: false + }); + return { status: res.status, json: res.json }; + } + + /** + * FormDataをmultipart/form-dataとしてPOST + */ + static async postFormData(url: string, formData: FormData, headers: Record = {}) { + const boundary = `----ObsidianFormBoundary${Math.random().toString(16).slice(2)}`; + const body = await this.formDataToArrayBuffer(formData, boundary); + + const response = await requestUrl({ + url, + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + ...headers + }, + body, + throw: false + }); + + return { status: response.status, json: response.json }; + } + + /** + * FormDataをArrayBufferに変換 + */ + private static async formDataToArrayBuffer(formData: FormData, boundary: string): Promise { + const textEncoder = new TextEncoder(); + const parts: Uint8Array[] = []; + + // FormDataのエントリーを配列に変換(TypeScript互換性のため) + const entries: Array<[string, FormDataEntryValue]> = []; + // FormData.prototype.entries() の代替実装 + formData.forEach((value, key) => { + entries.push([key, value]); + }); + + for (const [name, value] of entries) { + parts.push(textEncoder.encode(`--${boundary}\r\n`)); + + if (value != null && typeof value === 'object' && 'arrayBuffer' in value) { + const blobValue = value as File | Blob; + const filename = blobValue instanceof File ? blobValue.name : 'blob'; + parts.push(textEncoder.encode( + `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` + + `Content-Type: ${blobValue.type || 'application/octet-stream'}\r\n\r\n` + )); + parts.push(new Uint8Array(await blobValue.arrayBuffer())); + } else { + parts.push(textEncoder.encode( + `Content-Disposition: form-data; name="${name}"\r\n\r\n` + + String(value) + )); + } + parts.push(textEncoder.encode('\r\n')); + } + + parts.push(textEncoder.encode(`--${boundary}--\r\n`)); + + // すべてのパートを結合 + const totalLength = parts.reduce((sum, part) => sum + part.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + + return result.buffer; + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..54b8ecf --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,5 @@ +export { Logger, getLogger, LogLevel } from './Logger'; +export { mergeSettings, hasSettingsKey } from './settings-utils'; +export { migrateCorrectionEntries, patternsToString, stringToPatterns } from './migration'; +export { DeferredViewHelper } from './DeferredViewHelper'; +// export { ObsidianHttpClient } from './ObsidianHttpClient'; // 未使用: requestUrlを直接使用 diff --git a/src/utils/migration.ts b/src/utils/migration.ts new file mode 100644 index 0000000..68929d9 --- /dev/null +++ b/src/utils/migration.ts @@ -0,0 +1,38 @@ +import { CorrectionEntry, CorrectionEntryLegacy } from '../interfaces'; + +/** + * データマイグレーション関数 + * 旧形式の補正エントリを新形式に変換する + */ +export function migrateCorrectionEntries( + entries: (CorrectionEntry | CorrectionEntryLegacy)[] +): CorrectionEntry[] { + return entries.map(entry => { + if (Array.isArray(entry.from)) { + // 既に新形式の場合 + return entry as CorrectionEntry; + } else { + // 旧形式から変換 + const legacyEntry = entry as CorrectionEntryLegacy; + return { + from: [legacyEntry.from], + to: legacyEntry.to + // categoryは削除、priorityも削除 + }; + } + }); +} + +/** + * UI表示用:パターン配列を文字列に変換 + */ +export function patternsToString(patterns: string[]): string { + return patterns.join(', '); +} + +/** + * UI入力用:文字列をパターン配列に変換 + */ +export function stringToPatterns(input: string): string[] { + return input ? input.split(',').map(s => s.trim()).filter(s => s) : []; +} diff --git a/src/utils/settings-utils.ts b/src/utils/settings-utils.ts new file mode 100644 index 0000000..53b128c --- /dev/null +++ b/src/utils/settings-utils.ts @@ -0,0 +1,38 @@ +/** + * Type-safe utilities for settings management + */ + +import { VoiceInputSettings } from '../interfaces'; + +/** + * Type-safe function to merge settings objects + */ +export function mergeSettings( + target: VoiceInputSettings, + source: Partial +): void { + (Object.keys(source) as Array).forEach(key => { + const value = source[key]; + if (value !== undefined) { + // Use index signature for type-safe assignment + (target as Record)[key] = value; + } + }); +} + +/** + * Check if a key exists in settings (including legacy keys) + */ +export function hasSettingsKey( + obj: unknown, + key: keyof VoiceInputSettings | string +): obj is Record { + return typeof obj === 'object' && obj !== null && key in obj; +} + +/** + * Type guard for partial settings objects + */ +export function isPartialSettings(obj: unknown): obj is Partial { + return typeof obj === 'object' && obj !== null; +} diff --git a/src/views/VoiceInputView.ts b/src/views/VoiceInputView.ts new file mode 100644 index 0000000..ea41872 --- /dev/null +++ b/src/views/VoiceInputView.ts @@ -0,0 +1,249 @@ +import { + ItemView, + WorkspaceLeaf +} from 'obsidian'; +import type VoiceInputPlugin from '../plugin'; +import { VoiceInputViewUI } from './VoiceInputViewUI'; +import { VoiceInputViewActions } from './VoiceInputViewActions'; +import { DraftManager } from '../managers'; +import { getI18n } from '../services'; + +export const VIEW_TYPE_VOICE_INPUT = 'voice-input-view'; + +/** + * Main Voice Input View - coordinates UI and actions + */ +export class VoiceInputView extends ItemView { + plugin: VoiceInputPlugin; + ui: VoiceInputViewUI; + actions: VoiceInputViewActions; + private blurHandler: ((e: FocusEvent) => void) | null = null; + private autoSaveTimeout: NodeJS.Timeout | null = null; + private periodicSaveInterval: NodeJS.Timeout | null = null; + private static readonly AUTO_SAVE_DELAY = 2000; // 2 seconds + private static readonly PERIODIC_SAVE_INTERVAL = 5000; // 5 seconds + + constructor(leaf: WorkspaceLeaf, plugin: VoiceInputPlugin) { + super(leaf); + this.plugin = plugin; + // Note: this.app is available from ItemView base class + } + + getViewType() { + return VIEW_TYPE_VOICE_INPUT; + } + + getDisplayText() { + return 'Voice Input'; + } + + getIcon() { + return 'microphone'; + } + + async onOpen() { + const containerElement = this.containerEl.children[1]; + if (containerElement instanceof HTMLElement) { + const container = containerElement; + container.empty(); + container.addClass('voice-input-view'); + + // Capture the last active markdown view is handled by the plugin + + // Initialize components + this.ui = new VoiceInputViewUI(this, this.plugin, container); + this.actions = new VoiceInputViewActions(this, this.plugin); + + // Initialize services + await this.actions.initializeServices(); + + // Create UI + this.ui.createUI(); + + // Restore saved draft if exists + await this.restoreDraft(); + + // Set up focus management and auto-save + this.setupFocusManagement(); + this.setupAutoSaveOnBlur(); + this.setupPeriodicSave(); + } + } + + async onClose() { + // Clear any pending auto-save timeout + if (this.autoSaveTimeout) { + clearTimeout(this.autoSaveTimeout); + this.autoSaveTimeout = null; + } + + // Clear periodic save interval + if (this.periodicSaveInterval) { + clearInterval(this.periodicSaveInterval); + this.periodicSaveInterval = null; + } + + // Save any text content + if (this.ui?.textArea?.value) { + await DraftManager.saveDraft(this.app, this.ui.textArea.value, 'view-close'); + } else { + // Clear saved draft if textarea is empty + await DraftManager.clearDraft(this.app); + } + + // Remove focus event listeners + this.cleanupFocusManagement(); + + // Remove blur handler + if (this.blurHandler && this.ui?.textArea) { + this.ui.textArea.removeEventListener('blur', this.blurHandler); + this.blurHandler = null; + } + + // Stop recording if active + if (this.actions.audioRecorder) { + await this.actions.stopRecording(); + } + + // Clean up + this.actions.destroy(); + this.ui.destroy(); + } + + /** + * Update transcription service when settings change + */ + updateTranscriptionService() { + this.actions.updateTranscriptionService(); + } + + /** + * Refresh UI when language changes + */ + refreshUI() { + // Re-create the UI with new language + const containerElement = this.containerEl.children[1]; + if (containerElement instanceof HTMLElement) { + const container = containerElement; + container.empty(); + container.addClass('voice-input-view'); + + // Preserve current text content + const currentText = this.ui?.textArea?.value || ''; + + // Re-create UI components + this.ui = new VoiceInputViewUI(this, this.plugin, container); + this.ui.createUI(); + + // Restore text content + if (currentText) { + this.ui.textArea.value = currentText; + } + + // Re-setup focus management + this.setupFocusManagement(); + this.setupAutoSaveOnBlur(); + this.setupPeriodicSave(); + } + } + + /** + * Set up focus management to handle recording state + */ + private setupFocusManagement() { + // Removed click outside detection to allow recording to continue + // when users interact with other parts of Obsidian + } + + /** + * Clean up focus management event listeners + */ + private cleanupFocusManagement() { + // No cleanup needed as click outside detection has been removed + } + + /** + * Set up auto-save when text area loses focus + */ + private setupAutoSaveOnBlur() { + if (!this.ui?.textArea) return; + + // Remove existing handler if any + if (this.blurHandler) { + this.ui.textArea.removeEventListener('blur', this.blurHandler); + } + + // Create and store the handler + this.blurHandler = async () => { + if (this.ui?.textArea?.value) { + await DraftManager.saveDraft(this.app, this.ui.textArea.value, 'focus-lost'); + } + }; + + // Save draft when text area loses focus + this.ui.textArea.addEventListener('blur', this.blurHandler); + } + + /** + * Restore saved draft text if exists + */ + private async restoreDraft() { + const draftText = await DraftManager.loadDraft(this.app); + if (draftText && this.ui?.textArea) { + this.ui.textArea.value = draftText; + // Show notification that draft was restored + if (this.actions) { + const i18n = getI18n(); + this.actions.setStatusWithTimeout(i18n.t('status.draftRestored')); + } + } + } + + /** + * Set up periodic auto-save that saves textarea content regardless of state + * This simple approach handles all cases including cleared text + */ + private setupPeriodicSave() { + // Clear existing interval if any + if (this.periodicSaveInterval) { + clearInterval(this.periodicSaveInterval); + } + + // Set up periodic save every 5 seconds + this.periodicSaveInterval = setInterval(async () => { + if (this.ui?.textArea) { + const content = this.ui.textArea.value; + if (content.trim()) { + // Save the content + await DraftManager.saveDraft(this.app, content, 'periodic-save'); + } else { + // Clear the draft when content is empty (handles cleared text) + await DraftManager.clearDraft(this.app); + } + } + }, VoiceInputView.PERIODIC_SAVE_INTERVAL); + } + + /** + * Handle text changes with debounced auto-save + */ + onTextChanged() { + // Clear existing timeout + if (this.autoSaveTimeout) { + clearTimeout(this.autoSaveTimeout); + } + + // Set new timeout for auto-save (for responsiveness, keeps existing behavior) + this.autoSaveTimeout = setTimeout(async () => { + if (this.ui?.textArea) { + const content = this.ui.textArea.value; + if (content.trim()) { + await DraftManager.saveDraft(this.app, content, 'auto-save'); + } else { + // Clear saved draft if textarea is empty + await DraftManager.clearDraft(this.app); + } + } + }, VoiceInputView.AUTO_SAVE_DELAY); + } +} diff --git a/src/views/VoiceInputViewActions.ts b/src/views/VoiceInputViewActions.ts new file mode 100644 index 0000000..430f0fa --- /dev/null +++ b/src/views/VoiceInputViewActions.ts @@ -0,0 +1,832 @@ +import { Notice, MarkdownView, normalizePath } from 'obsidian'; +import { AudioRecorder } from '../core'; +import { TranscriptionService } from '../core'; +import { TranscriptionError, TranscriptionErrorType } from '../errors'; +import { SecurityUtils } from '../security'; +import { DraftManager } from '../managers'; +import type { VoiceInputView } from './VoiceInputView'; +import type VoiceInputPlugin from '../plugin'; +import { getI18n, createServiceLogger } from '../services'; +import type { I18nService } from '../interfaces'; +import type { StopReason, RecordingState } from '../interfaces'; +import { Logger } from '../utils'; + +/** + * Handles all recording and text manipulation actions for the Voice Input View + */ +export class VoiceInputViewActions { + private view: VoiceInputView; + private plugin: VoiceInputPlugin; + private i18n: I18nService; + private logger: Logger; + + audioRecorder: AudioRecorder | null = null; + transcriptionService: TranscriptionService | null = null; + recordingState: RecordingState = { + isRecording: false, + isPushToTalkMode: false, + processingQueue: [] + }; + private isProcessingAudio = false; + private statusTimer: NodeJS.Timeout | null = null; + private clearConfirmTimer: NodeJS.Timeout | null = null; + private clearPressCount = 0; + + constructor(view: VoiceInputView, plugin: VoiceInputPlugin) { + this.view = view; + this.plugin = plugin; + this.i18n = getI18n(); + this.logger = createServiceLogger('VoiceInputViewActions'); + } + + get app() { + return this.view.app; + } + + /** + * Initialize services + */ + async initializeServices() { + // Initialize transcription service + const decryptedApiKey = this.plugin.settings.openaiApiKey; + this.transcriptionService = new TranscriptionService( + decryptedApiKey, + this.plugin.settings.customDictionary + ); + this.applyTranscriptionSettings(); + } + + /** + * Apply transcription settings to the service + */ + private applyTranscriptionSettings() { + if (!this.transcriptionService) return; + + this.transcriptionService.setModel(this.plugin.settings.transcriptionModel); + // Apply transcription correction and custom dictionary settings + this.transcriptionService.setTranscriptionCorrection(this.plugin.settings.enableTranscriptionCorrection); + this.transcriptionService.setCustomDictionary(this.plugin.settings.customDictionary); + + // 後方互換性のため、詳細設定も更新 + this.transcriptionService.updateCorrectorSettings({ + enabled: this.plugin.settings.enableTranscriptionCorrection + }); + } + + /** + * Update transcription service settings + */ + async updateTranscriptionService() { + if (this.transcriptionService) { + const decryptedApiKey = this.plugin.settings.openaiApiKey; + this.transcriptionService.updateApiKey(decryptedApiKey); + this.transcriptionService.setCustomDictionary(this.plugin.settings.customDictionary); + this.applyTranscriptionSettings(); + } + } + + /** + * Handle microphone status changes + */ + private handleMicrophoneStatus(status: 'initializing' | 'ready' | 'error') { + if (status === 'initializing') { + this.view.ui.statusEl.setText(this.i18n.t('status.recording.micInit')); + this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordPreparing')); + } else if (status === 'ready') { + this.view.ui.statusEl.setText(this.i18n.t('status.recording.recording')); + // Show different text for push-to-talk mode + if (this.recordingState.isPushToTalkMode) { + this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStopPushToTalk')); + } else { + this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStop')); + } + this.view.ui.recordButton.addClass('recording'); + } else if (status === 'error') { + this.view.ui.statusEl.setText(this.i18n.t('status.error')); + this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStart')); + this.view.ui.recordButton.removeClass('recording'); + new Notice(this.i18n.t('error.audio.micInitFailed')); + } + } + + /** + * Toggle recording on/off + */ + async toggleRecording() { + if (this.audioRecorder && this.audioRecorder.isActive()) { + await this.stopRecording(); + } else { + await this.startRecording(); + } + } + + /** + * Start recording + */ + async startRecording() { + // Validate API key + if (!this.plugin.settings.openaiApiKey) { + new Notice(this.i18n.t('error.api.noKey')); + return; + } + + if (!SecurityUtils.validateOpenAIAPIKey(this.plugin.settings.openaiApiKey)) { + new Notice(this.i18n.t('error.api.invalidKey')); + return; + } + + // Skip secure context check in Obsidian + // Obsidian provides its own secure environment + + try { + this.logger.info('Starting recording session', { + mode: 'continuous', + maxDuration: this.plugin.settings.maxRecordingSeconds + }); + + // Dispose existing audio recorder if any + if (this.audioRecorder) { + this.audioRecorder.dispose(); + this.audioRecorder = null; + } + + // Update UI + this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStop')); + this.view.ui.recordButton.addClass('recording'); + this.view.ui.statusEl.setText(this.i18n.t('status.recording.preparing')); + this.view.ui.setButtonsEnabled(false); + this.view.ui.showCancelButton(true); // Show cancel button + + // Initialize audio recorder for continuous recording mode + // AudioWorklet requires serving from same origin as the page + // In Obsidian, we'll let it fallback to ScriptProcessor + this.audioRecorder = new AudioRecorder({ + useVAD: false, // 常に連続録音モード + onSpeechEnd: async (audioBlob) => { + // 連続録音モードでのonSpeechEndは最大時間到達時のみ + this.recordingState.isRecording = false; + this.updateUIAfterStop(); + await this.processRecordedAudio(audioBlob, { type: 'max-duration' }); + }, + onMicrophoneStatusChange: (status) => { + this.handleMicrophoneStatus(status); + }, + visualizerContainer: this.view.ui.visualizerContainer, + useSimpleVisualizer: false, + maxRecordingSeconds: this.plugin.settings.maxRecordingSeconds + }); + + await this.audioRecorder.initialize(); + await this.audioRecorder.startRecording(); + this.recordingState.isRecording = true; + + // Enable other buttons + this.view.ui.setButtonsEnabled(true); + + } catch (error) { + const transcriptionError = error instanceof TranscriptionError + ? error + : TranscriptionError.fromError(error, TranscriptionErrorType.AUDIO_DEVICE_ERROR); + + this.logger.error('Error starting recording', transcriptionError); + this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStart')); + this.view.ui.recordButton.removeClass('recording'); + this.view.ui.statusEl.setText(this.i18n.t('status.error')); + this.view.ui.setButtonsEnabled(true); + new Notice(transcriptionError.getLocalizedMessage()); + } + } + + /** + * Stop recording with reason + */ + async stopRecording(reason?: StopReason) { + if (!this.audioRecorder) { + return; + } + + // Default to manual stop if no reason provided + const stopReason = reason || { type: 'manual' as const }; + this.recordingState.lastStopReason = stopReason; + + try { + this.logger.info('Stopping recording session', { + reason: stopReason.type, + hasAudioRecorder: !!this.audioRecorder + }); + + // Get the audio blob before destroying the recorder + const audioBlob = await this.audioRecorder.stopRecording(); + + // Clean up audio recorder (check if still exists) + if (this.audioRecorder) { + this.audioRecorder.destroy(); + this.audioRecorder = null; + } + this.recordingState.isRecording = false; + + // Update UI once + this.updateUIAfterStop(); + + // Process audio for manual stop + if (audioBlob && audioBlob.size > 0) { + await this.processRecordedAudio(audioBlob, stopReason); + } + + } catch (error) { + const transcriptionError = error instanceof TranscriptionError + ? error + : TranscriptionError.fromError(error, TranscriptionErrorType.AUDIO_DEVICE_ERROR); + + this.logger.error('Error stopping recording', transcriptionError); + this.updateUIAfterError(); + new Notice(transcriptionError.getLocalizedMessage()); + } + } + + /** + * Update UI after recording stops + */ + private updateUIAfterStop(): void { + if (this.view.ui.recordButton) { + this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStart')); + this.view.ui.recordButton.removeClass('recording'); + } + if (this.view.ui.statusEl) { + this.view.ui.statusEl.setText(this.i18n.t('status.idle')); + this.view.ui.statusEl.removeClass('processing'); + this.view.ui.statusEl.removeClass('error'); + } + this.view.ui.showCancelButton(false); // Hide cancel button + } + + /** + * Update UI after error + */ + private updateUIAfterError(): void { + if (this.view.ui.recordButton) { + this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStart')); + this.view.ui.recordButton.removeClass('recording'); + } + if (this.view.ui.statusEl) { + this.view.ui.statusEl.setText(this.i18n.t('status.error')); + this.view.ui.statusEl.removeClass('processing'); + this.view.ui.statusEl.addClass('error'); + } + this.view.ui.showCancelButton(false); // Hide cancel button + } + + /** + * Set status text and clear it after a timeout + */ + setStatusWithTimeout(text: string, timeout = 3000): void { + if (!this.view.ui.statusEl) return; + + // Clear existing timer + if (this.statusTimer) { + clearTimeout(this.statusTimer); + this.statusTimer = null; + } + + // Set new status + this.view.ui.statusEl.setText(text); + this.view.ui.statusEl.removeClass('processing'); + this.view.ui.statusEl.removeClass('error'); + + // Set timer to clear status + this.statusTimer = setTimeout(() => { + if (this.view.ui.statusEl && !this.recordingState.isRecording && !this.isProcessingAudio) { + this.view.ui.statusEl.setText(this.i18n.t('status.idle')); + } + this.statusTimer = null; + }, timeout); + } + + /** + * Process recorded audio with proper notifications + */ + private async processRecordedAudio(audioBlob: Blob, stopReason: StopReason): Promise { + // Show appropriate status based on stop reason + if (this.view.ui.statusEl) { + switch (stopReason.type) { + case 'vad-auto': + this.setStatusWithTimeout(this.i18n.t('status.transcription.vadAutoStopped')); + break; + case 'max-duration': + this.setStatusWithTimeout(this.i18n.t('status.transcription.maxDurationReached')); + break; + // No status update for manual stop + } + } + + // Add to queue + this.recordingState.processingQueue.push({ + audioBlob, + timestamp: Date.now(), + stopReason + }); + + // Always update status when queue changes (unless a timed status is active) + if (!this.statusTimer) { + this.updateProcessingStatus(); + } + + // Process queue if not already processing + if (!this.isProcessingAudio) { + this.processQueue(); + } + } + + /** + * Process items from the queue + */ + private async processQueue(): Promise { + if (this.isProcessingAudio || this.recordingState.processingQueue.length === 0) { + return; + } + + this.isProcessingAudio = true; + + while (this.recordingState.processingQueue.length > 0) { + // Get the first item without removing it yet + const item = this.recordingState.processingQueue[0]; + if (!item) { + this.recordingState.processingQueue.shift(); + continue; + } + + // Update status before processing (queue still contains current item) + this.updateProcessingStatus(); + + try { + await this.handleSpeechEnd(item.audioBlob); + } catch (error) { + this.logger.error('Error processing audio', error); + } finally { + // Remove the processed item after completion + this.recordingState.processingQueue.shift(); + } + } + + this.isProcessingAudio = false; + // Don't update status here if a status timer is active + // This prevents overwriting status messages set by setStatusWithTimeout + if (!this.statusTimer) { + this.updateProcessingStatus(); + } + + // Check if new items were added while processing + if (this.recordingState.processingQueue.length > 0) { + // Use setTimeout to avoid potential stack overflow + setTimeout(() => this.processQueue(), 0); + } + } + + /** + * Update processing status display + */ + private updateProcessingStatus(): void { + if (!this.view.ui.statusEl) return; + + // Don't update if a status message with timer is active + if (this.statusTimer) return; + + const queueLength = this.recordingState.processingQueue.length; + + if (this.isProcessingAudio) { + let statusText = this.i18n.t('status.processing.transcribing'); + // When processing, the first item is being processed, others are waiting + const waitingCount = queueLength > 0 ? queueLength - 1 : 0; + if (waitingCount > 0) { + statusText += ` (${waitingCount} 待機中)`; + } + this.view.ui.statusEl.setText(statusText); + this.view.ui.statusEl.addClass('processing'); + this.view.ui.statusEl.removeClass('error'); + } else if (!this.recordingState.isRecording) { + this.view.ui.statusEl.setText(this.i18n.t('status.idle')); + this.view.ui.statusEl.removeClass('processing'); + this.view.ui.statusEl.removeClass('error'); + } + } + + /** + * Handle speech end event from audio recorder + * @param audioBlob - The recorded audio blob + */ + async handleSpeechEnd(audioBlob: Blob): Promise { + + // Validate that the view and UI components are still valid + if (!this.view || !this.view.ui || !this.view.ui.textArea) { + return; + } + + // Check if the plugin is still active + if (!this.plugin || !this.app) { + return; + } + + // Skip extremely small audio blobs (likely empty or corrupt) + // WebM header alone is ~200-300 bytes, so anything under 500 bytes is suspicious + const MIN_VALID_AUDIO_SIZE = 500; // bytes + // Minimum valid audio size for continuous recording + const minSpeechBytes = MIN_VALID_AUDIO_SIZE; + + if (audioBlob.size < minSpeechBytes) { + this.setStatusWithTimeout(this.i18n.t('status.transcription.audioTooShort')); + return; + } + + // Ensure transcription service is initialized + if (!this.transcriptionService) { + await this.initializeServices(); + + // Re-validate after async initialization + if (!this.transcriptionService) { + return; + } + } + + try { + // No longer block UI during processing - allow parallel recording + // Update status is now handled by updateProcessingStatus() + + // Transcribe audio + if (!this.transcriptionService) { + throw new Error('Transcription service not available'); + } + const result = await this.transcriptionService.transcribeAudio(audioBlob, this.plugin.settings.pluginLanguage); + + // Check if result is empty + if (!result.text || result.text.trim() === '') { + this.setStatusWithTimeout(this.i18n.t('status.transcription.noAudioDetected')); + return; + } + + // Validate UI elements still exist before updating them + if (this.view.ui.textArea) { + // Append to text area + if (this.view.ui.textArea.value) { + this.view.ui.textArea.value += '\n\n'; + } + this.view.ui.textArea.value += result.text; + + // Auto scroll to bottom + this.view.ui.textArea.scrollTop = this.view.ui.textArea.scrollHeight; + + // Auto-save draft after successful transcription + this.saveDraft(); + + // Show success status briefly + this.setStatusWithTimeout(this.i18n.t('status.processing.completed')); + } else { + this.logger.error('TextArea not found during transcription insertion'); + } + + // Queue status will be updated after timeout by updateProcessingStatus() + + } catch (error) { + const transcriptionError = error instanceof TranscriptionError + ? error + : TranscriptionError.fromError(error, TranscriptionErrorType.TRANSCRIPTION_FAILED); + + this.logger.error('Transcription error', transcriptionError); + new Notice(transcriptionError.getLocalizedMessage()); + if (this.view.ui.statusEl) { + this.view.ui.statusEl.setText(this.i18n.t('status.error')); + this.view.ui.statusEl.addClass('error'); + this.view.ui.statusEl.removeClass('processing'); + } + } finally { + // Processing state is now managed by processQueue + } + } + + /** + * Clear text from textarea + */ + clearText() { + const text = this.view.ui.textArea.value.trim(); + if (!text) { + this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToClear')); + return; + } + + // Double-press confirmation (3 seconds timeout) + this.clearPressCount++; + + if (this.clearPressCount === 1) { + // First press - show confirmation message + this.setStatusWithTimeout(this.i18n.t('status.warning.clearConfirm')); + + // Set timer to reset press count + this.clearConfirmTimer = setTimeout(() => { + this.clearPressCount = 0; + this.clearConfirmTimer = null; + }, 3000); + } else if (this.clearPressCount === 2) { + // Second press within timeout - clear the text + if (this.clearConfirmTimer) { + clearTimeout(this.clearConfirmTimer); + this.clearConfirmTimer = null; + } + this.clearPressCount = 0; + + // Clear the text + this.view.ui.textArea.value = ''; + this.setStatusWithTimeout(this.i18n.t('status.memoCleared')); + } + } + + /** + * Copy text to clipboard + */ + async copyToClipboard() { + const text = this.view.ui.textArea.value.trim(); + if (!text) { + this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToCopy')); + return; + } + + try { + await navigator.clipboard.writeText(text); + this.setStatusWithTimeout(this.i18n.t('status.clipboardCopied')); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error('Clipboard copy error', error); + new Notice(this.i18n.t('notification.error.clipboardFailed') + ': ' + errorMessage); + this.view.ui.statusEl.setText(this.i18n.t('status.error')); + } + } + + /** + * Clean up text using GPT + */ + async cleanupText() { + if (!this.view.ui.textArea.value.trim()) { + this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToCleanup')); + return; + } + + // Ensure transcription service is initialized + if (!this.transcriptionService) { + await this.initializeServices(); + } + + if (!this.transcriptionService) { + new Notice(this.i18n.t('notification.warning.serviceInitFailed')); + return; + } + + try { + this.view.ui.statusEl.setText(this.i18n.t('status.cleanupInProgress')); + this.view.ui.setButtonsEnabled(false); + + // Use the dictionary corrector for cleanup + const corrector = this.transcriptionService.getCorrector(); + const cleanedText = await corrector.correct(this.view.ui.textArea.value); + this.view.ui.textArea.value = cleanedText; + + this.setStatusWithTimeout(this.i18n.t('status.cleanupCompleted')); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error('Cleanup error', error); + new Notice(this.i18n.t('notification.error.cleanupFailed') + errorMessage); + this.view.ui.statusEl.setText(this.i18n.t('status.error')); + } finally { + this.view.ui.setButtonsEnabled(true); + } + } + + /** + * Cancel recording without processing + */ + async cancelRecording() { + if (!this.audioRecorder || !this.audioRecorder.isActive()) { + return; + } + + try { + // Stop recording and discard audio + await this.audioRecorder.stopRecording(); + + // Clean up audio recorder + if (this.audioRecorder) { + this.audioRecorder.destroy(); + this.audioRecorder = null; + } + + this.recordingState.isRecording = false; + + // Update UI + this.updateUIAfterStop(); + this.view.ui.setButtonsEnabled(true); + this.setStatusWithTimeout(this.i18n.t('status.recording.cancelled')); + + } catch (error) { + this.logger.error('Error cancelling recording', error); + this.updateUIAfterError(); + this.view.ui.setButtonsEnabled(true); + } + } + + /** + * Insert text to active markdown note + */ + async insertToNote() { + const text = this.view.ui.textArea.value.trim(); + if (!text) { + this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToInsert')); + return; + } + + // Use ViewManager to find a suitable markdown view + const viewManager = this.plugin.getViewManager(); + let targetView: MarkdownView | null = viewManager.findTargetMarkdownView(); + + // Method 4: Create a new note as fallback + if (!targetView) { + try { + // Generate timestamp for filename + const timestamp = new Date().toISOString() + .replace(/T/, '-') + .replace(/:/g, '-') + .replace(/\..+/, ''); + + const fileName = `Voice-Memo-${timestamp}.md`; + const normalizedFileName = normalizePath(fileName); + const newFile = await this.app.vault.create(normalizedFileName, ''); + + // Open the new file in a new tab for better UX + const leaf = this.app.workspace.getLeaf('tab'); + await leaf.openFile(newFile); + if (leaf.view instanceof MarkdownView) { + targetView = leaf.view; + } else { + throw new Error('Failed to open markdown view for new file'); + } + + new Notice(this.i18n.t('notification.success.newNoteCreated')); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + new Notice(this.i18n.t('notification.error.noteCreateFailed') + ': ' + errorMessage); + await navigator.clipboard.writeText(text); + return; + } + } + + // Insert the text + if (targetView && targetView.editor) { + const editor = targetView.editor; + + // Make the target view active + this.app.workspace.setActiveLeaf(targetView.leaf, { focus: true }); + + // Get cursor position + const cursor = editor.getCursor(); + + // Insert at cursor position + editor.replaceRange(text, cursor); + + // Move cursor to end of inserted text + const newCursor = { + line: cursor.line, + ch: cursor.ch + text.length + }; + editor.setCursor(newCursor); + + // Clear text area after insertion + this.view.ui.textArea.value = ''; + this.setStatusWithTimeout(this.i18n.t('status.noteInserted')); + + // ViewManagerがターゲットビューを記録(必要に応じて) + } else { + new Notice(this.i18n.t('notification.warning.noEditorFound')); + await navigator.clipboard.writeText(text); + } + } + + /** + * Append text to the end of active markdown note + */ + async appendToNote() { + const text = this.view.ui.textArea.value.trim(); + if (!text) { + this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToInsert')); + return; + } + + // Use ViewManager to find a suitable markdown view + const viewManager = this.plugin.getViewManager(); + let targetView: MarkdownView | null = viewManager.findTargetMarkdownView(); + + // Create a new note as fallback (same logic as insertToNote) + if (!targetView) { + try { + // Generate timestamp for filename + const timestamp = new Date().toISOString() + .replace(/T/, '-') + .replace(/:/g, '-') + .replace(/\..+/, ''); + + const fileName = `Voice-Memo-${timestamp}.md`; + const normalizedFileName = normalizePath(fileName); + const newFile = await this.app.vault.create(normalizedFileName, ''); + + // Open the new file in a new tab for better UX + const leaf = this.app.workspace.getLeaf('tab'); + await leaf.openFile(newFile); + if (leaf.view instanceof MarkdownView) { + targetView = leaf.view; + } else { + throw new Error('Failed to open markdown view for new file'); + } + + new Notice(this.i18n.t('notification.success.newNoteCreated')); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + new Notice(this.i18n.t('notification.error.noteCreateFailed') + ': ' + errorMessage); + await navigator.clipboard.writeText(text); + return; + } + } + + // Insert the text at the end of the document + if (targetView && targetView.editor) { + const editor = targetView.editor; + + // Make the target view active + this.app.workspace.setActiveLeaf(targetView.leaf, { focus: true }); + + // Get the last line of the document + const lastLine = editor.lastLine(); + const lastLineLength = editor.getLine(lastLine).length; + + // Insert at end with proper spacing + const insertPosition = { line: lastLine, ch: lastLineLength }; + const textToInsert = '\n\n' + text; + editor.replaceRange(textToInsert, insertPosition); + + // Move cursor to end of inserted text + const newCursor = { + line: lastLine + 2, + ch: text.length + }; + editor.setCursor(newCursor); + + // Clear text area after insertion + this.view.ui.textArea.value = ''; + this.setStatusWithTimeout(this.i18n.t('status.noteAppended')); + + } else { + new Notice(this.i18n.t('notification.warning.noEditorFound')); + await navigator.clipboard.writeText(text); + } + } + + /** + * Clean up resources + */ + destroy() { + // Clear processing queue + this.recordingState.processingQueue = []; + this.isProcessingAudio = false; + + // Clear status timer + if (this.statusTimer) { + clearTimeout(this.statusTimer); + this.statusTimer = null; + } + + // Clear clear confirm timer + if (this.clearConfirmTimer) { + clearTimeout(this.clearConfirmTimer); + this.clearConfirmTimer = null; + } + + // Stop and clean up audio recorder + if (this.audioRecorder) { + // First stop recording if active + this.audioRecorder.stopRecording().catch(error => { + this.logger.error('Error stopping recording during destroy', error); + }); + + // Then destroy the recorder + this.audioRecorder.destroy(); + this.audioRecorder = null; + } + + // Clear any references to prevent memory leaks + this.transcriptionService = null; + } + + /** + * Save current text as draft + */ + private async saveDraft() { + if (this.view.ui?.textArea?.value) { + await DraftManager.saveDraft(this.app, this.view.ui.textArea.value, 'auto-save-transcription'); + } + } +} diff --git a/src/views/VoiceInputViewUI.ts b/src/views/VoiceInputViewUI.ts new file mode 100644 index 0000000..94a316d --- /dev/null +++ b/src/views/VoiceInputViewUI.ts @@ -0,0 +1,444 @@ +import { UI_CONSTANTS } from '../config'; +import { Notice, Setting, ToggleComponent, DropdownComponent, setIcon } from 'obsidian'; +import type { VoiceInputView } from './VoiceInputView'; +import type VoiceInputPlugin from '../plugin'; +import { getI18n } from '../services'; +import type { I18nService } from '../interfaces'; +import { hasInternalSettingAPI } from '../types'; + +/** + * Handles UI creation and management for the Voice Input View + */ +export class VoiceInputViewUI { + private view: VoiceInputView; + private i18n: I18nService; + + // UI elements + container: HTMLElement; + textArea: HTMLTextAreaElement; + recordButton: HTMLButtonElement; + cancelButton: HTMLButtonElement; + formatButton: HTMLButtonElement; + contextCorrectionButton: HTMLButtonElement; + cleanupButton: HTMLButtonElement; + clipboardButton: HTMLButtonElement; + insertButton: HTMLButtonElement; + insertAtCursorButton: HTMLButtonElement; + appendButton: HTMLButtonElement; + clearButton: HTMLButtonElement; + statusEl: HTMLElement; + visualizerContainer: HTMLElement; + settingsContainer: HTMLElement; + firstRowContainer: HTMLElement; + secondRowContainer: HTMLElement; + + // Settings controls + correctionToggle: ToggleComponent; + transcriptionModelDropdown: DropdownComponent; + + // Event handlers + private clickHandler: ((e: MouseEvent) => void) | null = null; + private mouseDownHandler: ((e: MouseEvent) => void) | null = null; + private mouseUpHandler: (() => void) | null = null; + private mouseLeaveHandler: (() => void) | null = null; + private touchStartHandler: ((e: TouchEvent) => void) | null = null; + private touchEndHandler: ((e: TouchEvent) => void) | null = null; + private textChangeHandler: ((e: Event) => void) | null = null; + + constructor(view: VoiceInputView, plugin: VoiceInputPlugin, container: HTMLElement) { + this.view = view; + this.container = container; + this.i18n = getI18n(); + } + + get plugin(): VoiceInputPlugin { + return this.view.plugin; + } + + get app() { + return this.view.app; + } + + /** + * Create all UI elements + */ + createUI() { + // Title bar with settings button + const titleBar = this.container.createDiv('voice-input-title-bar'); + const titleEl = titleBar.createEl('h3', { text: this.i18n.t('ui.titles.main') }); + titleEl.addClass('voice-input-title'); + + // Settings gear button + const settingsButton = titleBar.createEl('button', { + cls: 'voice-input-settings-button' + }); + setIcon(settingsButton, 'settings'); + settingsButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.settingsButton')); + settingsButton.addEventListener('click', () => { + // Open settings tab using type-safe approach + if (hasInternalSettingAPI(this.app) && this.app.setting) { + this.app.setting.open(); + this.app.setting.openTabById(this.plugin.manifest.id); + } else { + new Notice('Settings panel is not available'); + } + }); + + // Settings panel + this.settingsContainer = this.container.createDiv('voice-input-settings-panel'); + this.createSettingsPanel(); + + // Visualizer container + this.visualizerContainer = this.container.createDiv('voice-input-visualizer'); + + // Status display container (between visualizer and text area) + const statusContainer = this.container.createDiv('voice-input-status-container'); + this.statusEl = statusContainer.createEl('div', { + text: this.i18n.t('status.idle'), + cls: 'voice-input-status-text' + }); + + // Text area + const textContainer = this.container.createDiv('voice-input-text-container'); + this.textArea = textContainer.createEl('textarea', { + placeholder: this.i18n.t('ui.placeholders.textarea'), + cls: 'voice-input-textarea' + }); + + // Setup auto-save on text changes + this.setupTextChangeListener(); + + // First row buttons (コピー、クリア) + this.firstRowContainer = this.container.createDiv('voice-input-button-group voice-input-action-buttons'); + + // Copy button + this.clipboardButton = this.firstRowContainer.createEl('button', { + cls: 'voice-input-button' + }); + this.clipboardButton.createEl('span', { + cls: 'voice-input-button-text', + text: this.i18n.t('ui.buttons.copy') + }); + this.clipboardButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.copy')); + this.clipboardButton.addEventListener('click', () => this.view.actions.copyToClipboard()); + + // Clear button + this.clearButton = this.firstRowContainer.createEl('button', { + cls: 'voice-input-button' + }); + this.clearButton.createEl('span', { + cls: 'voice-input-button-text', + text: this.i18n.t('ui.buttons.clear') + }); + this.clearButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.clear')); + this.clearButton.addEventListener('click', () => { + this.view.actions.clearText(); + }); + + // Second row buttons (カーソル位置に挿入、末尾に挿入) + this.secondRowContainer = this.container.createDiv('voice-input-button-group voice-input-action-buttons'); + + // Insert at cursor button + this.insertAtCursorButton = this.secondRowContainer.createEl('button', { + cls: 'voice-input-button' + }); + this.insertAtCursorButton.createEl('span', { + cls: 'voice-input-button-text', + text: this.i18n.t('ui.buttons.insertAtCursor') + }); + this.insertAtCursorButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.insertAtCursor')); + this.insertAtCursorButton.addEventListener('click', () => this.view.actions.insertToNote()); + + // Append button + this.appendButton = this.secondRowContainer.createEl('button', { + cls: 'voice-input-button' + }); + this.appendButton.createEl('span', { + cls: 'voice-input-button-text', + text: this.i18n.t('ui.buttons.append') + }); + this.appendButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.append')); + this.appendButton.addEventListener('click', () => this.view.actions.appendToNote()); + + // Keep the old insertButton for backward compatibility (set to hidden) + /** + * @deprecated Use insertAtCursorButton instead. This is kept for backward compatibility and refers to the same element. + */ + this.insertButton = this.insertAtCursorButton; + + // Cancel button (hidden initially, replaces entire first row when recording) + this.cancelButton = this.container.createEl('button', { + text: this.i18n.t('ui.buttons.cancel'), + cls: 'voice-input-cancel-button-full voice-input-hidden' + }); + this.cancelButton.addEventListener('click', () => { + this.view.actions.cancelRecording(); + }); + + // Second row - Record button container + const recordButtonContainer = this.container.createDiv('voice-input-record-container'); + + // Record button + this.recordButton = recordButtonContainer.createEl('button', { + text: this.i18n.t('ui.buttons.recordStart'), + cls: 'voice-input-record-button' + }); + + // Add both click and push-to-talk functionality + this.setupRecordButtonHandlers(); + + // Legacy buttons (hidden) + this.formatButton = document.createElement('button'); + this.formatButton.classList.add('voice-input-hidden'); + + this.contextCorrectionButton = document.createElement('button'); + this.contextCorrectionButton.classList.add('voice-input-hidden'); + + this.cleanupButton = document.createElement('button'); + this.cleanupButton.classList.add('voice-input-hidden'); + } + + /** + * Create settings panel with quick controls + */ + private createSettingsPanel() { + // Transcription model dropdown + new Setting(this.settingsContainer) + .setName(this.i18n.t('ui.settings.transcriptionModel')) + .setClass('voice-input-inline-setting') + .addDropdown(dropdown => { + this.transcriptionModelDropdown = dropdown; + dropdown + .addOption('gpt-4o-transcribe', this.i18n.t('ui.options.modelFull')) + .addOption('gpt-4o-mini-transcribe', this.i18n.t('ui.options.modelMini')) + .setValue(this.plugin.settings.transcriptionModel) + .onChange(async (value) => { + this.plugin.settings.transcriptionModel = value as 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe'; + await this.plugin.saveSettings(); + this.view.actions.updateTranscriptionService(); + }); + }); + + // AI Post-processing section + new Setting(this.settingsContainer) + .setName(this.i18n.t('ui.settings.aiPostProcessing')) + .setClass('voice-input-inline-setting') + .addToggle(toggle => { + this.correctionToggle = toggle; + toggle + .setValue(this.plugin.settings.enableTranscriptionCorrection) + .onChange(async (value) => { + this.plugin.settings.enableTranscriptionCorrection = value; + await this.plugin.saveSettings(); + this.view.actions.updateTranscriptionService(); + // Enable/disable the model dropdown + }); + }); + } + + /** + * Setup record button event handlers for both click and push-to-talk + */ + private setupRecordButtonHandlers() { + let longPressTimer: NodeJS.Timeout | null = null; + let isPushToTalk = false; + // let pressStartTime = 0; + + // Create event handlers + this.clickHandler = (_e) => { + // If this was part of a push-to-talk gesture, ignore it + if (isPushToTalk || longPressTimer) { + // Don't reset isPushToTalk here - let the timeout handle it + return; + } + + this.view.actions.toggleRecording(); + }; + + // Push-to-talk functionality with delayed start + const startLongPress = () => { + // pressStartTime = Date.now(); + + // Start a timer to begin recording after threshold + longPressTimer = setTimeout(() => { + isPushToTalk = true; + longPressTimer = null; + + if (!this.view.actions.audioRecorder || !this.view.actions.audioRecorder.isActive()) { + this.recordButton.setText(this.i18n.t('ui.buttons.recordPushToTalk')); + this.view.actions.recordingState.isPushToTalkMode = true; + this.view.actions.startRecording().catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + new Notice(this.i18n.t('error.audio.recordingFailed') + ': ' + errorMessage); + }); + } + }, UI_CONSTANTS.PUSH_TO_TALK_THRESHOLD); + }; + + const endLongPress = () => { + // const pressDuration = Date.now() - pressStartTime; + + // Cancel timer if still running (short press) + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + return; + } + + // If push-to-talk was active, stop recording + if (isPushToTalk && this.view.actions.audioRecorder && this.view.actions.audioRecorder.isActive()) { + this.view.actions.recordingState.isPushToTalkMode = false; + this.view.actions.stopRecording(); + // Keep isPushToTalk true to prevent click handler + setTimeout(() => { isPushToTalk = false; }, UI_CONSTANTS.PUSH_TO_TALK_RESET_DELAY); + } + }; + + // Create mouse/touch event handlers + this.mouseDownHandler = (e) => { + e.preventDefault(); + startLongPress(); + }; + + this.mouseUpHandler = () => { + endLongPress(); + }; + + this.mouseLeaveHandler = () => { + endLongPress(); + }; + + this.touchStartHandler = (e) => { + e.preventDefault(); + startLongPress(); + }; + + this.touchEndHandler = (e) => { + e.preventDefault(); + endLongPress(); + }; + + // Add event listeners + this.recordButton.addEventListener('click', this.clickHandler); + this.recordButton.addEventListener('mousedown', this.mouseDownHandler); + this.recordButton.addEventListener('mouseup', this.mouseUpHandler); + this.recordButton.addEventListener('mouseleave', this.mouseLeaveHandler); + this.recordButton.addEventListener('touchstart', this.touchStartHandler); + this.recordButton.addEventListener('touchend', this.touchEndHandler); + } + + /** + * Enable/disable action buttons + */ + setButtonsEnabled(enabled: boolean) { + this.cleanupButton.disabled = !enabled; + this.clipboardButton.disabled = !enabled; + this.insertButton.disabled = !enabled; + this.insertAtCursorButton.disabled = !enabled; + this.appendButton.disabled = !enabled; + this.clearButton.disabled = !enabled; + } + + /** + * Show/hide cancel button during recording + */ + showCancelButton(show: boolean) { + if (show) { + // Hide button rows + this.firstRowContainer.classList.add('voice-input-hidden'); + this.firstRowContainer.classList.remove('voice-input-flex'); + this.secondRowContainer.classList.add('voice-input-hidden'); + this.secondRowContainer.classList.remove('voice-input-flex'); + // Show cancel button with flex for centering + this.cancelButton.classList.remove('voice-input-hidden'); + this.cancelButton.classList.add('voice-input-flex'); + } else { + // Show button rows + this.firstRowContainer.classList.remove('voice-input-hidden'); + this.firstRowContainer.classList.add('voice-input-flex'); + this.secondRowContainer.classList.remove('voice-input-hidden'); + this.secondRowContainer.classList.add('voice-input-flex'); + // Hide cancel button + this.cancelButton.classList.add('voice-input-hidden'); + this.cancelButton.classList.remove('voice-input-flex'); + } + } + + /** + * Update settings UI components to reflect current settings + */ + updateSettingsUI() { + // Update correction toggle + if (this.correctionToggle) { + this.correctionToggle.setValue(this.plugin.settings.enableTranscriptionCorrection); + } + + // Update transcription model dropdown + if (this.transcriptionModelDropdown) { + this.transcriptionModelDropdown.setValue(this.plugin.settings.transcriptionModel); + } + + // Update post-processing model dropdown + } + + /** + * Setup text change listener for auto-save + */ + private setupTextChangeListener() { + if (!this.textArea) return; + + // Create the text change handler + this.textChangeHandler = () => { + // Trigger auto-save through the view + this.view.onTextChanged(); + }; + + // Listen to multiple events that indicate text changes + this.textArea.addEventListener('input', this.textChangeHandler); + this.textArea.addEventListener('paste', this.textChangeHandler); + this.textArea.addEventListener('cut', this.textChangeHandler); + } + + /** + * Clean up resources + */ + destroy() { + // Remove text change listener + if (this.textArea && this.textChangeHandler) { + this.textArea.removeEventListener('input', this.textChangeHandler); + this.textArea.removeEventListener('paste', this.textChangeHandler); + this.textArea.removeEventListener('cut', this.textChangeHandler); + } + + // Remove event listeners + if (this.recordButton) { + if (this.clickHandler) { + this.recordButton.removeEventListener('click', this.clickHandler); + } + if (this.mouseDownHandler) { + this.recordButton.removeEventListener('mousedown', this.mouseDownHandler); + } + if (this.mouseUpHandler) { + this.recordButton.removeEventListener('mouseup', this.mouseUpHandler); + } + if (this.mouseLeaveHandler) { + this.recordButton.removeEventListener('mouseleave', this.mouseLeaveHandler); + } + if (this.touchStartHandler) { + this.recordButton.removeEventListener('touchstart', this.touchStartHandler); + } + if (this.touchEndHandler) { + this.recordButton.removeEventListener('touchend', this.touchEndHandler); + } + } + + // Clear references + this.clickHandler = null; + this.mouseDownHandler = null; + this.mouseUpHandler = null; + this.mouseLeaveHandler = null; + this.touchStartHandler = null; + this.touchEndHandler = null; + this.textChangeHandler = null; + } +} diff --git a/src/views/index.ts b/src/views/index.ts new file mode 100644 index 0000000..3c7ff81 --- /dev/null +++ b/src/views/index.ts @@ -0,0 +1,3 @@ +export { VoiceInputView, VIEW_TYPE_VOICE_INPUT } from './VoiceInputView'; +export { VoiceInputViewUI } from './VoiceInputViewUI'; +export { VoiceInputViewActions } from './VoiceInputViewActions'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0dd8329 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": ["DOM", "ES5", "ES6", "ES7", "ES2015.Iterable", "DOM.Iterable"] + }, + "include": ["**/*.ts", "src/types/**/*.d.ts"], + "exclude": [ + "node_modules", + "docs/external/**", + "build/**", + "tests/**" + ] +} \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..556bd17 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "types": ["jest", "node"] + }, + "include": [ + "src/**/*", + "tests/**/*" + ], + "exclude": [ + "node_modules", + "main.js" + ] +} \ No newline at end of file diff --git a/version-bump.mjs b/version-bump.mjs new file mode 100644 index 0000000..371fc31 --- /dev/null +++ b/version-bump.mjs @@ -0,0 +1,14 @@ +import { readFileSync, writeFileSync } from "fs"; + +const targetVersion = process.env.npm_package_version; + +// read minAppVersion from manifest.json and bump version to target version +let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); +const { minAppVersion } = manifest; +manifest.version = targetVersion; +writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); + +// update versions.json with target version and minAppVersion from manifest.json +let versions = JSON.parse(readFileSync("versions.json", "utf8")); +versions[targetVersion] = minAppVersion; +writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); \ No newline at end of file diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..068ee55 --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.8.0": "17.0.0" +} \ No newline at end of file