Compare commits

...

18 commits
0.9.2 ... main

Author SHA1 Message Date
mssoftjp
9cde86540e fix: improve scorecard compliance 2026-05-17 22:40:27 +09:00
mssoftjp
fd15231bab chore: bump version to 0.9.4 2026-05-17 21:41:53 +09:00
mssoftjp
d8a3200619 ci: update github actions versions 2026-05-17 21:16:51 +09:00
mssoftjp
3249ed9811 ci: opt into node 24 actions runtime 2026-05-17 21:15:38 +09:00
mssoftjp
5c04d21985 chore: improve scorecard compliance checks 2026-05-17 21:14:15 +09:00
mssoftjp
b4dcb897d2 chore: bump version to 0.9.3 2025-12-27 03:47:25 +09:00
mssoftjp
1417f67984 chore(check): add artifact sanity check 2025-12-26 01:29:26 +09:00
mssoftjp
6b649ac25e chore(eslint): lint src and manifest 2025-12-26 00:52:41 +09:00
mssoftjp
3331cece0e chore: align with obsidianmd eslint plugin 2025-12-26 00:40:01 +09:00
mssoftjp
c40db83bb2 chore: bump version to 0.9.2 2025-12-14 13:23:16 +09:00
mssoftjp
dd65bcc549 fix: use view type when resolving voice view 2025-12-14 13:19:57 +09:00
mssoftjp
5fc7b50f4c fix: make draft save resilient to existing files 2025-12-14 12:52:41 +09:00
mssoftjp
738c72694d docs: document work attitude expectations 2025-12-14 12:46:53 +09:00
mssoftjp
ab368ee387 fix: short-circuit draft folder creation when it already exists 2025-12-14 12:41:16 +09:00
mssoftjp
2f77ca92dc fix: ignore draft folder exists errors 2025-12-14 12:38:12 +09:00
mssoftjp
4f41ee0df4 build: strip bundled comments and sanitize fvad loader 2025-12-14 12:25:41 +09:00
mssoftjp
6c81ba0491 chore: enforce lint rules and refactor helpers 2025-12-14 12:25:28 +09:00
mssoftjp
88289ab60e docs: update plugin description and texts 2025-12-14 12:25:11 +09:00
40 changed files with 3210 additions and 1183 deletions

30
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: CI
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
jobs:
prepr:
name: Pre-PR checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Run checks
run: npm run check:prepr

1
.gitignore vendored
View file

@ -33,6 +33,7 @@ models/
tmp/
temp/
*.tmp
obsidian-developer-docs/
# Logs
logs/

94
AGENTS.md Normal file
View file

@ -0,0 +1,94 @@
# Repository Guidelines
## Project Structure & Module Organization
- Source: `src/` (TypeScript). Key areas: `plugin/` (entry/commands/ribbon), `views/` (`VoiceInputView` and other UI/Actions), `core/` (audio/VAD/transcription), `config/`, `utils/`, `interfaces/`, `settings/`, `security/`.
- Assets: `src/lib/fvad-wasm/{fvad.wasm,fvad.js}` (copied during build). Keep generated binaries inside `build/<version>/`.
- Build output: `build/latest/` and `build/YYYYMMDD_HHMMSS/` (snapshots). Release bundles include `main.js`, `manifest.json`, optional `styles.css`, and wasm/binary assets.
- Root holds `manifest.json`, `package.json`; use `tmp/` only for short-lived review artifacts and clean it up.
- Tests: `tests/` (Jest + jsdom). Mirrors the `src/` layout.
## Build, Test, and Development Commands
- `npm ci`: clean install dependencies.
- `npm run dev`: local development with esbuild watch.
- `npm run build`: `tsc -noEmit -skipLibCheck` + esbuild, outputs to `build/`.
- `npm run build-plugin`: run `npm run build` then post-build steps to populate `build/latest/` and snapshot.
- `npm run check`: `npm run lint` then `npm run build`.
- `npm run deploy-local`: after build, deploy to detected Obsidian vaults.
- `npm test`: run Jest tests. Optional: `npm test -- --runTestsByPath <file>`.
- `npm run analyze:unused`: output unused file list from the esbuild dependency graph.
- `npm run lint` / `npm run lint:fix`: lint TypeScript sources.
## Coding Style & Naming Conventions
- Language: TypeScript (ES2018), 2-space indent. Prefer explicit types and interfaces. Avoid `any` and non-null `!`.
- Do not mutate shared config constants; derive overrides via helpers (e.g., `getModelConfig`).
- Lint: ESLint (`@typescript-eslint`). Example: `npm run lint`.
- Modules: barrel-export per folder via `index.ts`.
- Naming: classes `PascalCase`; functions/variables `camelCase`. Files follow existing pattern (class files `PascalCase.ts`; folders lowercase).
- Comments only for non-obvious logic.
## Lint & Static Analysis
- Pin `eslint-plugin-obsidianmd` to the CI version and ensure `eslint.config.mjs` loads it; update the plugin and config together.
- After `npm run build-plugin`, run `npm run check:artifacts` to sanity-check `build/latest/` (required files, manifest JSON, and high-risk patterns) instead of linting minified `main.js`.
- Keep TS strict flags: `noImplicitAny`, `noFallthroughCasesInSwitch`, `noImplicitOverride` (consider `noUncheckedIndexedAccess` when helpful).
- Prefer a single enum in comparisons/switch; use `assertUnreachable` for exhaustiveness.
- Track Obsidian API changes and replace deprecated APIs (e.g., `getFilename`).
- Lint imported templates/generated code immediately and clear warnings before merging.
- Prefer CSS files over inline styles; class names/IDs must be plugin-prefixed to avoid clashes.
- Use `getLanguage()` for locale; avoid browser-only globals in mobile builds.
## Testing Guidelines
- Framework: Jest (`jest-environment-jsdom`).
- Location: `tests/**` mirrors `src/**`. Naming uses `*.test.ts`; keep each suite focused.
- Execution: `npm test`. Add tests focused on audio/VAD/transcription, controller/storage paths, and settings migration logic.
## Commit & Pull Request Guidelines
- Commits: Conventional Commits; short and imperative (e.g., `fix: ...`, `chore(css): ...`, `refactor(http): centralize requestUrl`, `style(lint): add EOF newlines`).
- PRs: include summary/background; attach screenshots or GIFs for UI changes; link related issues; add manual-test notes (e.g., deploy path) and relevant logs for UI/build changes. Ensure `npm run lint`, `npm test`, and `npm run build-plugin` pass.
- Do not run `git push`; the user will push at their discretion.
## Pre-PR Checklist
- `npm run lint` (with `eslint-plugin-obsidianmd`).
- `npm test` passes.
- `npm run build-plugin`.
- `npm run check:artifacts` (or `npm run check:prepr`).
- Re-check `obsidian-developer-docs/en/Obsidian October plugin self-critique checklist.md`.
- Any allowed `any` is justified in code comments or PR notes.
- Confirm new/changed commands avoid default hotkeys and commandId/name duplication.
## Security & Configuration Tips
- Never commit API keys; store them encrypted in plugin settings. Obsidian fetches only `main.js`, `manifest.json`, `styles.css`; bundle wasm/binary assets (e.g., `fvad.wasm`) with releases.
- For network requests, use Obsidian `requestUrl` via `ObsidianHttpClient`.
- Do not commit `build/` output. For local verification use `npm run deploy-local`.
## Agent Workflow & Issue Hygiene
- Align with `.gitignore`; do not expose ignored artifacts.
- When creating issues, specify steps, target files/modules, settings/i18n keys, acceptance criteria.
- No branch-naming instructions needed; clarify scope with labels and tasks.
## Obsidian Plugin Compliance (`obsidian-developer-docs/en/Plugins`)
- Submission/naming/distribution: remove placeholders; avoid extra "Obsidian" in the plugin name. Do not include plugin name or ID in command names/IDs. `main.js` only in release assets, not in the repo. `fundingUrl` must point to donation services (set if possible). `minAppVersion` should be minimal. Description must be ≤250 chars, end with `.`, no emoji, proper capitalization, start with an action verb. If using Node/Electron set `isDesktopOnly: true`.
- General/style: use `this.app`, avoid global `app`. Remove unnecessary `console.log`. No default hotkeys. Do not override core styles (use custom classes + CSS variables). No inline styles in JS/HTML. Replace deprecated APIs flagged by IDE strikeouts. Split large `main.ts`. Namespace selectors with a plugin prefix; prefer CSS over JS styling; use Lucide icons via `setIcon`/`addIcon` (≤ v0.446.0) with custom SVG viewBox `0 0 100 100` when needed.
- Security/disclosure: In README disclose payments/accounts/network/external files/ads/telemetry/closed source, etc. Keep dependencies minimal (less is safer). No client-side telemetry. Commit package manager lock file.
- UI/settings text: enforce sentence case. Only add setting headings when multiple sections; do not include words like "settings/option". Use `Setting#setHeading` instead of `<h1>`. No default hotkeys. Choose command callback type appropriately (`callback` / `checkCallback` / `editorCallback` / `editorCheckCallback`).
- DOM/resources: do not use `innerHTML`/`outerHTML`/`insertAdjacentHTML`; assemble with `createEl` etc. Register events/intervals with `registerEvent`/`registerInterval` and release on unload. Classnames/IDs in DOM must be plugin-prefixed.
- Workspace/view: register custom views with `registerView`; do not hold references (use `getLeavesOfType` as needed). Avoid direct `workspace.activeLeaf`; use `getActiveViewOfType`/`activeEditor`. Do not manually detach leaves in `onunload`.
- Vault/editor/API: resolve paths via `plugin.manifest.dir`; avoid hardcoded `.obsidian`. Use Editor API for active edits, `Vault.process` for background; use `FileManager.processFrontMatter` for frontmatter. Use `trashFile` to respect user settings when deleting. Store plugin data with `loadData`/`saveData`. Prefer Vault API over Adapter API; locate with `getFileByPath` etc., avoid full scans. Always `normalizePath`. Bundle images/icons locally; avoid remote CDNs.
- Mobile compatibility: if `isDesktopOnly: false`, do not require Node modules at top level (guard with `Platform.isDesktopApp` and dynamic require). Avoid `process.platform`; use `Platform`. Determine `Vault.adapter` via `instanceof FileSystemAdapter` (mobile uses `CapacitorAdapter`). Use `requestUrl` instead of `fetch/axios.get`. Beware iOS <16.4 lacking regex lookbehind support. Avoid browser-only globals; status bar items are not available on mobile.
- Performance: initialize startup code with `workspace.onLayoutReady()` when possible. Avoid repo-wide path scans. Minify `main.js` for release. Use `import { moment } from 'obsidian'`. Add DeferredViews support if needed for 1.7.2+ compatibility. Continually optimize load times.
- Data/sync: keep user data in `saveData()` / `data.json` unless intentionally unsynced; document any external files written. Do not store secrets.
- TypeScript/coding: use `const`/`let`; forbid `var`. Prefer `async/await` over promise chains. Avoid globals; avoid `as any` with proper typing. Before casting, check with `instanceof`.
## Development Workflow Guardrails
- Before coding: skim `obsidian-developer-docs/en/Home.md` and `.../Developer policies.md` to align with current Obsidian plugin rules.
- Keep `obsidian-developer-docs/en/Obsidian October plugin self-critique checklist.md` open during implementation; use it mid-task (CSS namespacing, API choices, path handling, lifecycle cleanup, etc.) and consult on every change.
- Re-check the checklist before opening a PR to avoid reviewer reminders; if a rule must be bent, note the rationale in the PR description.
- Treat this section and `obsidian-developer-docs/` as the single source of truth; update both when guidelines change so they never diverge.
## Communication Preference
- Respond to the user in Japanese for all interactions; internal reasoning language is unrestricted.
## Attitude & Quality Bar
- Default to reviewer mindset: proactively surface issues, edge cases, and guideline gaps before they are reported back to us.
- Treat guideline compliance (Obsidian docs, repo rules) as part of “done”; dont weaken lint/rules to fit code—fix code instead.
- Favor minimal noise: concise status, clear next steps, and verify with lint/tests/builds after impactful changes.
- Assume logs and console noise matter: prevent recurring warnings/errors rather than muting them.

View file

@ -1,6 +1,6 @@
# Voice Input Plugin for Obsidian
High-accuracy multilingual voice input for Obsidian. Uses OpenAI GPT-4o Audio Transcriptions to transcribe speech (with tuned prompts for ja/en/zh/ko) and insert into your notes with one click or push-to-talk.
Capture notes with high-accuracy multilingual voice input using OpenAI Speech-to-Text. Uses GPT-4o Audio Transcriptions (with tuned prompts for ja/en/zh/ko) and inserts into your notes with one click or push-to-talk.
## Features
@ -10,10 +10,10 @@ High-accuracy multilingual voice input for Obsidian. Uses OpenAI GPT-4o Audio Tr
- Language separation: independent UI language and voice recognition language settings
- Language linking: voice recognition follows the UI locale (ja/en/zh/ko) with optional overrides
- AI postprocessing: optional dictionary-based cleanup (applied to all languages when enabled)
- Quick controls in view: copy/clear/insert at cursor/append to end
- Quick controls in view: copy/clear/insert at caret/append to end
- Autosave drafts: periodic and on blur, automatic restore
- Multilingual support: Japanese, English, Chinese, Korean interface languages
- Voice activity detection modes: Off by default for maximum accuracy. Optional server-side chunking or local autostop (requires fvad.wasm/fvad.js, installed manually)
- Voice activity detection modes: Off by default for maximum accuracy. Optional server-side chunking or local autostop (requires fvad.wasm, installed manually)
## Requirements
@ -31,11 +31,13 @@ Note: OpenAI usage is billed by API.
Compatibility: requires Obsidian 1.8.0 or later (per `minAppVersion`).
Local VAD (optional): this plugin does not ship the WebAssembly files. If you want local VAD autostop, download `fvad.wasm` and `fvad.js` from the fvadwasm project and place them in the same plugin folder, or use the “Choose fvad.wasm / fvad.js…” button in Settings → Voice Activity Detection to copy them. Local VAD is desktoponly.
Release assets: the plugin only needs `main.js`, `manifest.json`, and `styles.css` from the release bundle. Optional local VAD files are user-provided and are not included in releases.
Local VAD (optional): this plugin does not ship the WebAssembly file. If you want local VAD autostop, download `fvad.wasm` from the fvadwasm project and place it in the same plugin folder, or use the “Choose fvad.wasm…” button in Settings → Voice Activity Detection to copy it. The plugin reads this local file from the installed plugin folder. No external network requests are made for local VAD. Local VAD is desktoponly.
## Commands
- Open Voice Input (`open-voice-input`): opens the Voice Input view. Assign a hotkey from Obsidians Settings → Hotkeys if desired.
- Open Voice Input (`open-view`): opens the Voice Input view. Assign a hotkey from Obsidians Settings → Hotkeys if desired.
## Usage
@ -46,7 +48,7 @@ Local VAD (optional): this plugin does not ship the WebAssembly files. If you wa
- Click “Start Voice Input” to toggle recording, or use pushtotalk: longpress 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.
- Copy, Insert at caret, or Append to end of the active note. Clear resets the area.
Tip: A settings gear in the view header opens the plugins settings.
@ -58,7 +60,7 @@ Tip: A settings gear in the view header opens the plugins settings.
- AI Postprocessing: enable dictionarybased cleanup (applied to all languages when enabled)
- Maximum Recording Duration: slider (default 5 min)
- Plugin Language: Japanese/English/Chinese/Korean (controls UI display only, auto-detected from Obsidian, adjustable)
- Voice Activity Detection: choose Off (default, most accurate), Server (faster turnaround via upstream silence trimming), or Local (desktop autostop; requires `fvad.wasm`/`fvad.js` installed manually)
- Voice Activity Detection: choose Off (default, most accurate), Server (faster turnaround via upstream silence trimming), or Local (desktop autostop; requires `fvad.wasm` installed manually)
### Drafts and sync
@ -67,13 +69,16 @@ Tip: A settings gear in the view header opens the plugins settings.
## Security & Privacy
- Processing in memory; audio is not written to disk by the plugin
- Audio you record is transmitted to OpenAI for transcription over HTTPS (via Obsidians `requestUrl`).
- API key is encrypted for storage
Note: When Electron SafeStorage is unavailable, the plugin falls back to lightweight obfuscation for the stored key; the plugin is desktoponly by design.
See also OpenAIs Privacy Policy.
- Processing in memory: audio is not written to disk by the plugin.
- Network use: recorded audio is sent over HTTPS to `https://api.openai.com/v1/audio/transcriptions` via Obsidians `requestUrl` for transcription. The settings connection test calls `https://api.openai.com/v1/models`. No telemetry, ads, or self-update requests are sent by the plugin.
- Account and billing: an OpenAI API key is required, and OpenAI API usage may be billed by OpenAI.
- API key storage: the API key is stored in plugin settings. When Electron SafeStorage is available, the key is encrypted with SafeStorage before saving.
- SafeStorage fallback: if Electron SafeStorage is unavailable, the plugin stores the key with a lightweight XOR/Base64 obfuscation fallback for backward compatibility. This fallback is not equivalent to OS-backed encryption.
- Clipboard access: the Copy button writes only the current transcription text to the system clipboard. If note creation or insertion fails, the plugin may also write that same transcription text to the clipboard as a recovery fallback. The plugin does not read from the clipboard.
- Vault file access: drafts are saved to, loaded from, and cleared from `<vault>/.obsidian/plugins/voice-input/draft.txt` using Obsidian Vault/FileManager APIs. Insert and append actions write only to the active target note, or create a timestamped `Voice-Memo-*.md` note when no suitable note is available.
- Local files: optional local VAD reads `fvad.wasm` from the plugin folder after you install or choose it. The release bundle does not include this WebAssembly file.
- External links: settings may show a link to the fvad-wasm GitHub project for manual download, but the plugin does not download those files automatically.
- Privacy policy: see [OpenAIs Privacy Policy](https://openai.com/policies/privacy-policy/) for OpenAI API data handling.
## Troubleshooting
@ -100,7 +105,7 @@ Thirdparty licensing: see `THIRD_PARTY_LICENSES.md`.
- ビュー内のクイック操作(コピー/クリア/カーソル位置へ挿入/末尾へ追記)
- 自動保存(定期保存とフォーカス外れ時)。再オープン時に自動復元
- 多言語サポート(日本語、英語、中国語、韓国語のインターフェース)
- VADモード選択標準はオフ精度重視。必要に応じてサーバーVAD応答を速めたい場合`fvad.wasm` / `fvad.js` を使ったローカルVADデスクトップの自動停止を利用可能
- VADモード選択標準はオフ精度重視。必要に応じてサーバーVAD応答を速めたい場合`fvad.wasm` を使ったローカルVADデスクトップの自動停止を利用可能
## 必要条件
@ -114,14 +119,15 @@ Thirdparty licensing: see `THIRD_PARTY_LICENSES.md`.
1. Releases から最新版を取得
2. `main.js`、`manifest.json`、`styles.css` を `<Vault>/.obsidian/plugins/voice-input/` に配置
3. `<Vault>/.obsidian/plugins/voice-input/` に置く
4. Obsidianを再起動し、プラグインを有効化
3. Obsidianを再起動し、プラグインを有効化
ローカルVAD任意: 本プラグインは WebAssembly ファイルを同梱しません。ローカルVADの自動停止を使う場合、`fvad.wasm` と `fvad.js` を fvadwasm プロジェクトから取得して同じフォルダに配置するか、設定 → 音声区間検出 の「fvad.wasm / fvad.js を選択…」ボタンでコピーしてください。ローカルVADはデスクトップ専用です。
リリースアセット: プラグインの実行に必要なファイルはリリースバンドル内の `main.js`、`manifest.json`、`styles.css` です。任意のローカルVADファイルはユーザーが用意するもので、リリースには含めません。
ローカルVAD任意: 本プラグインは WebAssembly ファイルを同梱しません。ローカルVADの自動停止を使う場合、`fvad.wasm` を fvadwasm プロジェクトから取得して同じフォルダに配置するか、設定 → 音声区間検出 の「fvad.wasm を選択…」ボタンでコピーしてください。プラグインはインストール済みプラグインフォルダ内のこのローカルファイルを読み込みます。ローカルVADのための外部ネットワーク通信は行いません。ローカルVADはデスクトップ専用です。
## コマンド
- Open Voice Input`open-voice-input`: 音声入力ビューを開きます。必要に応じてホットキーを割り当ててください。
- Open Voice Input`open-view`: 音声入力ビューを開きます。必要に応じてホットキーを割り当ててください。
## 使い方
@ -144,7 +150,7 @@ Thirdparty licensing: see `THIRD_PARTY_LICENSES.md`.
- AI後処理: 辞書ベースの補正(有効時は全言語に適用)
- 最大録音時間: スライダー初期値5分
- **プラグイン言語**: UI表示のみを制御。Obsidianの言語設定から自動検出ja/zh/ko/en
- 音声区間検出 (VAD): オフ(標準・精度重視)、サーバー(応答を速めたい場合の無音カット)、ローカル(デスクトップの自動停止。`fvad.wasm`/`fvad.js` を手動導入)から選択
- 音声区間検出 (VAD): オフ(標準・精度重視)、サーバー(応答を速めたい場合の無音カット)、ローカル(デスクトップの自動停止。`fvad.wasm` を手動導入)から選択
### ドラフトと同期
@ -173,13 +179,16 @@ Thirdparty licensing: see `THIRD_PARTY_LICENSES.md`.
## セキュリティ / プライバシー
- 処理はメモリ内で行い、音声ファイルはプラグイン側でディスク保存しません
- 録音した音声は文字起こしのため OpenAI に送信され、HTTPSObsidian の `requestUrl` 経由)で通信します。
- APIキーは保存時に暗号化
補足: Electron の SafeStorage が利用できない環境では保存キーを軽度に難読化して保持します(本プラグインはデスクトップ専用の設計です)。
OpenAIのプライバシーポリシーもご参照ください。
- 処理はメモリ内で行い、音声ファイルはプラグイン側でディスク保存しません。
- ネットワーク利用: 録音した音声は文字起こしのため `https://api.openai.com/v1/audio/transcriptions` に HTTPSObsidian の `requestUrl` 経由)で送信されます。設定画面の接続テストでは `https://api.openai.com/v1/models` に接続します。プラグインはテレメトリ、広告、自己更新のための通信を行いません。
- アカウントと課金: OpenAI API キーが必要です。OpenAI API の利用料金は OpenAI 側で発生する場合があります。
- APIキー保存: APIキーはプラグイン設定に保存されます。Electron SafeStorage が利用できる場合は、SafeStorage で暗号化して保存します。
- SafeStorage フォールバック: Electron SafeStorage が利用できない場合、後方互換性のため XOR/Base64 による軽度の難読化で保存します。この方式は OS レベルの暗号化と同等ではありません。
- クリップボードアクセス: コピーボタンは現在の文字起こし結果だけをシステムクリップボードへ書き込みます。ノート作成や挿入に失敗した場合も、復旧用 fallback として同じ文字起こし結果をクリップボードへ書き込むことがあります。プラグインはクリップボードからの読み取りは行いません。
- Vault ファイルアクセス: 下書きは Obsidian の Vault/FileManager API を使って `<Vault>/.obsidian/plugins/voice-input/draft.txt` に保存、読み込み、削除されます。挿入と追記は対象ノートのみに書き込み、適切なノートが見つからない場合は `Voice-Memo-*.md` 形式のノートを作成します。
- ローカルファイル: 任意のローカルVADは、ユーザーが配置または選択した `fvad.wasm` をプラグインフォルダから読み込みます。リリースバンドルにはこの WebAssembly ファイルは含めません。
- 外部リンク: 設定画面に fvad-wasm の GitHub プロジェクトへのリンクを表示することがありますが、プラグインが自動でこれらのファイルをダウンロードすることはありません。
- プライバシーポリシー: OpenAI API におけるデータの扱いは [OpenAI のプライバシーポリシー](https://openai.com/policies/privacy-policy/) も参照してください。
## トラブルシューティング

View file

@ -1,9 +1,13 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { builtinModules } from "node:module";
import fs from "fs";
import path from "path";
const builtins = [...new Set(builtinModules.flatMap((name) => (
name.startsWith("node:") ? [name] : [name, `node:${name}`]
)))];
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -43,10 +47,10 @@ const context = await esbuild.context({
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
minifyWhitespace: prod,
minifyIdentifiers: prod,
minifySyntax: prod,
legalComments: 'inline',
// Production builds must be minified (Obsidian dev docs: Optimize plugin load time)
minify: prod,
// Remove all other comments; banner remains
legalComments: 'none',
outfile: path.join(outDir, "main.js"),
});

View file

@ -1,53 +1,76 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import tsParser from '@typescript-eslint/parser';
import { defineConfig } from 'eslint/config';
import obsidianmd from 'eslint-plugin-obsidianmd';
import * as espree from 'espree';
import globals from 'globals';
export default tseslint.config(
const manifestJsonParser = {
meta: {
name: 'manifest-json-parser',
version: '1.0.0'
},
parseForESLint(text, options) {
const normalizedText = text.replace(/^\uFEFF/u, '');
const wrappedText = `(${normalizedText})`;
const ast = espree.parse(wrappedText, {
...options,
ecmaVersion: 2022,
sourceType: 'script',
comment: true,
loc: true,
range: true,
tokens: true
});
return { ast, services: {}, visitorKeys: espree.VisitorKeys };
}
};
const obsidianmdManifestRuleOverrides = Object.fromEntries(
Object.keys(obsidianmd.rules).map((ruleName) => [`obsidianmd/${ruleName}`, 'off'])
);
export default defineConfig([
{
ignores: [
'node_modules/**',
'build/**',
'dist/**',
'build/**',
'coverage/**',
'main.js',
'docs/**'
'*.config.mjs',
'scripts/**',
'docs/**',
'src/lib/fvad-wasm/**'
]
},
js.configs.recommended,
...tseslint.configs.recommended,
...obsidianmd.configs.recommended,
...obsidianmd.configs.recommendedWithLocalesEn,
{
files: [
'src/**/*.ts',
'tests/**/*.ts'
],
files: ['manifest.json'],
languageOptions: {
parser: manifestJsonParser
},
plugins: {
obsidianmd
},
rules: {
...obsidianmdManifestRuleOverrides,
'obsidianmd/validate-manifest': 'error'
}
},
{
files: ['src/**/*.ts', 'tests/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: ['./tsconfig.json', './tsconfig.test.json'],
tsconfigRootDir: import.meta.dirname ?? process.cwd()
},
globals: {
...globals.browser,
...globals.node,
Option: 'readonly',
NodeJS: 'readonly'
},
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname
}
},
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', 'debug'] }],
'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 }]
}
}
);
]);

View file

@ -1,9 +1,9 @@
{
"id": "voice-input",
"name": "Voice Input",
"version": "0.9.1",
"version": "0.9.5",
"minAppVersion": "1.8.0",
"description": "AI-powered high-accuracy voice input using OpenAI Speech to text API",
"description": "Capture notes with high-accuracy multilingual voice input using OpenAI Speech-to-Text.",
"author": "Musashino Software",
"authorUrl": "https://github.com/mssoftjp",
"fundingUrl": "https://buymeacoffee.com/mssoft",

3249
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,11 @@
{
"name": "voice-input",
"version": "0.9.1",
"version": "0.9.5",
"description": "Voice input plugin for Obsidian with accurate transcription",
"main": "main.js",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
@ -12,8 +15,10 @@
"deploy-local": "npm run build-plugin && node scripts/deploy-local.mjs",
"deploy:quick": "npm run deploy-local",
"check": "npm run lint && npm run build",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"check:artifacts": "node scripts/check-artifacts.mjs",
"check:prepr": "npm run lint && npm test && npm run build-plugin && npm run check:artifacts",
"lint": "eslint src manifest.json --ext .ts --max-warnings=0",
"lint:fix": "eslint src manifest.json --ext .ts --fix --max-warnings=0",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
@ -29,24 +34,25 @@
"author": "Musashino Software",
"license": "MIT",
"devDependencies": {
"@eslint/js": "9.39.2",
"@eslint/json": "0.14.0",
"@types/jest": "30.0.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"eslint": "^9.39.1",
"eslint-plugin-obsidianmd": "^0.1.8",
"eslint": "9.39.2",
"eslint-plugin-obsidianmd": "0.3.0",
"globals": "16.5.0",
"jest": "30.0.5",
"jest-environment-jsdom": "^30.0.5",
"obsidian": "latest",
"obsidian": "1.8.7",
"ts-jest": "29.4.0",
"tslib": "^2.6.2",
"typescript": "^5.5.4",
"typescript-eslint": "^8.46.3"
},
"dependencies": {
"@echogarden/fvad-wasm": "^0.2.0",
"@types/jest": "^30.0.0",
"jest": "^30.0.5",
"lodash.merge": "^4.6.2",
"ts-jest": "^29.4.0"
"@echogarden/fvad-wasm": "^0.2.0"
}
}

View file

@ -0,0 +1,85 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.join(__dirname, '..');
const buildLatestDir = path.join(rootDir, 'build', 'latest');
const buildLatestMainPath = path.join(buildLatestDir, 'main.js');
const buildLatestManifestPath = path.join(buildLatestDir, 'manifest.json');
function fail(message) {
console.error(`ERROR: ${message}`);
process.exit(1);
}
function ensureFileExists(filePath) {
if (!fs.existsSync(filePath)) {
fail(`Missing file: ${path.relative(rootDir, filePath)}`);
}
}
function excerpt(text, index, context = 80) {
const start = Math.max(0, index - context);
const end = Math.min(text.length, index + context);
const prefix = start > 0 ? '…' : '';
const suffix = end < text.length ? '…' : '';
return `${prefix}${text.slice(start, end)}${suffix}`;
}
if (!fs.existsSync(buildLatestDir)) {
fail('Missing build/latest. Run `npm run build-plugin` first.');
}
ensureFileExists(buildLatestMainPath);
ensureFileExists(buildLatestManifestPath);
const mainStat = fs.statSync(buildLatestMainPath);
if (!mainStat.isFile() || mainStat.size < 10_000) {
fail(`build/latest/main.js looks too small (${mainStat.size} bytes).`);
}
try {
JSON.parse(fs.readFileSync(buildLatestManifestPath, 'utf8'));
} catch (error) {
fail(`build/latest/manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
const mainJs = fs.readFileSync(buildLatestMainPath, 'utf8');
const forbidden = [
{ name: 'innerHTML', regex: /\binnerHTML\b/u },
{ name: 'outerHTML', regex: /\bouterHTML\b/u },
{ name: 'insertAdjacentHTML', regex: /\binsertAdjacentHTML\b/u },
{ name: 'eval(', regex: /\beval\s*\(/u },
{ name: 'new Function', regex: /\bnew\s+Function\b/u },
{ name: 'debugger', regex: /\bdebugger\b/u },
{ name: 'console.log(', regex: /\bconsole\.log\s*\(/u }
];
const failures = [];
for (const check of forbidden) {
const match = mainJs.match(check.regex);
if (!match || typeof match.index !== 'number') {
continue;
}
failures.push({
name: check.name,
index: match.index,
snippet: excerpt(mainJs, match.index)
});
}
if (failures.length > 0) {
for (const failureInfo of failures) {
console.error(`ERROR: Forbidden pattern "${failureInfo.name}" found in build/latest/main.js (index ${failureInfo.index}).`);
console.error(failureInfo.snippet);
console.error('');
}
process.exit(1);
}
console.log('OK: build/latest artifacts passed sanity checks.');

View file

@ -142,7 +142,6 @@ export const FILE_CONSTANTS = {
/** WASMファイルのパス */
WASM_PATHS: {
JS: 'src/lib/fvad-wasm/fvad.js',
WASM: 'src/lib/fvad-wasm/fvad.wasm'
}
} as const;

View file

@ -141,9 +141,13 @@ export const ConfigHelper = {
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);
if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
} else if (!isNaN(Number(value))) {
value = Number(value);
}
// ネストされたキーの処理
const keys = configKey.split('.');

View file

@ -36,7 +36,7 @@ export class AudioRecorder extends Disposable {
private isRecording: boolean = false;
private stream: MediaStream | null = null;
private options: AudioRecorderOptions;
private silenceTimer: NodeJS.Timeout | null = null;
private silenceTimer: number | null = null;
private lastSpeechTime: number = 0;
private continuousAudioData: Float32Array[] = [];
private audioRingBuffer: AudioRingBuffer;
@ -50,7 +50,7 @@ export class AudioRecorder extends Disposable {
private disposables: CompositeDisposable = new CompositeDisposable();
private logger: Logger | null = null;
private recordingStartTime: number = 0;
private maxRecordingTimer: NodeJS.Timeout | null = null;
private maxRecordingTimer: number | null = null;
private isStarting: boolean = false;
constructor(options: AudioRecorderOptions) {
@ -279,7 +279,7 @@ export class AudioRecorder extends Disposable {
// Set up maximum recording time limit
const maxSeconds = this.options.maxRecordingSeconds || AUDIO_CONSTANTS.MAX_RECORDING_SECONDS;
this.maxRecordingTimer = setTimeout(() => {
this.maxRecordingTimer = window.setTimeout(() => {
this.logger?.info(`Maximum recording time (${maxSeconds}s) reached, stopping recording`);
void this.stopRecording()
.then((audioBlob) => {
@ -401,10 +401,10 @@ export class AudioRecorder extends Disposable {
this.logger?.error('Failed to process audio data from analyser fallback', error);
}
this.analyserFrameRequest = requestAnimationFrame(processFrame);
this.analyserFrameRequest = window.requestAnimationFrame(processFrame);
};
this.analyserFrameRequest = requestAnimationFrame(processFrame);
this.analyserFrameRequest = window.requestAnimationFrame(processFrame);
}
private stopAnalyserProcessing(): void {
@ -489,7 +489,7 @@ export class AudioRecorder extends Disposable {
this.lastSpeechTime = Date.now();
if (this.silenceTimer) {
clearTimeout(this.silenceTimer);
window.clearTimeout(this.silenceTimer);
this.silenceTimer = null;
}
@ -514,7 +514,7 @@ export class AudioRecorder extends Disposable {
// Check if we should auto-stop due to silence (only in VAD mode)
if (this.options.useVAD && this.lastSpeechTime > 0 && !this.silenceTimer) {
const autoStopDuration = this.options.autoStopSilenceDuration ?? DEFAULT_AUDIO_SETTINGS.autoStopSilenceDuration;
this.silenceTimer = setTimeout(() => {
this.silenceTimer = window.setTimeout(() => {
if (!this.isRecording) {
return;
}
@ -543,7 +543,7 @@ export class AudioRecorder extends Disposable {
// Clear maximum recording timer
if (this.maxRecordingTimer) {
clearTimeout(this.maxRecordingTimer);
window.clearTimeout(this.maxRecordingTimer);
this.maxRecordingTimer = null;
}
@ -576,12 +576,12 @@ export class AudioRecorder extends Disposable {
private cleanup(): void {
if (this.silenceTimer) {
clearTimeout(this.silenceTimer);
window.clearTimeout(this.silenceTimer);
this.silenceTimer = null;
}
if (this.maxRecordingTimer) {
clearTimeout(this.maxRecordingTimer);
window.clearTimeout(this.maxRecordingTimer);
this.maxRecordingTimer = null;
}

View file

@ -16,7 +16,7 @@ export class AudioVisualizer extends Disposable {
constructor(container: HTMLElement) {
super();
this.canvas = document.createElement('canvas');
this.canvas = activeDocument.createElement('canvas');
this.canvas.className = 'voice-input-audio-visualizer-canvas';
this.canvas.width = 200;
this.canvas.height = 56;
@ -46,7 +46,7 @@ export class AudioVisualizer extends Disposable {
stop(): void {
this.isRecording = false;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
window.cancelAnimationFrame(this.animationId);
this.animationId = null;
}
this.clear();
@ -65,7 +65,7 @@ export class AudioVisualizer extends Disposable {
return;
}
this.animationId = requestAnimationFrame(() => this.draw());
this.animationId = window.requestAnimationFrame(() => this.draw());
// Get time domain data (waveform)
const waveformArray = this.dataArray as Uint8Array<ArrayBuffer>;
@ -230,20 +230,20 @@ export class SimpleAudioLevelIndicator extends Disposable {
}
private createUI(): void {
const wrapper = document.createElement('div');
const wrapper = activeDocument.createElement('div');
wrapper.className = 'voice-input-audio-level-indicator';
// Level bar
const levelContainer = document.createElement('div');
const levelContainer = activeDocument.createElement('div');
levelContainer.className = 'voice-input-audio-level-container';
this.levelBar = document.createElement('div');
this.levelBar = activeDocument.createElement('div');
this.levelBar.className = 'voice-input-audio-level-bar';
levelContainer.appendChild(this.levelBar);
// VAD indicator
this.vadIndicator = document.createElement('div');
this.vadIndicator = activeDocument.createElement('div');
this.vadIndicator.className = 'voice-input-audio-vad-indicator';
wrapper.appendChild(levelContainer);
@ -265,7 +265,7 @@ export class SimpleAudioLevelIndicator extends Disposable {
stop(): void {
this.isActive = false;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
window.cancelAnimationFrame(this.animationId);
this.animationId = null;
}
this.setLevelBarClass(0);
@ -284,7 +284,7 @@ export class SimpleAudioLevelIndicator extends Disposable {
return;
}
this.animationId = requestAnimationFrame(() => this.update());
this.animationId = window.requestAnimationFrame(() => this.update());
const frequencyArray = this.dataArray as Uint8Array<ArrayBuffer>;
this.analyser.getByteFrequencyData(frequencyArray);

View file

@ -1,111 +1,9 @@
/**
* AudioWorklet source code as a string
* This allows us to create a blob URL for loading in restricted environments
* AudioWorklet source code as a single-line string with comments stripped
* to keep bundled output comment-free (except the esbuild banner).
*/
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);
`;
export const AUDIO_WORKLET_SOURCE =
`class AudioProcessorWorklet extends AudioWorkletProcessor{constructor(){super();this.bufferSize=4096;this.audioBuffer=[];this.isRecording=!1;this.port.onmessage=e=>{this.handleMessage(e.data)}}handleMessage(e){switch(e.type){case"start":this.isRecording=!0,this.audioBuffer=[];break;case"stop":this.isRecording=!1,this.audioBuffer.length>0&&this.sendAudioData();break;case"configure":e.bufferSize&&(this.bufferSize=e.bufferSize);break}}process(e){const t=e[0];if(!this.isRecording||!t||t.length===0)return!0;const s=t[0];if(!s||s.length===0)return!0;this.audioBuffer.push(new Float32Array(s));const r=this.audioBuffer.reduce((n,o)=>n+o.length,0);return r>=this.bufferSize&&this.sendAudioData(),!0}sendAudioData(){if(this.audioBuffer.length===0)return;const e=this.audioBuffer.reduce((t,s)=>t+s.length,0),t=new Float32Array(e);let s=0;for(const r of this.audioBuffer)t.set(r,s),s+=r.length;this.port.postMessage({type:"audio",data:t,timestamp:currentTime}),this.audioBuffer=[]}}registerProcessor("audio-processor-worklet",AudioProcessorWorklet);`;
/**
* Create a blob URL for the AudioWorklet

View file

@ -1,7 +1,8 @@
import { App, FileSystemAdapter, normalizePath } from 'obsidian';
import createFvadModule from '@echogarden/fvad-wasm';
import { VAD_CONSTANTS, DEFAULT_VAD_SETTINGS, FILE_CONSTANTS } from '../../config';
import { Disposable } from '../../interfaces';
import { FvadModule, WindowWithFvad, hasFvadModule } from '../../types';
import { FvadModule } from '../../types';
import { createServiceLogger } from '../../services';
import { Logger } from '../../utils';
@ -104,86 +105,12 @@ export class VADProcessor extends Disposable {
* fvad
*/
private async initializeFvadModule(wasmBuffer: ArrayBuffer): Promise<void> {
// スクリプトタグを使用して fvad.js を読み込む
// これは Obsidian の制限された環境でモジュールを読み込む最も確実な方法
this.ensureFileSystemAdapter();
// グローバルオブジェクトを準備(型安全に)
const globalWindow = window as WindowWithFvad;
// fvad.js の内容を読み込んで評価(プラグインルートから)
const fvadJsPath = this.getPluginAssetPath('fvad.js');
const fvadJsContent = await this.readPluginTextAsset('fvad.js');
// モジュールを評価するための一時的な環境を作成
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(() => {
const loadModule = async (): Promise<void> => {
if (!hasFvadModule(globalWindow)) {
throw new Error('fvad module not found in global scope');
}
const createModule = globalWindow.__fvadModule;
if (!createModule) {
throw new Error('fvad module factory is undefined');
}
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 instanceof Error ? error : new Error(String(error)));
});
return {};
}
});
// クリーンアップ
document.head.removeChild(script);
delete globalWindow.__fvadModule;
};
loadModule()
.then(() => resolve())
.catch((error) => {
reject(error instanceof Error ? error : new Error(String(error)));
});
}, 100); // モジュール読み込みを待つ
this.fvadModule = await createFvadModule({
wasmBinary: new Uint8Array(wasmBuffer)
});
this.logger.debug('fvad module initialized from bundled loader');
}
/**
@ -244,21 +171,6 @@ export class VADProcessor extends Disposable {
return normalizePath(`${this.getPluginAssetBasePath()}/${fileName}`);
}
private async readPluginTextAsset(fileName: string): Promise<string> {
const fullPath = this.getPluginAssetPath(fileName);
const existingFile = this.app.vault.getFileByPath(fullPath);
if (existingFile) {
return this.app.vault.read(existingFile);
}
const adapter = this.ensureFileSystemAdapter();
const exists = await adapter.exists(fullPath);
if (!exists) {
throw new Error(`Asset not found: ${fullPath}`);
}
return adapter.read(fullPath);
}
private async readPluginBinaryAsset(fileName: string): Promise<ArrayBuffer> {
const fullPath = this.getPluginAssetPath(fileName);
const existingFile = this.app.vault.getFileByPath(fullPath);

View file

@ -187,7 +187,9 @@ export class ErrorHandler implements IDisposable {
context: ErrorContext,
severity: ErrorSeverity = ErrorSeverity.ERROR
): void {
if (this._isDisposed) return;
if (this._isDisposed) {
return;
}
// エラーログに記録
const logEntry: ErrorLogEntry = {
@ -388,7 +390,7 @@ export class ErrorHandler implements IDisposable {
*
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise(resolve => window.setTimeout(resolve, ms));
}
/**
@ -409,7 +411,9 @@ export class ErrorHandler implements IDisposable {
*
*/
dispose(): void {
if (this._isDisposed) return;
if (this._isDisposed) {
return;
}
if (this.unhandledRejectionHandler) {
window.removeEventListener('unhandledrejection', this.unhandledRejectionHandler);

View file

@ -101,7 +101,7 @@ export const en: TranslationResource = {
serviceInitFailed: 'Service initialization failed',
audioTooShort: 'Audio is too short',
noAudioDetected: 'No audio detected',
localVadMissing: 'Local VAD module not found. Using server-side detection instead. Place fvad.wasm and fvad.js under {path}.'
localVadMissing: 'Local VAD module not found. Using server-side detection instead. Place fvad.wasm under {path}.'
},
error: {
clipboardFailed: 'Failed to copy to clipboard',
@ -131,7 +131,7 @@ export const en: TranslationResource = {
cleanup: 'Clean up',
copy: 'Copy',
insert: 'Insert to note',
insertAtCursor: 'Insert at cursor',
insertAtCursor: 'Insert at caret',
append: 'Append to end',
clear: 'Clear',
cancel: 'Cancel',
@ -150,7 +150,7 @@ export const en: TranslationResource = {
},
titles: {
main: 'Voice input',
settings: 'Voice input settings'
settings: 'Voice input'
},
settings: {
apiKey: 'OpenAI API key',
@ -180,20 +180,20 @@ export const en: TranslationResource = {
dictionaryImportExport: 'Dictionary import/export',
dictionaryImportExportDesc: 'Import or export your correction dictionary as JSON',
vadMode: 'Voice activity detection',
vadModeDesc: 'Off (default) keeps the raw audio for maximum accuracy. Server VAD can trim silence upstream for quicker turnaround but may slightly affect segmentation accuracy. Local VAD (requires fvad.wasm/fvad.js) stops recording automatically on the desktop.',
vadModeLocalMissing: 'Local VAD requires fvad.wasm and fvad.js under {path}. Install them before switching.',
vadModeDesc: 'Off keeps the raw audio for maximum accuracy. Server VAD can trim silence upstream for quicker turnaround but may slightly affect segmentation accuracy. Local VAD requires fvad.wasm and stops recording automatically on the desktop.',
vadModeLocalMissing: 'Local VAD requires fvad.wasm under {path}. Install it before switching.',
vadModeLocalAvailable: 'Local VAD files found in {path}. Recording will auto-stop on silence.',
vadModeDisabledDesc: 'Voice activity detection is disabled audio is recorded continuously and sent untouched.',
vadModeSummaryServer: 'Cuts silence on the server for faster turnaround (cloud processing).',
vadModeSummaryLocal: 'Detects silence locally and auto-stops on desktop (requires fvad.wasm/fvad.js).',
vadModeInstallButton: 'Choose fvad.wasm / fvad.js…',
vadModeInstallDesc: 'Download fvad.wasm (and fvad.js) from the official WebRTC voice activity detection port, then choose the wasm file to copy it into the plugin folder (desktop only).',
vadModeSummaryLocal: 'Detects silence locally and auto-stops on desktop (requires fvad.wasm).',
vadModeInstallButton: 'Choose fvad.wasm…',
vadModeInstallDesc: 'Download fvad.wasm from the official WebRTC voice activity detection port, then choose the wasm file to copy it into the plugin folder (desktop only).',
vadModeInstallLinkLabel: 'Visit the fvad-wasm project',
vadModeInstallInvalidName: 'Please select a file named fvad.wasm.',
vadModeInstallInvalidType: 'The selected file does not appear to be a WebAssembly module.',
vadModeInstallSuccess: 'The fvad.wasm file is installed. Local voice activity detection will run the next time you record.',
vadModeInstallWriteError: 'Failed to install VAD assets: {error}',
vadModeInstallJsMissing: 'Local voice activity detection also needs fvad.js in the same plugin folder. Copy it manually if it is not present.'
vadModeInstallJsMissing: 'Local voice activity detection now only requires fvad.wasm. Update the plugin if this message appears.'
},
options: {
modelMini: 'GPT-4o mini transcribe',
@ -209,8 +209,8 @@ export const en: TranslationResource = {
},
tooltips: {
copy: 'Copy to clipboard',
insert: 'Insert at cursor',
insertAtCursor: 'Insert at cursor position',
insert: 'Insert at caret',
insertAtCursor: 'Insert at caret position',
append: 'Append to end of note',
clear: 'Press twice to clear text area',
settingsButton: 'Open settings'

View file

@ -101,7 +101,7 @@ export const ja: TranslationResource = {
serviceInitFailed: 'サービスの初期化に失敗しました',
audioTooShort: '音声が短すぎます',
noAudioDetected: '音声が検出されませんでした',
localVadMissing: 'ローカルVADモジュールが見つからないため、サーバーVADに切り替えます。{path} に fvad.wasm と fvad.js を配置してください。'
localVadMissing: 'ローカルVADモジュールが見つからないため、サーバーVADに切り替えます。{path} に fvad.wasm を配置してください。'
},
error: {
clipboardFailed: 'クリップボードへのコピーに失敗しました',
@ -180,20 +180,20 @@ export const ja: TranslationResource = {
dictionaryImportExport: '辞書のインポート/エクスポート',
dictionaryImportExportDesc: '補正辞書をJSONファイルとしてインポート・エクスポート',
vadMode: '音声区間検出VAD',
vadModeDesc: 'オフ標準は録音をそのまま送信するため精度重視です。サーバーVADを有効にすると無音区間を先にカットし、応答が速くなる場合がありますが、切り分けによって文字起こし精度に影響する可能性があります。ローカルVADfvad.wasm/fvad.js が必要はPC側で無音を検出し自動停止します。',
vadModeLocalMissing: 'ローカルVADを有効にするには {path} に fvad.wasm と fvad.js を配置してください。',
vadModeDesc: 'オフ標準は録音をそのまま送信するため精度重視です。サーバーVADを有効にすると無音区間を先にカットし、応答が速くなる場合がありますが、切り分けによって文字起こし精度に影響する可能性があります。ローカルVADfvad.wasm が必要はPC側で無音を検出し自動停止します。',
vadModeLocalMissing: 'ローカルVADを有効にするには {path} に fvad.wasm を配置してください。',
vadModeLocalAvailable: '{path} でローカルVAD用モジュールを検出しました。無音で自動停止します。',
vadModeDisabledDesc: 'VADを無効化すると録音は常に継続し、加工せず送信されます。',
vadModeSummaryServer: 'サーバー側で無音をカット(応答を速めたい場合)',
vadModeSummaryLocal: 'デスクトップで無音を検出して自動停止fvad.wasm/fvad.js 必須)',
vadModeInstallButton: 'fvad.wasm / fvad.js を選択…',
vadModeInstallDesc: '公式の WebRTC VAD 移植プロジェクトから fvad.wasm(および fvad.jsをダウンロードし、ここで wasm ファイルを選択するとプラグインフォルダにコピーされます(デスクトップのみ)。',
vadModeSummaryLocal: 'デスクトップで無音を検出して自動停止fvad.wasm 必須)',
vadModeInstallButton: 'fvad.wasm を選択…',
vadModeInstallDesc: '公式の WebRTC VAD 移植プロジェクトから fvad.wasm をダウンロードし、ここで wasm ファイルを選択するとプラグインフォルダにコピーされます(デスクトップのみ)。',
vadModeInstallLinkLabel: 'fvad-wasm プロジェクト',
vadModeInstallInvalidName: 'fvad.wasm というファイルを選択してください。',
vadModeInstallInvalidType: 'WebAssembly モジュールではないファイルが選択されました。',
vadModeInstallSuccess: 'fvad.wasm を配置しました。次回の録音からローカルVADを試行します。',
vadModeInstallWriteError: 'VAD関連ファイルの配置に失敗しました: {error}',
vadModeInstallJsMissing: 'ローカルVADには fvad.js も同じフォルダに必要です。存在しない場合は手動で配置してください。'
vadModeInstallJsMissing: '現在のローカルVADは fvad.wasm のみを必要とします。この表示が出る場合はプラグインを更新してください。'
},
options: {
modelMini: 'GPT-4o mini Transcribe',

View file

@ -101,7 +101,7 @@ export const ko: TranslationResource = {
serviceInitFailed: '서비스 초기화에 실패했습니다',
audioTooShort: '오디오가 너무 짧습니다',
noAudioDetected: '오디오가 감지되지 않았습니다',
localVadMissing: '로컬 VAD 모듈을 찾을 수 없어 서버 VAD로 전환합니다. {path} 에 fvad.wasm 및 fvad.js 를 배치하세요.'
localVadMissing: '로컬 VAD 모듈을 찾을 수 없어 서버 VAD로 전환합니다. {path} 에 fvad.wasm 배치하세요.'
},
error: {
clipboardFailed: '클립보드 복사에 실패했습니다',
@ -180,20 +180,20 @@ export const ko: TranslationResource = {
dictionaryImportExport: '사전 가져오기/내보내기',
dictionaryImportExportDesc: '교정 사전을 JSON 파일로 가져오기 또는 내보내기',
vadMode: '음성 활동 감지(VAD)',
vadModeDesc: '기본값인 “끄기”는 원본 오디오를 그대로 전송하여 최고 정확도를 유지합니다. 서버 VAD를 켜면 업로드 전에 무음을 잘라 전체 응답 속도가 빨라질 수 있지만, 분할 방식에 따라 정확도에 영향이 있을 수 있습니다. 로컬 VAD(fvad.wasm / fvad.js 필요)는 데스크톱에서 무음을 감지해 자동으로 녹음을 멈춥니다.',
vadModeLocalMissing: '{path} 에 fvad.wasm 및 fvad.js 를 배치하면 로컬 VAD를 사용할 수 있습니다.',
vadModeDesc: '기본값인 “끄기”는 원본 오디오를 그대로 전송하여 최고 정확도를 유지합니다. 서버 VAD를 켜면 업로드 전에 무음을 잘라 전체 응답 속도가 빨라질 수 있지만, 분할 방식에 따라 정확도에 영향이 있을 수 있습니다. 로컬 VAD(fvad.wasm 필요)는 데스크톱에서 무음을 감지해 자동으로 녹음을 멈춥니다.',
vadModeLocalMissing: '{path} 에 fvad.wasm 배치하면 로컬 VAD를 사용할 수 있습니다.',
vadModeLocalAvailable: '{path} 에서 로컬 VAD 모듈을 감지했습니다. 무음 시 자동으로 녹음을 중지합니다.',
vadModeDisabledDesc: 'VAD 끄기: 무음이 있어도 녹음을 계속 진행합니다.',
vadModeSummaryServer: '서버에서 무음을 잘라 응답을 빠르게 함 (클라우드)',
vadModeSummaryLocal: '데스크톱에서 무음을 감지해 자동 중지 (fvad.wasm/fvad.js 필요)',
vadModeInstallButton: 'fvad.wasm / fvad.js 선택…',
vadModeInstallDesc: '공식 WebRTC VAD 포트에서 fvad.wasm(및 fvad.js)을 다운로드한 뒤 여기서 wasm 파일을 선택하면 플러그인 폴더에 복사됩니다(데스크톱 한정).',
vadModeSummaryLocal: '데스크톱에서 무음을 감지해 자동 중지 (fvad.wasm 필요)',
vadModeInstallButton: 'fvad.wasm 선택…',
vadModeInstallDesc: '공식 WebRTC VAD 포트에서 fvad.wasm을 다운로드한 뒤 여기서 wasm 파일을 선택하면 플러그인 폴더에 복사됩니다(데스크톱 한정).',
vadModeInstallLinkLabel: 'fvad-wasm 프로젝트',
vadModeInstallInvalidName: '파일 이름이 fvad.wasm 인 것을 선택해주세요.',
vadModeInstallInvalidType: '선택한 파일이 WebAssembly 모듈이 아닌 것 같습니다.',
vadModeInstallSuccess: 'fvad.wasm 설치 완료. 다음 녹음부터 로컬 VAD를 시도합니다.',
vadModeInstallWriteError: 'VAD 관련 파일 설치에 실패했습니다: {error}',
vadModeInstallJsMissing: '로컬 VAD에는 fvad.js 도 필요합니다. 없을 경우 동일한 폴더에 직접 복사해 주세요.'
vadModeInstallJsMissing: '현재 로컬 VAD는 fvad.wasm만 필요합니다. 이 메시지가 표시되면 플러그인을 업데이트하세요.'
},
options: {
modelMini: 'GPT-4o mini Transcribe',

View file

@ -101,7 +101,7 @@ export const zh: TranslationResource = {
serviceInitFailed: '服务初始化失败',
audioTooShort: '音频过短',
noAudioDetected: '未检测到音频',
localVadMissing: '未找到本地VAD模块已切换为服务器端检测。请将 fvad.wasm 和 fvad.js 放在 {path}。'
localVadMissing: '未找到本地VAD模块已切换为服务器端检测。请将 fvad.wasm 放在 {path}。'
},
error: {
clipboardFailed: '复制到剪贴板失败',
@ -180,20 +180,20 @@ export const zh: TranslationResource = {
dictionaryImportExport: '词典导入/导出',
dictionaryImportExportDesc: '将校正词典作为JSON文件导入或导出',
vadMode: '语音活动检测VAD',
vadModeDesc: '默认的“关闭”会保持原始音频以获得最高的转写精度。启用服务器VAD可以在上传前剪掉静音可能缩短整体响应时间但也可能影响分段精度。本地VAD需要 fvad.wasm / fvad.js)会在桌面端检测静音并自动停止录音。',
vadModeLocalMissing: '启用本地VAD需要将 fvad.wasm 和 fvad.js 放在 {path}。',
vadModeDesc: '默认的“关闭”会保持原始音频以获得最高的转写精度。启用服务器VAD可以在上传前剪掉静音可能缩短整体响应时间但也可能影响分段精度。本地VAD需要 fvad.wasm)会在桌面端检测静音并自动停止录音。',
vadModeLocalMissing: '启用本地VAD需要将 fvad.wasm 放在 {path}。',
vadModeLocalAvailable: '在 {path} 检测到本地VAD模块将在静音时自动停止录音。',
vadModeDisabledDesc: '关闭VAD持续录音并完整发送音频。',
vadModeSummaryServer: '服务器裁剪静音,加快整体响应(云端处理)',
vadModeSummaryLocal: '在桌面本地检测静音并自动停止(需 fvad.wasm/fvad.js',
vadModeInstallButton: '选择 fvad.wasm / fvad.js…',
vadModeInstallDesc: '请从官方 WebRTC VAD 移植项目下载 fvad.wasm(以及 fvad.js,在此选择 wasm 文件即可复制到插件目录(仅桌面)。',
vadModeSummaryLocal: '在桌面本地检测静音并自动停止(需 fvad.wasm',
vadModeInstallButton: '选择 fvad.wasm…',
vadModeInstallDesc: '请从官方 WebRTC VAD 移植项目下载 fvad.wasm,在此选择 wasm 文件即可复制到插件目录(仅桌面)。',
vadModeInstallLinkLabel: 'fvad-wasm 项目',
vadModeInstallInvalidName: '请选择名为 fvad.wasm 的文件。',
vadModeInstallInvalidType: '所选文件不像 WebAssembly 模块。',
vadModeInstallSuccess: '已安装 fvad.wasm。下次录音将尝试本地 VAD。',
vadModeInstallWriteError: '安装 VAD 相关文件失败:{error}',
vadModeInstallJsMissing: '本地 VAD 还需要 fvad.js。若不存在请手动复制到同一插件目录。'
vadModeInstallJsMissing: '当前本地 VAD 只需要 fvad.wasm。如果看到此消息请更新插件。'
},
options: {
modelMini: 'GPT-4o mini Transcribe',

View file

@ -30,24 +30,20 @@ export class DraftManager {
const folderPath = this.getDraftFolderPath(app);
const abstractFile = app.vault.getAbstractFileByPath(folderPath);
if (abstractFile instanceof TFolder) {
return;
}
if (abstractFile) {
this.logger?.warn('Draft path exists but is not a folder', { folderPath });
return;
}
try {
const adapter = app.vault.adapter;
if (await adapter.exists(folderPath)) {
if (abstractFile instanceof TFolder) {
return; // already exists and is a folder
}
if (abstractFile) {
this.logger?.warn('Draft path exists but is not a folder', { folderPath });
return;
}
await adapter.mkdir(folderPath);
await app.vault.createFolder(folderPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes('File already exists') && !message.includes('folder already exists')) {
const normalized = message.toLowerCase();
if (!normalized.includes('already exists')) {
this.logger?.error('Failed to create draft folder', error);
throw error;
}
@ -95,28 +91,55 @@ export class DraftManager {
await this.ensureDraftFolder(app);
let saved = false;
const writeWithVault = async (file: TFile): Promise<boolean> => {
try {
await app.vault.process(file, () => textToSave);
return true;
} catch (processError) {
this.logger?.warn('Vault.process failed for draft', processError);
}
try {
await app.vault.modify(file, textToSave);
return true;
} catch (modifyError) {
this.logger?.warn('Vault.modify failed for draft', modifyError);
}
return false;
};
const existingFile = app.vault.getFileByPath(draftPath);
if (existingFile instanceof TFile) {
try {
await app.vault.modify(existingFile, textToSave);
saved = true;
} catch (error) {
this.logger?.warn('Vault.modify failed for draft, falling back to adapter', error);
}
saved = await writeWithVault(existingFile);
} else {
try {
await app.vault.create(draftPath, textToSave);
saved = true;
const created = app.vault.getFileByPath(draftPath);
if (created instanceof TFile) {
saved = await writeWithVault(created);
} else {
saved = true; // created but not immediately resolvable; treat as success
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes('already exists')) {
this.logger?.warn('Vault.create failed for draft, falling back to adapter', error);
const normalized = message.toLowerCase();
if (normalized.includes('already exists')) {
const file = app.vault.getFileByPath(draftPath);
if (file instanceof TFile) {
saved = await writeWithVault(file);
} else {
saved = true;
}
} else {
this.logger?.warn('Vault.create failed for draft', error);
}
}
}
if (!saved) {
await app.vault.adapter.write(draftPath, textToSave);
this.logger?.error('Failed to save draft via Vault API');
return false;
}
this.logger?.info('Draft saved successfully', {
@ -146,13 +169,7 @@ export class DraftManager {
return text;
}
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;
return null;
} catch (error) {
this.logger?.error('Failed to load draft', error);
return null;
@ -172,11 +189,6 @@ export class DraftManager {
this.logger?.info('Draft cleared successfully via file manager');
return;
}
if (await app.vault.adapter.exists(draftPath)) {
await app.vault.adapter.remove(draftPath);
this.logger?.info('Draft cleared successfully (adapter fallback)');
}
} catch (error) {
this.logger?.error('Failed to clear draft', error);
}

View file

@ -200,7 +200,7 @@ export default class VoiceInputPlugin extends Plugin {
// Add timeout to prevent hanging
return Promise.race([
view.actions.stopRecording(),
new Promise(resolve => setTimeout(resolve, 1000)) // 1 second timeout
new Promise(resolve => window.setTimeout(resolve, 1000)) // 1 second timeout
]).catch(error => {
this.logger.error('Error stopping recording during unload', error);
});
@ -276,7 +276,7 @@ export default class VoiceInputPlugin extends Plugin {
}
delete migratedData.language;
needsSave = true;
this.logger?.info(`Migrating language (${data.language}) to transcriptionLanguage (${migratedData.transcriptionLanguage})`);
this.logger?.info(`Migrating language (${String(data.language ?? '')}) to transcriptionLanguage (${String(migratedData.transcriptionLanguage ?? '')})`);
}
// languageからpluginLanguageへの移行古いバージョンとの互換性のため
@ -289,7 +289,7 @@ export default class VoiceInputPlugin extends Plugin {
migratedData.pluginLanguage = 'en';
}
needsSave = true;
this.logger?.info(`Migrating language (${data.language}) to pluginLanguage (${migratedData.pluginLanguage})`);
this.logger?.info(`Migrating language (${String(data.language ?? '')}) to pluginLanguage (${String(migratedData.pluginLanguage ?? '')})`);
}
// 不要になったlanguageフィールドの削除
@ -303,15 +303,15 @@ export default class VoiceInputPlugin extends Plugin {
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})`);
this.logger?.info(`Removing recordingMode setting (was: ${String(data.recordingMode ?? '')})`);
}
if ('autoStopSilenceDuration' in data) {
delete migratedData.autoStopSilenceDuration;
this.logger?.info(`Removing autoStopSilenceDuration setting (was: ${data.autoStopSilenceDuration})`);
this.logger?.info(`Removing autoStopSilenceDuration setting (was: ${String(data.autoStopSilenceDuration ?? '')})`);
}
if ('minSpeechDuration' in data) {
delete migratedData.minSpeechDuration;
this.logger?.info(`Removing minSpeechDuration setting (was: ${data.minSpeechDuration})`);
this.logger?.info(`Removing minSpeechDuration setting (was: ${String(data.minSpeechDuration ?? '')})`);
}
needsSave = true;
this.logger?.info('Migrated to continuous recording only mode');

View file

@ -49,7 +49,7 @@ const resolveElectronRenderer = (): ElectronRenderer | null => {
if (windowElectron) {
return windowElectron ?? null;
}
return (globalThis as ElectronGlobal).electron ?? null;
return (window as ElectronGlobal).electron ?? null;
};
export class SafeStorageService {
@ -68,23 +68,23 @@ export class SafeStorageService {
// 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'}`);
this.logger.debug(`window.require exists: ${String('require' in window)}`);
this.logger.debug(`window.require is function: ${String(typeof (window as ElectronWindow).require === 'function')}`);
const electron = resolveElectronRenderer();
if (!electron) {
this.logger.debug('Electron renderer not available');
} 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.logger.debug(`Electron object exists: ${String(!!electron)}`);
this.logger.debug(`Electron.remote exists: ${String(!!electron?.remote)}`);
this.logger.debug(`Electron.safeStorage exists: ${String(!!electron?.safeStorage)}`);
this.logger.debug(`Electron.remote.safeStorage exists: ${String(!!electron?.remote?.safeStorage)}`);
this.safeStorage = electron?.remote?.safeStorage || electron?.safeStorage || null;
}
this.logger.debug(`SafeStorage initialized: ${!!this.safeStorage}`);
this.logger.debug(`SafeStorage initialized: ${String(!!this.safeStorage)}`);
} catch (e) {
this.logger.error('Error during safeStorage initialization', e instanceof Error ? e : new Error(String(e)));
}
@ -94,11 +94,15 @@ export class SafeStorageService {
/** 暗号化して保存用文字列へ変換 */
static encryptForStore(apiKey: string): string {
if (!apiKey) return '';
if (!apiKey) {
return '';
}
// Trim the API key before storing
const trimmedKey = apiKey.trim();
if (!trimmedKey) return '';
if (!trimmedKey) {
return '';
}
this.logger.debug('Encrypting API key for storage');
@ -124,7 +128,9 @@ export class SafeStorageService {
/** 保存文字列 -> 平文 API キー */
static decryptFromStore(stored: string): string {
if (!stored) return '';
if (!stored) {
return '';
}
this.logger.debug('Decrypting stored API key');
@ -169,6 +175,14 @@ export class SafeStorageService {
return '';
}
private static encodeBinaryString(text: string): string {
return Buffer.from(text, 'latin1').toString('base64');
}
private static decodeBinaryString(encoded: string): string {
return Buffer.from(encoded, 'base64').toString('latin1');
}
private static xorEncrypt(text: string, key: string): string {
let result = '';
for (let i = 0; i < text.length; i++) {
@ -176,12 +190,12 @@ export class SafeStorageService {
text.charCodeAt(i) ^ key.charCodeAt(i % key.length)
);
}
return btoa(result);
return this.encodeBinaryString(result);
}
private static xorDecrypt(encoded: string, key: string): string {
try {
const text = atob(encoded);
const text = this.decodeBinaryString(encoded);
let result = '';
for (let i = 0; i < text.length; i++) {
result += String.fromCharCode(

View file

@ -6,7 +6,9 @@ export class SecurityUtils {
* Mask API key for display purposes
*/
static maskAPIKey(key: string): string {
if (!key || key.length < 10) return '';
if (!key || key.length < 10) {
return '';
}
return key.substring(0, 7) + '*'.repeat(40);
}

View file

@ -105,7 +105,9 @@ export class I18nServiceImpl implements I18nService {
*/
private getTranslation(key: string, locale: Locale): string | undefined {
const resource = translations[locale];
if (!resource) return undefined;
if (!resource) {
return undefined;
}
// Navigate through the object using the key path
const keys = key.split('.');

View file

@ -63,11 +63,6 @@ export class VoiceInputSettingTab extends PluginSettingTab {
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'))
@ -222,7 +217,7 @@ export class VoiceInputSettingTab extends PluginSettingTab {
button.setCta();
// 3秒後に元に戻す
setTimeout(() => {
window.setTimeout(() => {
button.setButtonText(this.i18n.t('ui.buttons.connectionTest'));
button.removeCta();
button.setDisabled(false);
@ -232,7 +227,7 @@ export class VoiceInputSettingTab extends PluginSettingTab {
button.setButtonText(this.i18n.t('ui.buttons.testFailed'));
// 3秒後に元に戻す
setTimeout(() => {
window.setTimeout(() => {
button.setButtonText(this.i18n.t('ui.buttons.connectionTest'));
button.setDisabled(false);
}, 3000);
@ -267,7 +262,6 @@ export class VoiceInputSettingTab extends PluginSettingTab {
const FVAD_DOWNLOAD_URL = 'https://github.com/echogarden-project/fvad-wasm';
const wasmFileName = 'fvad.wasm';
const loaderFileName = 'fvad.js';
const vadInstructionsPath = getLocalVadInstructionsPath(this.app);
const initialVadMode = this.plugin.settings.vadMode ?? 'disabled';
const vadModeSetting = new Setting(containerEl)
@ -304,21 +298,19 @@ export class VoiceInputSettingTab extends PluginSettingTab {
helperNote = helperContainer.createDiv({ cls: 'setting-item-description' });
const buttonElement = helperContainer.createEl('button', { text: this.i18n.t('ui.settings.vadModeInstallButton') });
if (buttonElement instanceof HTMLButtonElement) {
if (buttonElement.instanceOf(HTMLButtonElement)) {
helperButton = buttonElement;
helperButton.classList.add('mod-cta');
helperContainer.classList.add('voice-input-hidden');
helperButton.addEventListener('click', () => {
const input = document.createElement('input');
const input = activeDocument.createElement('input');
input.type = 'file';
input.accept = '.wasm,.js,application/wasm';
input.multiple = true;
input.accept = '.wasm,application/wasm';
input.onchange = () => {
this.runAsync(async () => {
const files = input.files ? Array.from(input.files) : [];
const wasmFile = files.find(file => file.name === wasmFileName);
const jsFile = files.find(file => file.name === loaderFileName);
if (!wasmFile) {
new Notice(this.i18n.t('ui.settings.vadModeInstallInvalidName'));
@ -347,20 +339,7 @@ export class VoiceInputSettingTab extends PluginSettingTab {
const wasmTarget = getLocalVadAssetPath(this.app, wasmFileName);
await adapter.writeBinary(wasmTarget, toArrayBuffer(wasmBytes));
let loaderPresent = await adapter.exists(getLocalVadAssetPath(this.app, loaderFileName));
if (jsFile) {
const loaderContent = await jsFile.text();
const loaderTarget = getLocalVadAssetPath(this.app, loaderFileName);
await adapter.write(loaderTarget, loaderContent);
loaderPresent = true;
}
if (!loaderPresent) {
new Notice(this.i18n.t('ui.settings.vadModeInstallJsMissing'));
} else {
new Notice(this.i18n.t('ui.settings.vadModeInstallSuccess'));
}
new Notice(this.i18n.t('ui.settings.vadModeInstallSuccess'));
} catch (error) {
console.error(error);
new Notice(this.i18n.t('notification.error.fileWrite'));
@ -380,27 +359,27 @@ export class VoiceInputSettingTab extends PluginSettingTab {
}
const createVadDescription = (includeMissing: boolean, includeLocal: boolean): DocumentFragment => {
const fragment = document.createDocumentFragment();
const fragment = activeDocument.createDocumentFragment();
fragment.appendText(this.i18n.t('ui.settings.vadModeDesc'));
fragment.appendChild(document.createElement('br'));
fragment.appendChild(activeDocument.createElement('br'));
fragment.appendText(`${this.i18n.t('ui.options.vadServer')}: ${this.i18n.t('ui.settings.vadModeSummaryServer')}`);
fragment.appendChild(document.createElement('br'));
fragment.appendChild(activeDocument.createElement('br'));
fragment.appendText(`${this.i18n.t('ui.options.vadLocal')}: ${this.i18n.t('ui.settings.vadModeSummaryLocal')}`);
if (includeMissing) {
fragment.appendChild(document.createElement('br'));
fragment.appendChild(document.createElement('br'));
fragment.appendChild(activeDocument.createElement('br'));
fragment.appendChild(activeDocument.createElement('br'));
fragment.appendText(this.i18n.t('ui.settings.vadModeLocalMissing', { path: vadInstructionsPath }));
fragment.appendChild(document.createElement('br'));
const link = document.createElement('a');
fragment.appendChild(activeDocument.createElement('br'));
const link = activeDocument.createElement('a');
link.href = FVAD_DOWNLOAD_URL;
link.textContent = this.i18n.t('ui.settings.vadModeInstallLinkLabel');
link.target = '_blank';
link.rel = 'noopener noreferrer';
fragment.appendChild(link);
} else if (includeLocal) {
fragment.appendChild(document.createElement('br'));
fragment.appendChild(document.createElement('br'));
fragment.appendChild(activeDocument.createElement('br'));
fragment.appendChild(activeDocument.createElement('br'));
fragment.appendText(this.i18n.t('ui.settings.vadModeLocalAvailable', { path: vadInstructionsPath }));
}
@ -493,14 +472,10 @@ export class VoiceInputSettingTab extends PluginSettingTab {
// Create table container for dictionary tables
const tableContainer = containerEl.createDiv('voice-input-dictionary-table-container');
// Definite Corrections Section
// Definite Corrections Section (no heading to comply with Obsidian guidelines)
new Setting(tableContainer)
.setHeading()
.setName(this.i18n.t('ui.settings.dictionaryDefinite', { max: DICTIONARY_CONSTANTS.MAX_DEFINITE_CORRECTIONS }));
tableContainer.createEl('div', {
cls: 'setting-item-description',
text: this.i18n.t('ui.help.dictionaryFromComma')
});
.setName(this.i18n.t('ui.settings.dictionaryDefinite', { max: DICTIONARY_CONSTANTS.MAX_DEFINITE_CORRECTIONS }))
.setDesc(this.i18n.t('ui.help.dictionaryFromComma'));
this.createCorrectionTable(
tableContainer,
this.plugin.settings.customDictionary.definiteCorrections,
@ -752,17 +727,17 @@ export class VoiceInputSettingTab extends PluginSettingTab {
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const a = activeDocument.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);
activeDocument.body.appendChild(a);
a.click();
// Clean up
setTimeout(() => {
document.body.removeChild(a);
window.setTimeout(() => {
activeDocument.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
@ -778,14 +753,16 @@ export class VoiceInputSettingTab extends PluginSettingTab {
* Import dictionary from JSON
*/
private importDictionary() {
const input = document.createElement('input');
const input = activeDocument.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
this.runAsync(async () => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
if (!file) {
return;
}
try {
const text = await file.text();

View file

@ -388,11 +388,11 @@
/* Utility classes for JavaScript style replacements */
.voice-input-hidden {
display: none !important;
display: none;
}
.voice-input-flex {
display: flex !important;
display: flex;
}
/* Audio Visualizer Styles */
@ -450,6 +450,6 @@
/* Form element styles */
.voice-input-textarea-wide {
width: 100% !important;
min-height: 100px !important;
width: 100%;
min-height: 100px;
}

19
src/types/fvad-wasm.d.ts vendored Normal file
View file

@ -0,0 +1,19 @@
declare module '@echogarden/fvad-wasm' {
interface FvadWasmModule {
_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 };
}
type FvadWasmModuleFactory = (options?: {
wasmBinary?: Uint8Array;
}) => Promise<FvadWasmModule>;
const createFvadModule: FvadWasmModuleFactory;
export default createFvadModule;
}

View file

@ -37,11 +37,18 @@ export class DeferredViewHelper {
* @returns The VoiceInputView if available, null otherwise
*/
static async safeGetVoiceInputView(leaf: WorkspaceLeaf): Promise<VoiceInputView | null> {
const view = await this.safeGetView<View>(leaf);
// Narrow by constructor name to the concrete VoiceInputView
return (view && view.constructor?.name === 'VoiceInputView')
? (view as unknown as VoiceInputView)
: null;
const view = await this.safeGetView<View & { getViewType?: () => string }>(leaf);
if (!view) {
return null;
}
const viewType = typeof view.getViewType === 'function' ? view.getViewType() : undefined;
// Do not rely on constructor.name because it is minified in production builds.
// NOTE: Keep the view type string duplicated here to avoid importing from the view module
// which can introduce circular dependencies in tests.
return viewType === 'voice-input-view' ? (view as VoiceInputView) : null;
}
/**

View file

@ -62,10 +62,11 @@ export class Logger {
});
}
const prefix = `${mainLogger.config.prefix ?? ''} [${moduleName}]`;
const moduleLogger = new Logger({
debugMode: mainLogger.config.debugMode,
logLevel: mainLogger.config.logLevel,
prefix: `${mainLogger.config.prefix} [${moduleName}]`
prefix
});
mainLogger.moduleLoggers.set(moduleName, moduleLogger);
return moduleLogger;
@ -104,7 +105,8 @@ export class Logger {
*/
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}`;
const prefix = this.config.prefix ?? '';
return `${timestamp} ${LogLevel[level].padEnd(5)} ${prefix} ${message}`;
}
/**
@ -246,7 +248,8 @@ export class Logger {
*/
time(label: string): void {
if (this.shouldLog(LogLevel.DEBUG)) {
const timerKey = `${this.config.prefix} ${label}`;
const prefix = this.config.prefix ?? '';
const timerKey = `${prefix} ${label}`;
const now = typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
@ -259,9 +262,12 @@ export class Logger {
*/
timeEnd(label: string): void {
if (this.shouldLog(LogLevel.DEBUG)) {
const timerKey = `${this.config.prefix} ${label}`;
const prefix = this.config.prefix ?? '';
const timerKey = `${prefix} ${label}`;
const start = this.performanceTimers.get(timerKey);
if (start === undefined) return;
if (start === undefined) {
return;
}
const now = typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
@ -276,10 +282,11 @@ export class Logger {
* Create a scoped logger for a specific operation
*/
scope(scopeName: string): Logger {
const prefix = this.config.prefix ?? '';
return new Logger({
debugMode: this.config.debugMode,
logLevel: this.config.logLevel,
prefix: `${this.config.prefix} [${scopeName}]`
prefix: `${prefix} [${scopeName}]`
});
}
}

View file

@ -86,7 +86,7 @@ export class ObsidianHttpClient {
for (const [name, value] of entries) {
parts.push(textEncoder.encode(`--${boundary}\r\n`));
if (value != null && typeof value === 'object' && 'arrayBuffer' in value) {
if (value !== null && value !== undefined && typeof value === 'object' && 'arrayBuffer' in value) {
const blobValue = value as File | Blob;
const filename = blobValue instanceof File ? blobValue.name : 'blob';
parts.push(textEncoder.encode(

View file

@ -2,7 +2,6 @@ import { App, normalizePath } from 'obsidian';
import { FILE_CONSTANTS } from '../config';
const FVAD_WASM = 'fvad.wasm';
const FVAD_JS = 'fvad.js';
function getPluginBasePath(app: App): string {
return normalizePath(`${app.vault.configDir}/plugins/${FILE_CONSTANTS.PLUGIN_ID}`);
@ -20,15 +19,8 @@ export async function hasLocalVadAssets(app: App): Promise<boolean> {
getPluginAssetPath(app, FVAD_WASM),
normalizePath(`${baseDir}/node_modules/@echogarden/fvad-wasm/${FVAD_WASM}`)
];
const jsCandidates = [
getPluginAssetPath(app, FVAD_JS),
normalizePath(`${baseDir}/node_modules/@echogarden/fvad-wasm/${FVAD_JS}`)
];
const hasWasm = await existsInCandidates(adapter, wasmCandidates);
const hasJs = await existsInCandidates(adapter, jsCandidates);
return hasWasm && hasJs;
return existsInCandidates(adapter, wasmCandidates);
}
export function getLocalVadInstructionsPath(app: App): string {

View file

@ -19,8 +19,8 @@ export class VoiceInputView extends ItemView {
ui: VoiceInputViewUI;
actions: VoiceInputViewActions;
private blurHandler: ((e: FocusEvent) => void) | null = null;
private autoSaveTimeout: NodeJS.Timeout | null = null;
private periodicSaveInterval: NodeJS.Timeout | null = null;
private autoSaveTimeout: number | null = null;
private periodicSaveInterval: number | null = null;
private static readonly AUTO_SAVE_DELAY = 2000; // 2 seconds
private static readonly PERIODIC_SAVE_INTERVAL = 5000; // 5 seconds
@ -52,7 +52,7 @@ export class VoiceInputView extends ItemView {
private async handleOpen(): Promise<void> {
const containerElement = this.containerEl.children[1];
if (containerElement instanceof HTMLElement) {
if (containerElement.instanceOf(HTMLElement)) {
const container = containerElement;
container.empty();
container.addClass('voice-input-view');
@ -99,13 +99,13 @@ export class VoiceInputView extends ItemView {
private async handleClose(): Promise<void> {
// Clear any pending auto-save timeout
if (this.autoSaveTimeout) {
clearTimeout(this.autoSaveTimeout);
window.clearTimeout(this.autoSaveTimeout);
this.autoSaveTimeout = null;
}
// Clear periodic save interval
if (this.periodicSaveInterval) {
clearInterval(this.periodicSaveInterval);
window.clearInterval(this.periodicSaveInterval);
this.periodicSaveInterval = null;
}
@ -149,7 +149,7 @@ export class VoiceInputView extends ItemView {
refreshUI() {
// Re-create the UI with new language
const containerElement = this.containerEl.children[1];
if (containerElement instanceof HTMLElement) {
if (containerElement.instanceOf(HTMLElement)) {
const container = containerElement;
container.empty();
container.addClass('voice-input-view');
@ -192,7 +192,9 @@ export class VoiceInputView extends ItemView {
* Set up auto-save when text area loses focus
*/
private setupAutoSaveOnBlur() {
if (!this.ui?.textArea) return;
if (!this.ui?.textArea) {
return;
}
// Remove existing handler if any
if (this.blurHandler) {
@ -232,11 +234,11 @@ export class VoiceInputView extends ItemView {
private setupPeriodicSave() {
// Clear existing interval if any
if (this.periodicSaveInterval) {
clearInterval(this.periodicSaveInterval);
window.clearInterval(this.periodicSaveInterval);
}
// Set up periodic save every 5 seconds
this.periodicSaveInterval = setInterval(() => {
this.periodicSaveInterval = window.setInterval(() => {
if (this.ui?.textArea) {
const content = this.ui.textArea.value;
if (content.trim()) {
@ -256,11 +258,11 @@ export class VoiceInputView extends ItemView {
onTextChanged() {
// Clear existing timeout
if (this.autoSaveTimeout) {
clearTimeout(this.autoSaveTimeout);
window.clearTimeout(this.autoSaveTimeout);
}
// Set new timeout for auto-save (for responsiveness, keeps existing behavior)
this.autoSaveTimeout = setTimeout(() => {
this.autoSaveTimeout = window.setTimeout(() => {
if (this.ui?.textArea) {
const content = this.ui.textArea.value;
if (content.trim()) {

View file

@ -32,8 +32,8 @@ export class VoiceInputViewActions {
private isProcessingAudio = false;
// 連打や高速操作による並行実行を防ぐための遷移ロック
private isTransitioning = false;
private statusTimer: NodeJS.Timeout | null = null;
private clearConfirmTimer: NodeJS.Timeout | null = null;
private statusTimer: number | null = null;
private clearConfirmTimer: number | null = null;
private clearPressCount = 0;
private pendingVadAutoStop = false;
@ -65,7 +65,9 @@ export class VoiceInputViewActions {
* Apply transcription settings to the service
*/
private applyTranscriptionSettings() {
if (!this.transcriptionService) return;
if (!this.transcriptionService) {
return;
}
this.transcriptionService.setModel(this.plugin.settings.transcriptionModel);
// Apply transcription correction and custom dictionary settings
@ -128,7 +130,9 @@ export class VoiceInputViewActions {
* Toggle recording on/off
*/
async toggleRecording() {
if (this.isTransitioning) return; // 二重操作防止
if (this.isTransitioning) {
return; // 二重操作防止
}
this.isTransitioning = true;
if (this.audioRecorder && this.audioRecorder.isActive()) {
try {
@ -353,11 +357,13 @@ export class VoiceInputViewActions {
* Set status text and clear it after a timeout
*/
setStatusWithTimeout(text: string, timeout = 3000): void {
if (!this.view.ui.statusEl) return;
if (!this.view.ui.statusEl) {
return;
}
// Clear existing timer
if (this.statusTimer) {
clearTimeout(this.statusTimer);
window.clearTimeout(this.statusTimer);
this.statusTimer = null;
}
@ -367,7 +373,7 @@ export class VoiceInputViewActions {
this.view.ui.statusEl.removeClass('error');
// Set timer to clear status
this.statusTimer = setTimeout(() => {
this.statusTimer = window.setTimeout(() => {
if (this.view.ui.statusEl && !this.recordingState.isRecording && !this.isProcessingAudio) {
this.view.ui.statusEl.setText(this.i18n.t('status.idle'));
}
@ -453,7 +459,7 @@ export class VoiceInputViewActions {
// Check if new items were added while processing
if (this.recordingState.processingQueue.length > 0) {
// Use setTimeout to avoid potential stack overflow
setTimeout(() => {
window.setTimeout(() => {
void this.processQueue().catch((error) => {
this.logger.error('Failed to continue processing audio queue', error);
});
@ -465,10 +471,14 @@ export class VoiceInputViewActions {
* Update processing status display
*/
private updateProcessingStatus(): void {
if (!this.view.ui.statusEl) return;
if (!this.view.ui.statusEl) {
return;
}
// Don't update if a status message with timer is active
if (this.statusTimer) return;
if (this.statusTimer) {
return;
}
const queueLength = this.recordingState.processingQueue.length;
@ -599,14 +609,14 @@ export class VoiceInputViewActions {
this.setStatusWithTimeout(this.i18n.t('status.warning.clearConfirm'));
// Set timer to reset press count
this.clearConfirmTimer = setTimeout(() => {
this.clearConfirmTimer = window.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);
window.clearTimeout(this.clearConfirmTimer);
this.clearConfirmTimer = null;
}
this.clearPressCount = 0;
@ -682,7 +692,9 @@ export class VoiceInputViewActions {
* Cancel recording without processing
*/
async cancelRecording() {
if (this.isTransitioning) return; // 二重キャンセル防止
if (this.isTransitioning) {
return; // 二重キャンセル防止
}
this.isTransitioning = true;
if (!this.audioRecorder || !this.audioRecorder.isActive()) {
this.isTransitioning = false;
@ -879,13 +891,13 @@ export class VoiceInputViewActions {
// Clear status timer
if (this.statusTimer) {
clearTimeout(this.statusTimer);
window.clearTimeout(this.statusTimer);
this.statusTimer = null;
}
// Clear clear confirm timer
if (this.clearConfirmTimer) {
clearTimeout(this.clearConfirmTimer);
window.clearTimeout(this.clearConfirmTimer);
this.clearConfirmTimer = null;
}

View file

@ -194,13 +194,13 @@ export class VoiceInputViewUI {
this.setupRecordButtonHandlers();
// Legacy buttons (hidden)
this.formatButton = document.createElement('button');
this.formatButton = activeDocument.createElement('button');
this.formatButton.classList.add('voice-input-hidden');
this.contextCorrectionButton = document.createElement('button');
this.contextCorrectionButton = activeDocument.createElement('button');
this.contextCorrectionButton.classList.add('voice-input-hidden');
this.cleanupButton = document.createElement('button');
this.cleanupButton = activeDocument.createElement('button');
this.cleanupButton.classList.add('voice-input-hidden');
}
@ -255,7 +255,7 @@ export class VoiceInputViewUI {
* Setup record button event handlers for both click and push-to-talk
*/
private setupRecordButtonHandlers() {
let longPressTimer: NodeJS.Timeout | null = null;
let longPressTimer: number | null = null;
let isPushToTalk = false;
// let pressStartTime = 0;
@ -275,7 +275,7 @@ export class VoiceInputViewUI {
// pressStartTime = Date.now();
// Start a timer to begin recording after threshold
longPressTimer = setTimeout(() => {
longPressTimer = window.setTimeout(() => {
isPushToTalk = true;
longPressTimer = null;
@ -295,7 +295,7 @@ export class VoiceInputViewUI {
// Cancel timer if still running (short press)
if (longPressTimer) {
clearTimeout(longPressTimer);
window.clearTimeout(longPressTimer);
longPressTimer = null;
return;
}
@ -307,7 +307,9 @@ export class VoiceInputViewUI {
console.error('Failed to stop recording after push-to-talk', error);
});
// Keep isPushToTalk true to prevent click handler
setTimeout(() => { isPushToTalk = false; }, UI_CONSTANTS.PUSH_TO_TALK_RESET_DELAY);
window.setTimeout(() => {
isPushToTalk = false;
}, UI_CONSTANTS.PUSH_TO_TALK_RESET_DELAY);
}
};
@ -406,7 +408,9 @@ export class VoiceInputViewUI {
* Setup text change listener for auto-save
*/
private setupTextChangeListener() {
if (!this.textArea) return;
if (!this.textArea) {
return;
}
// Create the text change handler
this.textChangeHandler = () => {

View file

@ -17,6 +17,11 @@ export class App {
vault = new Vault();
}
export const Platform = {
isMobileApp: false,
isDesktopApp: true,
};
export class Plugin {
app = new App();

View file

@ -0,0 +1,11 @@
import { SafeStorageService } from '../../../src/security/SafeStorageService';
describe('SafeStorageService', () => {
test('round-trips API keys through the fallback store format without browser base64 APIs', () => {
const apiKey = 'sk-proj-test-key-abcdefghijklmnopqrstuvwxyz1234567890';
const stored = SafeStorageService.encryptForStore(apiKey);
expect(stored).toMatch(/^XOR_V1::/u);
expect(SafeStorageService.decryptFromStore(stored)).toBe(apiKey);
});
});

View file

@ -11,6 +11,7 @@
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"types": ["node"],
"lib": ["DOM", "ES2018", "DOM.Iterable"]
},
"include": ["**/*.ts", "src/types/**/*.d.ts"],

View file

@ -1,5 +1,9 @@
{
"0.8.0": "1.8.0",
"0.9.0": "1.8.0",
"0.9.1": "1.8.0"
"0.9.1": "1.8.0",
"0.9.2": "1.8.0",
"0.9.3": "1.8.0",
"0.9.4": "1.8.0",
"0.9.5": "1.8.0"
}