diff --git a/.gitignore b/.gitignore index ca0fe32..d08e3aa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,5 @@ main.js data.json .superpowers/ .bounded -tests/fixtures/*.m4a -tests/fixtures/*.webm .DS_Store +.claude/worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 3c3270d..eb9e070 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ ## Тесты - Golden-тесты: `tests/fixtures/.m4a` + `tests/fixtures/.txt` (эталонный транскрипт). Провайдер в тестах мокается; golden-файлы проверяют формирование заметки и обработку ответа, а не сам сервис. -- Тестовый vault: `<путь укажите>` — синхронизируется между Mac и телефоном. +- Тестовый vault: `/Users/andrey.epifancev/Library/Mobile Documents/iCloud~md~obsidian/Documents/voice-note-test` — синхронизируется между Mac и телефоном. ## Первая подсистема Жизненный цикл записи и очередь (core), затем провайдер, затем обёртка. Тестовая инфраструктура — Task 1 первого плана. diff --git a/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md new file mode 100644 index 0000000..3184b76 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md @@ -0,0 +1,2528 @@ +# Core Recording Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Чистая подсистема `src/core/`: жизненный цикл голосовой записи, формат заметки, планировщик повторов и очередь отправки поверх портов — плюс тестовая инфраструктура проекта. + +**Architecture:** Чистые функции (`record.ts`, `naming.ts`, `note.ts`, `schedule.ts`) + два модуля, работающие через порты (`capture.ts`, `queue.ts`). Порты (`RecordingStore`, `NoteStore`, `Transcriber`, `Env`) реализует позже обёртка Obsidian; в тестах — in-memory фейки. Frontmatter и тело заметки меняются одной операцией `NoteStore.process`. + +**Tech Stack:** TypeScript (strict), esbuild (шаблон obsidian-sample-plugin), vitest, Node 24 только для тестов и сборки. + +**Spec:** `openspec/changes/core-recording-lifecycle/` — `proposal.md`, `design.md` (включая «Decisions made without discussion»), `specs/recording-lifecycle/spec.md`, `specs/voice-note-format/spec.md`. Issue: #1. Ветка: `feat/1-core-recording-lifecycle`. + +## Global Constraints + +- `src/core/**` MUST NOT импортировать `obsidian`, `fs`, `path`, `child_process`, `node:*` (проверяет `tests/core-boundary.test.ts`). +- Рантайм — только Web API; `manifest.json`: `"isDesktopOnly": false`. +- Папки: аудио `voice/source/`, заметки `voice/inbox/`. +- `voice-id` = `YYYYMMDD-HHMMSS-xxxx` по **UTC**; имя заметки `YYYY-MM-DD HH-mm-ss.md` по **локальному** времени; коллизия → ` (2)`, ` (3)`… +- Расширения: `audio/mp4`→`m4a`, `audio/webm`→`webm`, `audio/ogg`→`ogg`, иначе `bin` (mime без параметров, регистр игнорируется). +- `MAX_ATTEMPTS = 5`; паузы после 1–4 неудачи: 30 с, 2 мин, 10 мин, 30 мин; зависший `sent` — строго больше 10 минут. +- Пустой транскрипт → `failed` с `voice-error: no-speech`, в заметке `> [!info] В записи не распознано речи`. +- Маркеры области: `%%voice-note:%%` … `%%/voice-note%%`. +- Тексты статусов (дословно): `> [!info] Распознаётся…`; `> [!info] Не удалось отправить, повтор в HH:mm (попытка N/5)`; `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`; `> [!note] Запись прервана, сохранено M:SS`. +- Строковые значения `voice-*` во frontmatter — в двойных кавычках (JSON-экранирование), числа и `true` — без кавычек. +- Тесты запускаются с `TZ=Europe/Moscow` (UTC+3, без летнего времени) — выставляется в `vitest.config.ts`. +- `npm test` — весь набор, зелёным считается только весь набор. +- Любое решение, принятое исполнителем без обсуждения, дописывается в `openspec/changes/core-recording-lifecycle/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). +- Коммиты заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 ` и ссылкой `Refs #1`. + +## Review Focus + +1. Транскрипт содержит `$&`, `$'`, `%%` или `---` — должен вставиться дословно (Task 5: тест «special replacement patterns»). +2. Сообщение ошибки провайдера с переводами строк, кавычками и двоеточиями — frontmatter остаётся валидным однострочным, callout однострочный (Task 4: тест «round-trips hostile error text»; Task 5: тест «collapses multi-line error»). +3. `metadataCache` отстаёт: `list()` показывает `saved`, а в файле уже `sent`/`inserted` — повторной отправки нет (Task 8: тесты «stale list»). +4. Заметку удалили между `list()` и отправкой — пропуск без исключения, остальные записи обрабатываются (Task 8: тест «note deleted before send»). +5. `voice-next-attempt` испорчен руками (не дата) — запись считается готовой к отправке, а не зависает навсегда (Task 6: тест «unparsable nextAttempt is due»). + +## File Structure + +| Файл | Ответственность | +|---|---| +| `package.json`, `tsconfig.json`, `vitest.config.ts`, `esbuild.config.mjs`, `manifest.json` | сборка, типы, тесты | +| `src/main.ts` | пустой плагин, чтобы сборка работала (обёртка — другая issue) | +| `src/core/ports.ts` | интерфейсы портов и `TranscribeResult` | +| `src/core/naming.ts` | id, пути, расширения, форматирование времени/длительности | +| `src/core/record.ts` | `VoiceRecord`, константы, `transition` | +| `src/core/note.ts` | frontmatter `voice-*` (чтение, разбор, патч) и тело заметки (начальное, рендер, вставка) | +| `src/core/schedule.ts` | `due`, `isStaleSent`, `staleOwn`, `claimable` | +| `src/core/capture.ts` | `saveCapture` | +| `src/core/queue.ts` | `QueueRunner` | +| `tests/core-boundary.test.ts` | страж границы core | +| `tests/fakes.ts` | in-memory порты, `TestEnv` | +| `tests/*.test.ts` | по одному на модуль + `golden.test.ts` | +| `tests/fixtures/test-01.note.md` | эталон итоговой заметки | + +--- + +### Task 1: Scaffold, boundary guard, ports + +**Model:** sonnet + +**Files:** +- Create: `package.json`, `tsconfig.json`, `vitest.config.ts`, `esbuild.config.mjs`, `manifest.json`, `src/main.ts`, `src/core/ports.ts`, `tests/core-boundary.test.ts` + +**Interfaces:** +- Consumes: — +- Produces: `src/core/ports.ts`: + - `interface RecordingStore { write(path: string, data: ArrayBuffer): Promise; read(path: string): Promise }` + - `interface NoteStore { exists(path: string): Promise; create(path: string, content: string): Promise; list(): Promise; process(path: string, fn: (content: string) => string): Promise }` + - `interface ListedNote { path: string; frontmatter: Record }` + - `type TranscribeResult = { ok: true; text: string } | { ok: false; kind: 'temporary' | 'permanent'; message: string }` + - `interface Transcriber { transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise }` + - `interface Env { now(): Date; random(): number; readonly deviceId: string }` + - команды `npm test`, `npm run build`, `npm run dev`, `npm run typecheck` + +- [ ] **Step 1: package.json и зависимости** + +```json +{ + "name": "obsidian-voice-note", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc --noEmit && node esbuild.config.mjs production", + "typecheck": "tsc --noEmit", + "test": "vitest run" + } +} +``` + +Run: `npm install -D typescript esbuild vitest obsidian @types/node` +Expected: `node_modules/` создан, в `package.json` появились `devDependencies`, создан `package-lock.json`. + +- [ ] **Step 2: tsconfig.json, vitest.config.ts** + +`tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "noImplicitOverride": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} +``` + +`vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config'; + +// Детерминированное локальное время для имён заметок и строк статуса: +// UTC+3 без перехода на летнее время. Воркеры vitest наследуют env. +process.env.TZ = 'Europe/Moscow'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +}); +``` + +- [ ] **Step 3: Написать падающий тест-страж** + +`tests/core-boundary.test.ts`: + +```ts +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const CORE = fileURLToPath(new URL('../src/core/', import.meta.url)); +const FORBIDDEN = + /(?:from\s+|import\s*\(\s*|require\s*\(\s*)['"](?:obsidian|fs|path|child_process|node:[^'"]*)(?:\/[^'"]*)?['"]/; + +function coreFiles(): string[] { + if (!existsSync(CORE)) return []; + return readdirSync(CORE, { recursive: true, encoding: 'utf8' }) + .filter((f) => f.endsWith('.ts')) + .map((f) => join(CORE, f)); +} + +describe('src/core boundary', () => { + it('detects forbidden imports', () => { + expect(FORBIDDEN.test(`import { Plugin } from 'obsidian';`)).toBe(true); + expect(FORBIDDEN.test(`import { readFile } from "fs/promises";`)).toBe(true); + expect(FORBIDDEN.test(`import x from 'node:path';`)).toBe(true); + expect(FORBIDDEN.test(`const cp = require('child_process');`)).toBe(true); + expect(FORBIDDEN.test(`import { transition } from './record';`)).toBe(false); + }); + + it('has files to check', () => { + expect(coreFiles().length).toBeGreaterThan(0); + }); + + it('imports neither obsidian nor Node modules', () => { + const offenders = coreFiles().filter((f) => FORBIDDEN.test(readFileSync(f, 'utf8'))); + expect(offenders).toEqual([]); + }); +}); +``` + +- [ ] **Step 4: Убедиться, что тест падает** + +Run: `npm test` +Expected: FAIL в `has files to check` (`expected 0 to be greater than 0`). + +- [ ] **Step 5: ports.ts** + +`src/core/ports.ts`: + +```ts +// Порты core. Реализации — в src/obsidian (позже) и tests/fakes.ts. + +export interface RecordingStore { + write(path: string, data: ArrayBuffer): Promise; + read(path: string): Promise; +} + +export interface ListedNote { + path: string; + frontmatter: Record; +} + +export interface NoteStore { + exists(path: string): Promise; + create(path: string, content: string): Promise; + // Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter. + // КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать + // содержимое файлов (vault.read / cachedRead / adapter). Вызывается на каждом + // tick, в том числе на телефоне. Данные могут отставать от диска — core + // перепроверяет состояние внутри process(). + list(): Promise; + // Атомарное чтение-изменение-запись. false, если заметки нет. + process(path: string, fn: (content: string) => string): Promise; +} + +export type TranscribeResult = + | { ok: true; text: string } + | { ok: false; kind: 'temporary' | 'permanent'; message: string }; + +export interface Transcriber { + transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise; +} + +export interface Env { + now(): Date; + random(): number; + readonly deviceId: string; +} +``` + +- [ ] **Step 6: Убедиться, что тест проходит** + +Run: `npm test` +Expected: PASS, 3 теста. + +- [ ] **Step 7: Сборка: manifest, main.ts, esbuild** + +`manifest.json`: + +```json +{ + "id": "voice-note", + "name": "Voice Note", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Голосовые заметки: запись, распознавание внешним сервисом, текст в заметку.", + "author": "Andrey Epifancev", + "isDesktopOnly": false +} +``` + +`src/main.ts`: + +```ts +import { Plugin } from 'obsidian'; + +// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала. +export default class VoiceNotePlugin extends Plugin { + override async onload(): Promise {} +} +``` + +`esbuild.config.mjs`: + +```js +import esbuild from 'esbuild'; +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import process from 'node:process'; + +const prod = process.argv[2] === 'production'; +const VAULT = '/Users/andrey.epifancev/Library/Mobile Documents/iCloud~md~obsidian/Documents/voice-note-test'; +const PLUGIN_DIR = `${VAULT}/.obsidian/plugins/voice-note`; + +// В dev-режиме после каждой сборки копируем плагин в тестовый vault, если он есть на этой машине. +const copyToVault = { + name: 'copy-to-vault', + setup(build) { + build.onEnd((result) => { + if (result.errors.length > 0 || !existsSync(VAULT)) return; + mkdirSync(PLUGIN_DIR, { recursive: true }); + for (const file of ['main.js', 'manifest.json']) copyFileSync(file, `${PLUGIN_DIR}/${file}`); + }); + }, +}; + +const context = await esbuild.context({ + 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', + ...builtinModules, + ], + format: 'cjs', + target: 'es2018', + logLevel: 'info', + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', + minify: prod, + plugins: prod ? [] : [copyToVault], +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} +``` + +- [ ] **Step 8: Проверить сборку и типы** + +Run: `npm run build && ls -la main.js` +Expected: exit 0, `main.js` существует (он в `.gitignore`). + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add package.json package-lock.json tsconfig.json vitest.config.ts esbuild.config.mjs manifest.json src/main.ts src/core/ports.ts tests/core-boundary.test.ts +git commit -m "chore: scaffold build and vitest, add core ports and boundary guard + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: naming + +**Model:** haiku + +**Files:** +- Create: `src/core/naming.ts`, `tests/naming.test.ts` + +**Interfaces:** +- Consumes: — +- Produces: + - `SOURCE_DIR = 'voice/source'`, `INBOX_DIR = 'voice/inbox'` + - `makeId(startedAt: Date, random: () => number): string` + - `extFromMime(mime: string): string` + - `audioPath(id: string, mime: string): string` + - `notePath(startedAt: Date, n: number): string` (n=1 — без суффикса) + - `formatDuration(ms: number): string` → `M:SS` + - `formatLocalTime(iso: string): string` → `HH:mm` по локальному времени + +- [ ] **Step 1: Написать падающие тесты** + +`tests/naming.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { + audioPath, + extFromMime, + formatDuration, + formatLocalTime, + makeId, + notePath, +} from '../src/core/naming'; + +const T = new Date('2026-09-27T11:15:03.000Z'); // 14:15:03 по Москве + +function seq(...values: number[]): () => number { + return () => values.shift() ?? 0; +} + +describe('test environment', () => { + it('runs in UTC+3', () => { + expect(T.getHours()).toBe(14); + }); +}); + +describe('makeId', () => { + it('uses UTC time and 4 hex chars', () => { + expect(makeId(T, () => 0)).toBe('20260927-111503-0000'); + expect(makeId(T, seq(0.99, 0.5, 0.0625, 0.1))).toBe('20260927-111503-f811'); + }); + + it('clamps random() === 1 to f', () => { + expect(makeId(T, () => 1)).toBe('20260927-111503-ffff'); + }); + + it('does not depend on the local date', () => { + const lateEvening = new Date('2026-09-27T22:30:00.000Z'); // 01:30 28-го по Москве + expect(makeId(lateEvening, () => 0)).toBe('20260927-223000-0000'); + expect(notePath(lateEvening, 1)).toBe('voice/inbox/2026-09-28 01-30-00.md'); + }); +}); + +describe('extFromMime', () => { + it.each([ + ['audio/mp4', 'm4a'], + ['audio/webm', 'webm'], + ['audio/webm;codecs=opus', 'webm'], + ['AUDIO/MP4', 'm4a'], + ['audio/ogg; codecs=opus', 'ogg'], + ['audio/x-foo', 'bin'], + ['', 'bin'], + ])('%s → %s', (mime, ext) => { + expect(extFromMime(mime)).toBe(ext); + }); +}); + +describe('paths', () => { + it('audioPath', () => { + expect(audioPath('20260927-111503-0000', 'audio/mp4')).toBe('voice/source/20260927-111503-0000.m4a'); + }); + + it('notePath uses local time and suffix from 2', () => { + expect(notePath(T, 1)).toBe('voice/inbox/2026-09-27 14-15-03.md'); + expect(notePath(T, 2)).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); + expect(notePath(T, 3)).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); + }); +}); + +describe('formatting', () => { + it.each([ + [0, '0:00'], + [47_000, '0:47'], + [125_400, '2:05'], + [-5, '0:00'], + ])('formatDuration(%d) = %s', (ms, text) => { + expect(formatDuration(ms)).toBe(text); + }); + + it('formatLocalTime', () => { + expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17'); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/naming.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/naming"`. + +- [ ] **Step 3: Реализация** + +`src/core/naming.ts`: + +```ts +export const SOURCE_DIR = 'voice/source'; +export const INBOX_DIR = 'voice/inbox'; + +const pad = (n: number) => String(n).padStart(2, '0'); + +// Время в id — UTC: порядок id не зависит от пояса устройства. +export function makeId(startedAt: Date, random: () => number): string { + const d = startedAt; + const date = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`; + const time = `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`; + let hex = ''; + for (let i = 0; i < 4; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16); + return `${date}-${time}-${hex}`; +} + +const EXT: Record = { + 'audio/mp4': 'm4a', + 'audio/webm': 'webm', + 'audio/ogg': 'ogg', +}; + +export function extFromMime(mime: string): string { + const base = mime.split(';')[0].trim().toLowerCase(); + return EXT[base] ?? 'bin'; +} + +export function audioPath(id: string, mime: string): string { + return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`; +} + +// Имя заметки — для человека, поэтому в локальном времени. +export function notePath(startedAt: Date, n: number): string { + const d = startedAt; + const name = + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; + return `${INBOX_DIR}/${name}${n > 1 ? ` (${n})` : ''}.md`; +} + +export function formatDuration(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + return `${Math.floor(total / 60)}:${pad(total % 60)}`; +} + +export function formatLocalTime(iso: string): string { + const d = new Date(iso); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS (все файлы). + +- [ ] **Step 5: Commit** + +```bash +git add src/core/naming.ts tests/naming.test.ts +git commit -m "feat(core): record ids, file paths, time formatting + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: record state machine + +**Model:** sonnet + +**Files:** +- Create: `src/core/record.ts`, `tests/record.test.ts` + +**Interfaces:** +- Consumes: — +- Produces: + - `type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted'`, `STATUSES: readonly Status[]` + - `MAX_ATTEMPTS = 5`, `BACKOFF_MS: readonly number[]` (`[30_000, 120_000, 600_000, 1_800_000]`), `SENT_STALE_MS = 600_000`, `NO_SPEECH = 'no-speech'` + - `interface VoiceRecord { id; status; device; audio (путь без [[ ]]); mime; attempts: number; nextAttempt?: string; sentAt?: string; error?: string; interrupted?: boolean }` + - `type RecordEvent = {type:'send'} | {type:'success'} | {type:'temporary';message} | {type:'permanent';message} | {type:'retry-now';device} | {type:'claim';device}` + - `class InvalidTransition extends Error` + - `transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord` — чистая, бросает `InvalidTransition` + +- [ ] **Step 1: Написать падающие тесты** + +`tests/record.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { + BACKOFF_MS, + InvalidTransition, + MAX_ATTEMPTS, + transition, + type RecordEvent, + type Status, + type VoiceRecord, +} from '../src/core/record'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const base: VoiceRecord = Object.freeze({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, +}); +const sent = (over: Partial = {}): VoiceRecord => + Object.freeze({ ...base, status: 'sent' as const, sentAt: NOW.toISOString(), ...over }); + +describe('transition', () => { + it('send: saved → sent with sentAt', () => { + expect(transition(base, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + sentAt: '2026-09-27T11:15:03.000Z', + }); + }); + + it('send: retry → sent, drops nextAttempt, keeps attempts and error', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 1, nextAttempt: 'x', error: 'timeout' }); + expect(transition(r, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + attempts: 1, + error: 'timeout', + sentAt: NOW.toISOString(), + }); + }); + + it('success: sent → inserted, clears transient keys, keeps attempts', () => { + expect(transition(sent({ error: 'old', attempts: 2 }), { type: 'success' }, NOW)).toEqual({ + ...base, + status: 'inserted', + attempts: 2, + }); + }); + + it('temporary: backoff 30s, 2m, 10m, 30m after failures 1..4', () => { + const got = [0, 1, 2, 3].map((a) => + transition(sent({ attempts: a }), { type: 'temporary', message: 'timeout' }, NOW), + ); + expect(got.map((r) => r.status)).toEqual(['retry', 'retry', 'retry', 'retry']); + expect(got.map((r) => r.attempts)).toEqual([1, 2, 3, 4]); + expect(got.map((r) => Date.parse(r.nextAttempt!) - NOW.getTime())).toEqual([...BACKOFF_MS]); + expect(got.every((r) => r.sentAt === undefined && r.error === 'timeout')).toBe(true); + }); + + it('temporary: fifth failure → failed with last message', () => { + expect( + transition(sent({ attempts: MAX_ATTEMPTS - 1 }), { type: 'temporary', message: 'e5' }, NOW), + ).toEqual({ ...base, status: 'failed', attempts: 5, error: 'e5' }); + }); + + it('permanent: sent → failed immediately', () => { + expect(transition(sent(), { type: 'permanent', message: '401 Unauthorized' }, NOW)).toEqual({ + ...base, + status: 'failed', + attempts: 1, + error: '401 Unauthorized', + }); + }); + + it('retry-now: failed → saved, attempts 0, device reassigned', () => { + const r = Object.freeze({ ...base, status: 'failed' as const, attempts: 5, error: '401' }); + expect(transition(r, { type: 'retry-now', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + it('retry-now: retry → saved, drops nextAttempt', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'retry-now', device: 'd1' }, NOW)).toEqual(base); + }); + + it('claim: retry → saved, new device, attempts kept', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'claim', device: 'd2' }, NOW)).toEqual({ + ...base, + device: 'd2', + attempts: 2, + error: 't', + }); + }); + + it('claim: sent → saved, drops sentAt', () => { + expect(transition(sent(), { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + const invalid: [Status, RecordEvent][] = [ + ['inserted', { type: 'send' }], + ['failed', { type: 'send' }], + ['sent', { type: 'send' }], + ['saved', { type: 'success' }], + ['retry', { type: 'success' }], + ['saved', { type: 'temporary', message: 'x' }], + ['inserted', { type: 'permanent', message: 'x' }], + ['saved', { type: 'retry-now', device: 'd1' }], + ['inserted', { type: 'retry-now', device: 'd1' }], + ['failed', { type: 'claim', device: 'd2' }], + ['inserted', { type: 'claim', device: 'd2' }], + ]; + + it.each(invalid)('rejects %s + %o', (status, event) => { + const r = Object.freeze({ ...base, status }); + expect(() => transition(r, event, NOW)).toThrow(InvalidTransition); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/record.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/record"`. + +- [ ] **Step 3: Реализация** + +`src/core/record.ts`: + +```ts +// Персистентные состояния. created и transcribed живут только в памяти и сюда не входят. +export type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted'; +export const STATUSES: readonly Status[] = ['saved', 'sent', 'retry', 'failed', 'inserted']; + +export const MAX_ATTEMPTS = 5; +// Пауза перед повтором после 1-й, 2-й, 3-й и 4-й временной неудачи. +export const BACKOFF_MS: readonly number[] = [30_000, 120_000, 600_000, 1_800_000]; +// sent старше этого считается прерванной отправкой. +export const SENT_STALE_MS = 600_000; +// Код причины в voice-error для пустого транскрипта. +export const NO_SPEECH = 'no-speech'; + +export interface VoiceRecord { + id: string; + status: Status; + device: string; + audio: string; // путь в vault, без [[ ]] + mime: string; + attempts: number; + nextAttempt?: string; // ISO UTC + sentAt?: string; // ISO UTC + error?: string; + interrupted?: boolean; +} + +export type RecordEvent = + | { type: 'send' } + | { type: 'success' } + | { type: 'temporary'; message: string } + | { type: 'permanent'; message: string } + | { type: 'retry-now'; device: string } + | { type: 'claim'; device: string }; + +export class InvalidTransition extends Error { + constructor(from: Status, event: RecordEvent['type']) { + super(`invalid transition: ${event} from ${from}`); + this.name = 'InvalidTransition'; + } +} + +type Transient = 'nextAttempt' | 'sentAt' | 'error'; + +function without(record: VoiceRecord, ...keys: Transient[]): VoiceRecord { + const copy = { ...record }; + for (const key of keys) delete copy[key]; + return copy; +} + +export function transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord { + const from = record.status; + switch (event.type) { + case 'send': + if (from !== 'saved' && from !== 'retry') break; + return { ...without(record, 'nextAttempt'), status: 'sent', sentAt: now.toISOString() }; + case 'success': + if (from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt', 'error'), status: 'inserted' }; + case 'temporary': { + if (from !== 'sent') break; + const attempts = record.attempts + 1; + const next = { ...without(record, 'sentAt', 'nextAttempt'), attempts, error: event.message }; + if (attempts >= MAX_ATTEMPTS) return { ...next, status: 'failed' }; + const at = new Date(now.getTime() + BACKOFF_MS[attempts - 1]).toISOString(); + return { ...next, status: 'retry', nextAttempt: at }; + } + case 'permanent': + if (from !== 'sent') break; + return { + ...without(record, 'sentAt', 'nextAttempt'), + status: 'failed', + attempts: record.attempts + 1, + error: event.message, + }; + case 'retry-now': + if (from !== 'failed' && from !== 'retry') break; + return { + ...without(record, 'nextAttempt', 'sentAt', 'error'), + status: 'saved', + attempts: 0, + device: event.device, + }; + case 'claim': + if (from !== 'saved' && from !== 'retry' && from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt'), status: 'saved', device: event.device }; + } + throw new InvalidTransition(from, event.type); +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/record.ts tests/record.test.ts +git commit -m "feat(core): voice record state machine with backoff + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: note frontmatter (read, parse, patch) + +**Model:** sonnet + +**Files:** +- Create: `src/core/note.ts`, `tests/note-frontmatter.test.ts` + +**Interfaces:** +- Consumes: из `record.ts`: `STATUSES`, `Status`, `VoiceRecord`. +- Produces (в `src/core/note.ts`): + - `VOICE_KEYS` (порядок ключей), `type VoiceKey` + - `recordToFields(r: VoiceRecord): Record` — значения уже в YAML-виде + - `readVoiceFields(content: string): Record` — ключи `voice-*` из frontmatter текста, значения раскавычены + - `parseRecord(fields: Record): VoiceRecord | null` — принимает и строки (из `readVoiceFields`), и типизированные значения (из `metadataCache`) + - `patchFrontmatter(content: string, fields: Record): string` + +- [ ] **Step 1: Написать падающие тесты** + +`tests/note-frontmatter.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { parseRecord, patchFrontmatter, readVoiceFields, recordToFields } from '../src/core/note'; +import type { VoiceRecord } from '../src/core/record'; + +const rec: VoiceRecord = { + id: '20260927-111503-0000', + status: 'retry', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/webm;codecs=opus', + attempts: 2, + nextAttempt: '2026-09-27T11:17:03.000Z', + error: 'timeout', + interrupted: true, +}; + +describe('recordToFields + patchFrontmatter', () => { + it('writes keys in fixed order with quoted strings', () => { + expect(patchFrontmatter('', recordToFields(rec))).toBe( + [ + '---', + 'voice-id: "20260927-111503-0000"', + 'voice-status: "retry"', + 'voice-device: "d1"', + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"', + 'voice-mime: "audio/webm;codecs=opus"', + 'voice-attempts: 2', + 'voice-next-attempt: "2026-09-27T11:17:03.000Z"', + 'voice-error: "timeout"', + 'voice-interrupted: true', + '---', + '', + ].join('\n'), + ); + }); + + it('round-trips through readVoiceFields + parseRecord', () => { + const content = patchFrontmatter('', recordToFields(rec)); + expect(parseRecord(readVoiceFields(content))).toEqual(rec); + }); + + it('round-trips hostile error text as a single line', () => { + const hostile = { ...rec, error: 'Error: "bad"\nline two: x \\ y — ошибка' }; + const content = patchFrontmatter('', recordToFields(hostile)); + const errorLines = content.split('\n').filter((l) => l.startsWith('voice-error:')); + expect(errorLines).toHaveLength(1); + expect(content.split('\n').filter((l) => l.startsWith('line two'))).toHaveLength(0); + expect(parseRecord(readVoiceFields(content))).toEqual(hostile); + }); +}); + +describe('patchFrontmatter', () => { + const user = '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\n---\nbody\n'; + + it('replaces in place and keeps user keys and order', () => { + expect(patchFrontmatter(user, { 'voice-status': '"inserted"' })).toBe( + '---\ntitle: Идея\nvoice-status: "inserted"\ntags: [idea]\n---\nbody\n', + ); + }); + + it('appends new keys at the end of the block', () => { + expect(patchFrontmatter(user, { 'voice-sent-at': '"t"' })).toBe( + '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\nvoice-sent-at: "t"\n---\nbody\n', + ); + }); + + it('removes keys set to undefined, ignores absent ones', () => { + expect(patchFrontmatter(user, { 'voice-status': undefined, 'voice-error': undefined })).toBe( + '---\ntitle: Идея\ntags: [idea]\n---\nbody\n', + ); + }); + + it('creates a block when there is none, body untouched', () => { + expect(patchFrontmatter('body\n', { 'voice-id': '"x"' })).toBe('---\nvoice-id: "x"\n---\nbody\n'); + }); + + it('does not match a longer key with the same prefix', () => { + expect(patchFrontmatter('---\nvoice-idx: 1\n---\n', { 'voice-id': '"x"' })).toBe( + '---\nvoice-idx: 1\nvoice-id: "x"\n---\n', + ); + }); +}); + +describe('readVoiceFields', () => { + it('reads only the frontmatter block', () => { + const content = '---\nvoice-id: "a"\nother: 1\n---\nvoice-id: "b"\n'; + expect(readVoiceFields(content)).toEqual({ 'voice-id': 'a' }); + }); + + it('returns {} without frontmatter', () => { + expect(readVoiceFields('voice-id: "a"\n')).toEqual({}); + }); + + it('accepts plain and single-quoted values', () => { + expect(readVoiceFields("---\nvoice-id: abc\nvoice-mime: 'audio/mp4'\n---\n")).toEqual({ + 'voice-id': 'abc', + 'voice-mime': 'audio/mp4', + }); + }); +}); + +describe('parseRecord', () => { + const typed = { + 'voice-id': '20260927-111503-0000', + 'voice-status': 'saved', + 'voice-device': 'd1', + 'voice-audio': '[[voice/source/20260927-111503-0000.m4a]]', + 'voice-mime': 'audio/mp4', + 'voice-attempts': 0, + }; + + it('accepts metadataCache-style typed values', () => { + expect(parseRecord({ ...typed, 'voice-attempts': 3, 'voice-interrupted': true })).toEqual({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 3, + interrupted: true, + }); + }); + + it('defaults missing attempts to 0', () => { + const { 'voice-attempts': _omit, ...rest } = typed; + expect(parseRecord(rest)?.attempts).toBe(0); + }); + + it.each([ + ['missing id', { ...typed, 'voice-id': undefined }], + ['bad status', { ...typed, 'voice-status': 'foo' }], + ['negative attempts', { ...typed, 'voice-attempts': -1 }], + ['non-numeric attempts', { ...typed, 'voice-attempts': 'abc' }], + ['object audio', { ...typed, 'voice-audio': { a: 1 } }], + ])('returns null for %s', (_name, fields) => { + expect(parseRecord(fields)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/note-frontmatter.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/note"`. + +- [ ] **Step 3: Реализация** + +`src/core/note.ts`: + +```ts +import { STATUSES, type Status, type VoiceRecord } from './record'; + +// Порядок, в котором ключи появляются в новой заметке. +export const VOICE_KEYS = [ + 'voice-id', + 'voice-status', + 'voice-device', + 'voice-audio', + 'voice-mime', + 'voice-attempts', + 'voice-next-attempt', + 'voice-sent-at', + 'voice-error', + 'voice-interrupted', +] as const; +export type VoiceKey = (typeof VOICE_KEYS)[number]; + +// Строки — всегда в двойных кавычках: JSON-строка является валидной YAML-строкой +// и экранирует переводы строк, кавычки и двоеточия в одну строку. +function yaml(value: string | number): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +export function recordToFields(r: VoiceRecord): Record { + return { + 'voice-id': yaml(r.id), + 'voice-status': yaml(r.status), + 'voice-device': yaml(r.device), + 'voice-audio': yaml(`[[${r.audio}]]`), + 'voice-mime': yaml(r.mime), + 'voice-attempts': yaml(r.attempts), + 'voice-next-attempt': r.nextAttempt === undefined ? undefined : yaml(r.nextAttempt), + 'voice-sent-at': r.sentAt === undefined ? undefined : yaml(r.sentAt), + 'voice-error': r.error === undefined ? undefined : yaml(r.error), + 'voice-interrupted': r.interrupted ? 'true' : undefined, + }; +} + +// [первая строка внутри блока, индекс закрывающего ---] +function frontmatterRange(lines: string[]): [number, number] | null { + if (lines[0] !== '---') return null; + const end = lines.indexOf('---', 1); + return end === -1 ? null : [1, end]; +} + +function unquote(raw: string): string { + if (raw.startsWith('"')) { + try { + const value: unknown = JSON.parse(raw); + if (typeof value === 'string') return value; + } catch { + // не JSON — вернём как есть + } + } + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { + return raw.slice(1, -1).replace(/''/g, "'"); + } + return raw; +} + +const VOICE_LINE = /^(voice-[a-z-]+):[ \t]*(.*)$/; + +export function readVoiceFields(content: string): Record { + const lines = content.split('\n'); + const range = frontmatterRange(lines); + const fields: Record = {}; + if (!range) return fields; + for (let i = range[0]; i < range[1]; i++) { + const match = VOICE_LINE.exec(lines[i]); + if (match) fields[match[1]] = unquote(match[2].trim()); + } + return fields; +} + +function str(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return undefined; +} + +export function parseRecord(fields: Record): VoiceRecord | null { + const id = str(fields['voice-id']); + const status = str(fields['voice-status']); + const device = str(fields['voice-device']); + const audio = str(fields['voice-audio']); + const mime = str(fields['voice-mime']); + const attempts = Number(str(fields['voice-attempts']) ?? '0'); + if (!id || !device || !audio || !mime) return null; + if (!STATUSES.includes(status as Status)) return null; + if (!Number.isInteger(attempts) || attempts < 0) return null; + + const record: VoiceRecord = { + id, + status: status as Status, + device, + audio: audio.replace(/^\[\[/, '').replace(/\]\]$/, ''), + mime, + attempts, + }; + const nextAttempt = str(fields['voice-next-attempt']); + const sentAt = str(fields['voice-sent-at']); + const error = str(fields['voice-error']); + if (nextAttempt) record.nextAttempt = nextAttempt; + if (sentAt) record.sentAt = sentAt; + if (error) record.error = error; + if (str(fields['voice-interrupted']) === 'true') record.interrupted = true; + return record; +} + +// Меняет только перечисленные ключи; undefined — удалить строку. +// Остальные строки frontmatter и их порядок не трогаются. +export function patchFrontmatter(content: string, fields: Record): string { + const lines = content.split('\n'); + let range = frontmatterRange(lines); + if (!range) { + lines.unshift('---', '---'); + range = [1, 1]; + } + const start = range[0]; + let end = range[1]; + for (const key of Object.keys(fields)) { + const value = fields[key]; + let index = -1; + for (let i = start; i < end; i++) { + if (lines[i].startsWith(`${key}:`)) { + index = i; + break; + } + } + if (value === undefined) { + if (index !== -1) { + lines.splice(index, 1); + end--; + } + } else if (index !== -1) { + lines[index] = `${key}: ${value}`; + } else { + lines.splice(end, 0, `${key}: ${value}`); + end++; + } + } + return lines.join('\n'); +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/note.ts tests/note-frontmatter.test.ts +git commit -m "feat(core): read, parse and patch voice-* frontmatter + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: note body (initial note, status region, transcript insertion) + +**Model:** sonnet + +**Files:** +- Modify: `src/core/note.ts` (дописать в конец) +- Create: `tests/note-body.test.ts` + +**Interfaces:** +- Consumes: `recordToFields`, `patchFrontmatter`, `readVoiceFields` (Task 4); `MAX_ATTEMPTS`, `NO_SPEECH`, `VoiceRecord` (Task 3); `formatDuration`, `formatLocalTime` (Task 2). +- Produces: + - `MARKER_END = '%%/voice-note%%'`, `markerStart(id: string): string` + - `statusLine(r: VoiceRecord): string` + - `initialNote(record: VoiceRecord, durationMs: number): string` + - `renderNote(content: string, record: VoiceRecord, text?: string): string` — `text` обязателен при `record.status === 'inserted'` + +- [ ] **Step 1: Написать падающие тесты** + +`tests/note-body.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields, renderNote } from '../src/core/note'; +import { NO_SPEECH, type VoiceRecord } from '../src/core/record'; + +const saved: VoiceRecord = { + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, +}; +const FM = + '---\n' + + 'voice-id: "20260927-111503-0000"\n' + + 'voice-status: "saved"\n' + + 'voice-device: "d1"\n' + + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"\n' + + 'voice-mime: "audio/mp4"\n' + + 'voice-attempts: 0\n' + + '---\n'; +const REGION = '%%voice-note:20260927-111503-0000%%\n> [!info] Распознаётся…\n%%/voice-note%%\n'; + +const inserted: VoiceRecord = { ...saved, status: 'inserted' }; + +describe('initialNote', () => { + it('builds frontmatter, embed and status region', () => { + expect(initialNote(saved, 47_000)).toBe(`${FM}![[voice/source/20260927-111503-0000.m4a]]\n\n${REGION}`); + }); + + it('adds interrupted line above the region', () => { + const note = initialNote({ ...saved, interrupted: true }, 47_000); + expect(note).toContain('voice-interrupted: true\n---\n'); + expect(note).toContain( + '![[voice/source/20260927-111503-0000.m4a]]\n\n> [!note] Запись прервана, сохранено 0:47\n\n%%voice-note:', + ); + }); +}); + +describe('renderNote: status region', () => { + it('retry shows local time and attempt', () => { + const r: VoiceRecord = { ...saved, status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:17:03.000Z', error: 't' }; + const out = renderNote(initialNote(saved, 0), r); + expect(out).toContain('> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)\n'); + expect(out).not.toContain('Распознаётся'); + expect(readVoiceFields(out)['voice-status']).toBe('retry'); + }); + + it('failed shows reason and the command', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: '401 Unauthorized' }); + expect(out).toContain('> [!warning] Не распознано: 401 Unauthorized. Команда «Повторить распознавание»\n'); + }); + + it('no-speech is not an error', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: NO_SPEECH }); + expect(out).toContain('> [!info] В записи не распознано речи\n'); + expect(out).not.toMatch(/ошибк|Не распознано:|Повторить/); + }); + + it('collapses multi-line error into one callout line', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: 'a\n\nb' }); + expect(out).toContain('> [!warning] Не распознано: a b. Команда'); + }); + + it('without markers updates frontmatter only', () => { + const content = `${FM}моя заметка\n`; + const out = renderNote(content, { ...saved, status: 'sent', sentAt: 'x' }); + expect(out).toBe(content.replace('voice-status: "saved"', 'voice-status: "sent"').replace('voice-attempts: 0\n', 'voice-attempts: 0\nvoice-sent-at: "x"\n')); + }); +}); + +describe('renderNote: transcript', () => { + it('replaces the region and keeps user text around it', () => { + const content = `${initialNote(saved, 0)}купить молоко\n`; + expect(renderNote(content, inserted, ' Привет, мир.\n')).toBe( + `${FM.replace('"saved"', '"inserted"')}![[voice/source/20260927-111503-0000.m4a]]\n\nПривет, мир.\nкупить молоко\n`, + ); + }); + + it('keeps the interrupted line', () => { + const r = { ...saved, interrupted: true }; + const out = renderNote(initialNote(r, 47_000), { ...r, status: 'inserted' }, 'текст'); + expect(out).toContain('> [!note] Запись прервана, сохранено 0:47\n\nтекст\n'); + }); + + it('appends at the end when markers were removed', () => { + const content = `${FM}заметка без маркеров\n\n\n`; + expect(renderNote(content, inserted, 'текст')).toBe( + `${FM.replace('"saved"', '"inserted"')}заметка без маркеров\n\nтекст\n`, + ); + }); + + it('is a no-op when already inserted', () => { + const once = renderNote(initialNote(saved, 0), inserted, 'первый'); + expect(renderNote(once, inserted, 'второй')).toBe(once); + }); + + it('inserts special replacement patterns verbatim', () => { + const text = "цена $& и $' и $1 и %%voice-note%% и ---"; + expect(renderNote(initialNote(saved, 0), inserted, text)).toContain(`\n\n${text}\n`); + }); + + it('keeps user properties in place', () => { + const content = initialNote(saved, 0).replace('---\nvoice-id', '---\ntags: [idea]\nvoice-id'); + const out = renderNote(content, inserted, 'x'); + expect(out.startsWith('---\ntags: [idea]\nvoice-id: "20260927-111503-0000"\nvoice-status: "inserted"\n')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/note-body.test.ts` +Expected: FAIL — `initialNote is not a function` / отсутствующий экспорт. + +- [ ] **Step 3: Реализация — дописать в `src/core/note.ts`** + +Заменить первую строку импорта в `src/core/note.ts` на: + +```ts +import { formatDuration, formatLocalTime } from './naming'; +import { MAX_ATTEMPTS, NO_SPEECH, STATUSES, type Status, type VoiceRecord } from './record'; +``` + +Дописать в конец файла: + +```ts +export const MARKER_END = '%%/voice-note%%'; +export const markerStart = (id: string) => `%%voice-note:${id}%%`; + +export function statusLine(r: VoiceRecord): string { + switch (r.status) { + case 'retry': { + const at = r.nextAttempt ? formatLocalTime(r.nextAttempt) : '—'; + return `> [!info] Не удалось отправить, повтор в ${at} (попытка ${r.attempts}/${MAX_ATTEMPTS})`; + } + case 'failed': { + if (r.error === NO_SPEECH) return '> [!info] В записи не распознано речи'; + const reason = (r.error ?? 'неизвестная причина').replace(/\s+/g, ' ').trim(); + return `> [!warning] Не распознано: ${reason}. Команда «Повторить распознавание»`; + } + default: + return '> [!info] Распознаётся…'; + } +} + +function region(r: VoiceRecord): string { + return `${markerStart(r.id)}\n${statusLine(r)}\n${MARKER_END}`; +} + +export function initialNote(record: VoiceRecord, durationMs: number): string { + const interrupted = record.interrupted + ? `> [!note] Запись прервана, сохранено ${formatDuration(durationMs)}\n\n` + : ''; + return ( + patchFrontmatter('', recordToFields(record)) + + `![[${record.audio}]]\n\n${interrupted}${region(record)}\n` + ); +} + +// Обновляет frontmatter и управляемую область одной операцией. +// Склейка через slice, не String.replace: в транскрипте могут быть $&, $' и т.п. +export function renderNote(content: string, record: VoiceRecord, text?: string): string { + if (record.status === 'inserted' && readVoiceFields(content)['voice-status'] === 'inserted') { + return content; + } + const patched = patchFrontmatter(content, recordToFields(record)); + const start = patched.indexOf(markerStart(record.id)); + const end = start === -1 ? -1 : patched.indexOf(MARKER_END, start); + + if (record.status === 'inserted') { + const body = (text ?? '').trim(); + if (end === -1) return `${patched.replace(/\s+$/, '')}\n\n${body}\n`; + return patched.slice(0, start) + body + patched.slice(end + MARKER_END.length); + } + if (end === -1) return patched; + return patched.slice(0, start) + region(record) + patched.slice(end + MARKER_END.length); +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/note.ts tests/note-body.test.ts +git commit -m "feat(core): note body with status region and transcript insertion + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: schedule + +**Model:** haiku + +**Files:** +- Create: `src/core/schedule.ts`, `tests/schedule.test.ts` + +**Interfaces:** +- Consumes: `SENT_STALE_MS`, `VoiceRecord` (Task 3). +- Produces: + - `interface Located { path: string; record: VoiceRecord }` + - `isStaleSent(r: VoiceRecord, now: Date): boolean` + - `due(items: readonly Located[], now: Date, deviceId: string): Located[]` — отсортировано по `record.id` + - `staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[]` + - `claimable(items: readonly Located[], now: Date, deviceId: string): Located[]` + +- [ ] **Step 1: Написать падающие тесты** + +`tests/schedule.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import type { VoiceRecord } from '../src/core/record'; +import { claimable, due, isStaleSent, staleOwn, type Located } from '../src/core/schedule'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString(); +const later = (ms: number) => new Date(NOW.getTime() + ms).toISOString(); + +function item(id: string, over: Partial = {}): Located { + return { + path: `voice/inbox/${id}.md`, + record: { id, status: 'saved', device: 'me', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }, + }; +} +const ids = (xs: Located[]) => xs.map((x) => x.record.id); + +describe('due', () => { + it('takes own saved and retry whose time has come, oldest id first', () => { + const items = [ + item('c', { status: 'retry', nextAttempt: NOW.toISOString() }), + item('a'), + item('b', { status: 'retry', nextAttempt: later(1) }), + item('d', { device: 'other' }), + item('e', { status: 'sent', sentAt: ago(0) }), + item('f', { status: 'failed' }), + item('g', { status: 'inserted' }), + ]; + expect(ids(due(items, NOW, 'me'))).toEqual(['a', 'c']); + }); + + it('unparsable nextAttempt is due', () => { + expect(ids(due([item('a', { status: 'retry', nextAttempt: 'завтра' })], NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('isStaleSent', () => { + it.each([ + ['exactly 10 min', { status: 'sent' as const, sentAt: ago(600_000) }, false], + ['10 min + 1 ms', { status: 'sent' as const, sentAt: ago(600_001) }, true], + ['2 min', { status: 'sent' as const, sentAt: ago(120_000) }, false], + ['no sentAt', { status: 'sent' as const }, true], + ['garbage sentAt', { status: 'sent' as const, sentAt: 'x' }, true], + ['not sent', { status: 'retry' as const, sentAt: ago(3_600_000) }, false], + ])('%s → %s', (_name, over, expected) => { + expect(isStaleSent(item('a', over).record, NOW)).toBe(expected); + }); +}); + +describe('staleOwn', () => { + it('only own stale sent', () => { + const items = [ + item('a', { status: 'sent', sentAt: ago(700_000) }), + item('b', { status: 'sent', sentAt: ago(700_000), device: 'other' }), + item('c', { status: 'sent', sentAt: ago(1_000) }), + ]; + expect(ids(staleOwn(items, NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('claimable', () => { + it("others' saved, retry and stale sent only", () => { + const o = { device: 'other' }; + const items = [ + item('a', o), + item('b', { ...o, status: 'retry', nextAttempt: later(1_200_000) }), + item('c', { ...o, status: 'sent', sentAt: ago(700_000) }), + item('d', { ...o, status: 'sent', sentAt: ago(1_000) }), + item('e', { ...o, status: 'failed' }), + item('f', { ...o, status: 'inserted' }), + item('g'), + ]; + expect(ids(claimable(items, NOW, 'me'))).toEqual(['a', 'b', 'c']); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/schedule.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/schedule"`. + +- [ ] **Step 3: Реализация** + +`src/core/schedule.ts`: + +```ts +import { SENT_STALE_MS, type VoiceRecord } from './record'; + +export interface Located { + path: string; + record: VoiceRecord; +} + +const time = (iso?: string) => (iso ? Date.parse(iso) : NaN); + +// Отсутствующее или испорченное время — не повод зависнуть навсегда. +export function isStaleSent(r: VoiceRecord, now: Date): boolean { + if (r.status !== 'sent') return false; + const t = time(r.sentAt); + return Number.isNaN(t) || now.getTime() - t > SENT_STALE_MS; +} + +export function due(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items + .filter(({ record: r }) => { + if (r.device !== deviceId) return false; + if (r.status === 'saved') return true; + if (r.status !== 'retry') return false; + const t = time(r.nextAttempt); + return Number.isNaN(t) || t <= now.getTime(); + }) + .sort((a, b) => (a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0)); +} + +export function staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter(({ record: r }) => r.device === deviceId && isStaleSent(r, now)); +} + +export function claimable(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter( + ({ record: r }) => + r.device !== deviceId && (r.status === 'saved' || r.status === 'retry' || isStaleSent(r, now)), + ); +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/schedule.ts tests/schedule.test.ts +git commit -m "feat(core): due, stale-sent and claimable selection + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: fakes and saveCapture + +**Model:** sonnet + +**Files:** +- Create: `tests/fakes.ts`, `src/core/capture.ts`, `tests/capture.test.ts` + +**Interfaces:** +- Consumes: порты (Task 1); `makeId`, `audioPath`, `notePath` (Task 2); `VoiceRecord` (Task 3); `initialNote`, `readVoiceFields` (Tasks 4–5). +- Produces: + - `tests/fakes.ts`: `MemoryRecordings` (`files: Map`), `MemoryNotes` (`files: Map`, `staleList: ListedNote[] | null`, `listCalls: number`), `type Step`, `ScriptedTranscriber` (`steps: Step[]`, `calls: {audio, mime, signal}[]`), `TestEnv` (`time`, `deviceId`, `random`, `now()`, `advance(ms)`) + - `src/core/capture.ts`: `interface Capture { chunks: ArrayBuffer[]; mime: string; startedAt: Date; endedAt: Date; interrupted: boolean }`, `interface CaptureDeps { recordings: RecordingStore; notes: NoteStore; env: Env }`, `saveCapture(deps: CaptureDeps, capture: Capture): Promise` — путь заметки или `null` для пустой записи + +- [ ] **Step 1: Фейки портов** + +`tests/fakes.ts`: + +```ts +import { readVoiceFields } from '../src/core/note'; +import type { + Env, + ListedNote, + NoteStore, + RecordingStore, + TranscribeResult, + Transcriber, +} from '../src/core/ports'; + +export class MemoryRecordings implements RecordingStore { + files = new Map(); + + async write(path: string, data: ArrayBuffer): Promise { + this.files.set(path, data.slice(0)); + } + + async read(path: string): Promise { + const data = this.files.get(path); + if (!data) throw new Error(`no such file: ${path}`); + return data.slice(0); + } +} + +export class MemoryNotes implements NoteStore { + files = new Map(); + // Имитация отстающего metadataCache: если задано, list() возвращает это. + staleList: ListedNote[] | null = null; + listCalls = 0; + + async exists(path: string): Promise { + return this.files.has(path); + } + + async create(path: string, content: string): Promise { + if (this.files.has(path)) throw new Error(`already exists: ${path}`); + this.files.set(path, content); + } + + async list(): Promise { + this.listCalls++; + if (this.staleList) return this.staleList; + return [...this.files] + .map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record })) + .filter((note) => 'voice-id' in note.frontmatter); + } + + async process(path: string, fn: (content: string) => string): Promise { + const content = this.files.get(path); + if (content === undefined) return false; + this.files.set(path, fn(content)); + return true; + } +} + +export type Step = + | TranscribeResult + | Error + | ((audio: ArrayBuffer, mime: string) => Promise); + +export class ScriptedTranscriber implements Transcriber { + calls: { audio: ArrayBuffer; mime: string; signal: AbortSignal }[] = []; + + constructor(public steps: Step[] = []) {} + + async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise { + this.calls.push({ audio, mime, signal }); + const step = this.steps.shift(); + if (step === undefined) throw new Error('ScriptedTranscriber: no more steps'); + if (step instanceof Error) throw step; + if (typeof step === 'function') return step(audio, mime); + return step; + } +} + +export class TestEnv implements Env { + constructor( + public time = new Date('2026-09-27T11:15:03.000Z'), + public deviceId = 'test-device', + public random: () => number = () => 0, + ) {} + + now(): Date { + return new Date(this.time); + } + + advance(ms: number): void { + this.time = new Date(this.time.getTime() + ms); + } +} +``` + +- [ ] **Step 2: Написать падающие тесты saveCapture** + +`tests/capture.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { saveCapture, type Capture } from '../src/core/capture'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { MemoryNotes, MemoryRecordings, TestEnv } from './fakes'; + +const T = new Date('2026-09-27T11:15:03.000Z'); +const buf = (...bytes: number[]) => new Uint8Array(bytes).buffer; + +function setup() { + const env = new TestEnv(T); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + return { env, notes, recordings, deps: { env, notes, recordings } }; +} + +const capture = (over: Partial = {}): Capture => ({ + chunks: [buf(1, 2), buf(3, 4, 5)], + mime: 'audio/mp4', + startedAt: T, + endedAt: new Date(T.getTime() + 47_000), + interrupted: false, + ...over, +}); + +describe('saveCapture', () => { + it('writes concatenated audio and a saved note', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture()); + + expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); + const audio = recordings.files.get('voice/source/20260927-111503-0000.m4a'); + expect(new Uint8Array(audio!)).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + expect(notes.files.get(path!)).toBe( + initialNote( + { + id: '20260927-111503-0000', + status: 'saved', + device: 'test-device', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, + }, + 47_000, + ), + ); + }); + + it.each([[[]], [[new ArrayBuffer(0)]], [[new ArrayBuffer(0), new ArrayBuffer(0)]]])( + 'creates nothing for empty data %#', + async (chunks) => { + const { deps, notes, recordings } = setup(); + expect(await saveCapture(deps, capture({ chunks, interrupted: true }))).toBeNull(); + expect(notes.files.size).toBe(0); + expect(recordings.files.size).toBe(0); + }, + ); + + it('marks interrupted recordings with duration', async () => { + const { deps, notes } = setup(); + const path = await saveCapture(deps, capture({ interrupted: true })); + const content = notes.files.get(path!)!; + expect(readVoiceFields(content)['voice-interrupted']).toBe('true'); + expect(content).toContain('> [!note] Запись прервана, сохранено 0:47'); + }); + + it('adds (2), (3) on name collision', async () => { + const { deps, notes } = setup(); + notes.files.set('voice/inbox/2026-09-27 14-15-03.md', 'чужая'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); + expect(notes.files.get('voice/inbox/2026-09-27 14-15-03.md')).toBe('чужая'); + }); + + it('keeps full mime and picks extension from it', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture({ mime: 'audio/webm;codecs=opus' })); + expect(recordings.files.has('voice/source/20260927-111503-0000.webm')).toBe(true); + expect(readVoiceFields(notes.files.get(path!)!)['voice-mime']).toBe('audio/webm;codecs=opus'); + }); +}); +``` + +- [ ] **Step 3: Убедиться, что тесты падают** + +Run: `npx vitest run tests/capture.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/capture"`. + +- [ ] **Step 4: Реализация** + +`src/core/capture.ts`: + +```ts +import { audioPath, makeId, notePath } from './naming'; +import { initialNote } from './note'; +import type { Env, NoteStore, RecordingStore } from './ports'; +import type { VoiceRecord } from './record'; + +export interface Capture { + chunks: ArrayBuffer[]; + mime: string; + startedAt: Date; + endedAt: Date; + interrupted: boolean; +} + +export interface CaptureDeps { + recordings: RecordingStore; + notes: NoteStore; + env: Env; +} + +// Сохраняет запись до любой сети. Возвращает путь заметки или null, если данных нет. +export async function saveCapture(deps: CaptureDeps, capture: Capture): Promise { + const size = capture.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + if (size === 0) return null; + + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of capture.chunks) { + bytes.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; + } + + const id = makeId(capture.startedAt, () => deps.env.random()); + const record: VoiceRecord = { + id, + status: 'saved', + device: deps.env.deviceId, + audio: audioPath(id, capture.mime), + mime: capture.mime, + attempts: 0, + }; + if (capture.interrupted) record.interrupted = true; + + await deps.recordings.write(record.audio, bytes.buffer); + + let n = 1; + while (await deps.notes.exists(notePath(capture.startedAt, n))) n++; + const path = notePath(capture.startedAt, n); + await deps.notes.create(path, initialNote(record, capture.endedAt.getTime() - capture.startedAt.getTime())); + return path; +} +``` + +- [ ] **Step 5: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS (включая страж границы: `capture.ts` не импортирует запрещённое). + +- [ ] **Step 6: Commit** + +```bash +git add tests/fakes.ts src/core/capture.ts tests/capture.test.ts +git commit -m "feat(core): save captured audio and initial note; in-memory port fakes + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: QueueRunner.tick + +**Model:** opus + +**Files:** +- Create: `src/core/queue.ts`, `tests/queue.test.ts` + +**Interfaces:** +- Consumes: `NoteStore`, `RecordingStore`, `Transcriber`, `TranscribeResult`, `Env` (Task 1); `transition`, `NO_SPEECH`, `VoiceRecord` (Task 3); `readVoiceFields`, `parseRecord`, `renderNote`, `initialNote` (Tasks 4–5); `due`, `staleOwn`, `isStaleSent`, `Located` (Task 6); фейки (Task 7). +- Produces: + - `interface QueueDeps { recordings: RecordingStore; notes: NoteStore; transcriber: Transcriber; env: Env }` + - `INTERRUPTED_SEND = 'отправка прервана'` + - `class QueueRunner { constructor(deps: QueueDeps); tick(): Promise; dispose(): void }` и приватный `update(path, expect, change): Promise`, `load(): Promise` — используются в Task 9 + +- [ ] **Step 1: Написать падающие тесты** + +`tests/queue.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { QueueRunner } from '../src/core/queue'; +import type { VoiceRecord } from '../src/core/record'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; + +const T0 = new Date('2026-09-27T11:15:03.000Z'); +const ok = (text: string): Step => ({ ok: true, text }); +const temporary = (message: string): Step => ({ ok: false, kind: 'temporary', message }); +const permanent = (message: string): Step => ({ ok: false, kind: 'permanent', message }); + +function setup(steps: Step[] = []) { + const env = new TestEnv(T0); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber(steps); + const runner = new QueueRunner({ recordings, notes, transcriber, env }); + return { env, notes, recordings, transcriber, runner }; +} +type Ctx = ReturnType; + +function rec(id: string, over: Partial = {}): VoiceRecord { + return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; +} + +function seed(ctx: Ctx, record: VoiceRecord, path = `voice/inbox/${record.id}.md`): string { + ctx.notes.files.set(path, initialNote(record, 0)); + ctx.recordings.files.set(record.audio, new Uint8Array([1, 2, 3]).buffer); + return path; +} + +const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); + +describe('QueueRunner.tick', () => { + it('sends a saved record and inserts the trimmed transcript', async () => { + const ctx = setup([ok(' привет ')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + + expect(ctx.transcriber.calls).toHaveLength(1); + expect(new Uint8Array(ctx.transcriber.calls[0].audio)).toEqual(new Uint8Array([1, 2, 3])); + expect(ctx.transcriber.calls[0].mime).toBe('audio/mp4'); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + expect(fields(ctx, path)['voice-sent-at']).toBeUndefined(); + expect(ctx.notes.files.get(path)).toContain('\n\nпривет\n'); + expect(ctx.notes.files.get(path)).not.toContain('%%voice-note'); + }); + + it('network lost, then back: retry at +30s, then inserted', async () => { + const ctx = setup([temporary('offline'), ok('текст')]); + const path = seed(ctx, rec('a')); + + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ + 'voice-status': 'retry', + 'voice-attempts': '1', + 'voice-next-attempt': '2026-09-27T11:15:33.000Z', + 'voice-error': 'offline', + }); + expect(ctx.notes.files.get(path)).toContain('Не удалось отправить, повтор в 14:15 (попытка 1/5)'); + + ctx.env.advance(17_000); // 11:15:20 — рано + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + + ctx.env.advance(13_000); // 11:15:33 — пора + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(2); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); + + it('five temporary failures → failed with the last message', async () => { + const ctx = setup([temporary('e1'), temporary('e2'), temporary('e3'), temporary('e4'), temporary('e5')]); + const path = seed(ctx, rec('a')); + for (let i = 0; i < 5; i++) { + await ctx.runner.tick(); + ctx.env.advance(3_600_000); + } + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(5); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '5', 'voice-error': 'e5' }); + }); + + it('permanent error → failed at once', async () => { + const ctx = setup([permanent('401 Unauthorized')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '1' }); + expect(ctx.notes.files.get(path)).toContain('Не распознано: 401 Unauthorized'); + }); + + it('whitespace-only transcript → failed no-speech', async () => { + const ctx = setup([ok(' \n\t ')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-error': 'no-speech' }); + expect(ctx.notes.files.get(path)).toContain('> [!info] В записи не распознано речи'); + }); + + it('exception from transcriber is a temporary failure', async () => { + const ctx = setup([new Error('socket hang up')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-error': 'socket hang up' }); + }); + + it('missing audio file is a temporary failure', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.recordings.files.clear(); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, path)['voice-status']).toBe('retry'); + }); + + it("does not send other devices' records", async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { device: 'iphone' })); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, path)['voice-status']).toBe('saved'); + }); + + it('own sent older than 10 min → retry without a request; younger untouched', async () => { + const ctx = setup([]); + const stale = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const fresh = seed(ctx, rec('b', { status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const before = ctx.notes.files.get(fresh); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, stale)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1', 'voice-error': 'отправка прервана' }); + expect(ctx.notes.files.get(fresh)).toBe(before); + }); + + it('processes records one at a time, oldest first', async () => { + let inFlight = 0; + let maxInFlight = 0; + const order: string[] = []; + const slow: Step = async (audio) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + order.push(String(new Uint8Array(audio)[0])); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight--; + return { ok: true, text: 't' }; + }; + const ctx = setup([slow, slow, slow]); + for (const [id, byte] of [['c', 3], ['a', 1], ['b', 2]] as const) { + seed(ctx, rec(id)); + ctx.recordings.files.set(`voice/source/${id}.m4a`, new Uint8Array([byte]).buffer); + } + await ctx.runner.tick(); + expect(maxInFlight).toBe(1); + expect(order).toEqual(['1', '2', '3']); + }); + + it('tick during tick runs exactly one extra pass', async () => { + const ctx: Ctx = setup(); + ctx.transcriber.steps.push(async () => { + void ctx.runner.tick(); + void ctx.runner.tick(); + void ctx.runner.tick(); + return { ok: true, text: 't' }; + }); + seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(ctx.notes.listCalls).toBe(2); + expect(ctx.transcriber.calls).toHaveLength(1); + }); + + it('note deleted during the request: result dropped, next record processed', async () => { + const ctx: Ctx = setup(); + const first = seed(ctx, rec('a')); + const second = seed(ctx, rec('b')); + ctx.transcriber.steps.push(async () => { + ctx.notes.files.delete(first); + return { ok: true, text: 'один' }; + }, ok('два')); + await ctx.runner.tick(); + expect(ctx.notes.files.has(first)).toBe(false); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('note deleted before send: skipped without a request', async () => { + const ctx = setup([ok('два')]); + const second = seed(ctx, rec('b')); + ctx.notes.staleList = [ + { path: 'voice/inbox/gone.md', frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }, + { path: second, frontmatter: fields(ctx, second) }, + ]; + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('stale list says saved, file says sent: no second request', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:15:00.000Z' })); + ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; + const before = ctx.notes.files.get(path); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(ctx.notes.files.get(path)).toBe(before); + }); + + it('stale list says saved, file says inserted: untouched', async () => { + const ctx = setup([ok('x')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + const done = ctx.notes.files.get(path); + ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(ctx.notes.files.get(path)).toBe(done); + }); + + it('finds records outside voice/inbox', async () => { + const ctx = setup([ok('x')]); + const path = seed(ctx, rec('a'), 'projects/перенесённая.md'); + await ctx.runner.tick(); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); + + it('ignores notes with a broken status', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.notes.files.set(path, ctx.notes.files.get(path)!.replace('"saved"', '"foo"')); + await expect(ctx.runner.tick()).resolves.toBeUndefined(); + expect(ctx.transcriber.calls).toHaveLength(0); + }); + + it('dispose aborts the signal given to the transcriber', async () => { + const ctx = setup([ok('x')]); + seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(ctx.transcriber.calls[0].signal.aborted).toBe(false); + ctx.runner.dispose(); + expect(ctx.transcriber.calls[0].signal.aborted).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/queue.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/queue"`. + +- [ ] **Step 3: Реализация** + +`src/core/queue.ts`: + +```ts +import { parseRecord, readVoiceFields, renderNote } from './note'; +import type { Env, NoteStore, RecordingStore, TranscribeResult, Transcriber } from './ports'; +import { NO_SPEECH, transition, type VoiceRecord } from './record'; +import { due, isStaleSent, staleOwn, type Located } from './schedule'; + +export interface QueueDeps { + recordings: RecordingStore; + notes: NoteStore; + transcriber: Transcriber; + env: Env; +} + +export const INTERRUPTED_SEND = 'отправка прервана'; + +interface Change { + record: VoiceRecord; + text?: string; +} + +export class QueueRunner { + private running = false; + private rerun = false; + private readonly controller = new AbortController(); + + constructor(private readonly deps: QueueDeps) {} + + // Повторный вызов во время работы не запускает параллельный проход, + // а просит ровно один дополнительный после текущего. + async tick(): Promise { + if (this.running) { + this.rerun = true; + return; + } + this.running = true; + try { + do { + this.rerun = false; + await this.pass(); + } while (this.rerun); + } finally { + this.running = false; + } + } + + dispose(): void { + this.controller.abort(); + } + + private async pass(): Promise { + const { env } = this.deps; + const items = await this.load(); + const now = env.now(); + for (const item of staleOwn(items, now, env.deviceId)) { + await this.update( + item.path, + (r) => r.id === item.record.id && r.device === env.deviceId && isStaleSent(r, now), + (r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }), + ); + } + for (const item of due(items, now, env.deviceId)) await this.send(item); + } + + private async send(item: Located): Promise { + const { recordings, transcriber, env } = this.deps; + const now = env.now(); + // Список мог устареть (metadataCache) — решение принимаем по актуальному файлу. + const sent = await this.update( + item.path, + (r) => r.id === item.record.id && due([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'send' }, now) }), + ); + if (!sent) return; + + let result: TranscribeResult; + try { + const audio = await recordings.read(sent.audio); + result = await transcriber.transcribe(audio, sent.mime, this.controller.signal); + } catch (e) { + result = { ok: false, kind: 'temporary', message: e instanceof Error ? e.message : String(e) }; + } + if (result.ok && result.text.trim() === '') { + result = { ok: false, kind: 'permanent', message: NO_SPEECH }; + } + + const done = env.now(); + const final = result; + await this.update( + item.path, + (r) => r.id === sent.id && r.status === 'sent', + (r) => + final.ok + ? { record: transition(r, { type: 'success' }, done), text: final.text } + : { record: transition(r, { type: final.kind, message: final.message }, done) }, + ); + } + + // Атомарно: перечитать запись из актуального содержимого, проверить ожидание, + // применить изменение. null — заметки нет или запись не в ожидаемом состоянии. + private async update( + path: string, + expect: (r: VoiceRecord) => boolean, + change: (r: VoiceRecord) => Change, + ): Promise { + const box: { record: VoiceRecord | null } = { record: null }; + const exists = await this.deps.notes.process(path, (content) => { + const current = parseRecord(readVoiceFields(content)); + if (!current || !expect(current)) return content; + const next = change(current); + box.record = next.record; + return renderNote(content, next.record, next.text); + }); + return exists ? box.record : null; + } + + private async load(): Promise { + const located: Located[] = []; + for (const { path, frontmatter } of await this.deps.notes.list()) { + const record = parseRecord(frontmatter); + if (record) located.push({ path, record }); + } + return located; + } +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Проверка типов** + +Run: `npm run typecheck` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/core/queue.ts tests/queue.test.ts +git commit -m "feat(core): queue runner with retries, stale-sent recovery and re-entrancy guard + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: manual commands — retry and claimAll + +**Model:** sonnet + +**Files:** +- Modify: `src/core/queue.ts` (методы в класс `QueueRunner`, импорт `claimable`) +- Create: `tests/queue-commands.test.ts` + +**Interfaces:** +- Consumes: `QueueRunner` с приватными `update`, `load`, `tick` (Task 8); `claimable` (Task 6); `transition` события `retry-now`, `claim` (Task 3). +- Produces: + - `QueueRunner.retry(path: string): Promise` — `true`, если запись была в `failed`/`retry` и перезапущена + - `QueueRunner.claimAll(): Promise` — число переназначенных записей + +- [ ] **Step 1: Написать падающие тесты** + +`tests/queue-commands.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { QueueRunner } from '../src/core/queue'; +import type { VoiceRecord } from '../src/core/record'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; + +const T0 = new Date('2026-09-27T11:15:03.000Z'); + +function setup(steps: Step[] = []) { + const env = new TestEnv(T0); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber(steps); + const runner = new QueueRunner({ recordings, notes, transcriber, env }); + return { env, notes, recordings, transcriber, runner }; +} +type Ctx = ReturnType; + +function rec(id: string, over: Partial = {}): VoiceRecord { + return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; +} + +function seed(ctx: Ctx, record: VoiceRecord): string { + const path = `voice/inbox/${record.id}.md`; + ctx.notes.files.set(path, initialNote(record, 0)); + ctx.recordings.files.set(record.audio, new Uint8Array([1]).buffer); + return path; +} + +const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); + +describe('QueueRunner.retry', () => { + it('failed → sent again with attempts from zero', async () => { + const ctx = setup([{ ok: false, kind: 'temporary', message: 'offline' }]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: '401' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' }); + }); + + it('retry state: sends now without waiting', async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { status: 'retry', attempts: 2, nextAttempt: '2026-09-27T12:00:00.000Z', error: 't' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-attempts': '0' }); + }); + + it("claims another device's failed record", async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e', device: 'iphone' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device' }); + }); + + it.each(['saved', 'inserted'] as const)('%s: no change, no request', async (status) => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status })); + const before = ctx.notes.files.get(path); + expect(await ctx.runner.retry(path)).toBe(false); + expect(ctx.notes.files.get(path)).toBe(before); + expect(ctx.transcriber.calls).toHaveLength(0); + }); + + it('missing note → false', async () => { + expect(await setup().runner.retry('voice/inbox/nope.md')).toBe(false); + }); +}); + +describe('QueueRunner.claimAll', () => { + it("takes the phone's retry and sends it now, keeping attempts", async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { device: 'iphone', status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); + expect(await ctx.runner.claimAll()).toBe(1); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device', 'voice-attempts': '2' }); + }); + + it('includes stale sent; leaves failed, inserted, fresh sent and own records', async () => { + const ctx = setup([{ ok: true, text: 'x' }, { ok: true, text: 'y' }]); + const o = { device: 'iphone' }; + const saved = seed(ctx, rec('a', o)); + const stale = seed(ctx, rec('b', { ...o, status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const fresh = seed(ctx, rec('c', { ...o, status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const failed = seed(ctx, rec('d', { ...o, status: 'failed', attempts: 5, error: 'e' })); + const done = seed(ctx, rec('e', { ...o, status: 'inserted' })); + const own = seed(ctx, rec('f', { status: 'retry', attempts: 1, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); + const untouched = [fresh, failed, done, own].map((p) => ctx.notes.files.get(p)); + + expect(await ctx.runner.claimAll()).toBe(2); + expect(fields(ctx, saved)['voice-status']).toBe('inserted'); + expect(fields(ctx, stale)['voice-status']).toBe('inserted'); + expect([fresh, failed, done, own].map((p) => ctx.notes.files.get(p))).toEqual(untouched); + }); + + it('nothing to claim → 0', async () => { + expect(await setup().runner.claimAll()).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/queue-commands.test.ts` +Expected: FAIL — `ctx.runner.retry is not a function`. + +- [ ] **Step 3: Реализация** + +В `src/core/queue.ts` заменить импорт из `./schedule` на: + +```ts +import { claimable, due, isStaleSent, staleOwn, type Located } from './schedule'; +``` + +Добавить в класс `QueueRunner` после `dispose()`: + +```ts + // «Повторить распознавание»: failed/retry → saved, счётчик с нуля, запись + // переходит текущему устройству (иначе чужую запись здесь не отправить). + async retry(path: string): Promise { + const { env } = this.deps; + const changed = await this.update( + path, + (r) => r.status === 'failed' || r.status === 'retry', + (r) => ({ record: transition(r, { type: 'retry-now', device: env.deviceId }, env.now()) }), + ); + if (!changed) return false; + await this.tick(); + return true; + } + + // «Распознать все ожидающие здесь»: чужие saved/retry/зависшие sent → своё + // устройство, статус saved (без ожидания повтора), счётчик сохраняется. + async claimAll(): Promise { + const { env } = this.deps; + const now = env.now(); + let claimed = 0; + for (const item of claimable(await this.load(), now, env.deviceId)) { + const changed = await this.update( + item.path, + (r) => r.id === item.record.id && claimable([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'claim', device: env.deviceId }, now) }), + ); + if (changed) claimed++; + } + await this.tick(); + return claimed; + } +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/queue.ts tests/queue-commands.test.ts +git commit -m "feat(core): manual retry and claim-all commands + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 10: golden test-01 + +**Model:** sonnet + +**Files:** +- Create: `tests/golden.test.ts`, `tests/fixtures/test-01.note.md` + +**Interfaces:** +- Consumes: `saveCapture` (Task 7), `QueueRunner` (Task 8), фейки (Task 7); фикстуры `tests/fixtures/test-01.m4a` (в git), `tests/fixtures/test-01.txt`. +- Produces: правило «новый golden-кейс = `.note.md` + `.txt` + `.{m4a|webm|ogg}`». + +- [ ] **Step 1: Написать падающий тест** + +`tests/golden.test.ts`: + +```ts +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { saveCapture } from '../src/core/capture'; +import { QueueRunner } from '../src/core/queue'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv } from './fakes'; + +const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url)); +const MIME: Record = { m4a: 'audio/mp4', webm: 'audio/webm', ogg: 'audio/ogg' }; +const cases = readdirSync(FIXTURES) + .filter((f) => f.endsWith('.note.md')) + .map((f) => f.slice(0, -'.note.md'.length)); + +function audioFor(name: string): { file: string; mime: string } { + for (const ext of Object.keys(MIME)) { + const file = `${FIXTURES}${name}.${ext}`; + if (existsSync(file)) return { file, mime: MIME[ext] }; + } + throw new Error(`нет аудиофикстуры tests/fixtures/${name}.m4a (или .webm/.ogg) для эталона ${name}.note.md`); +} + +function toArrayBuffer(buf: Buffer): ArrayBuffer { + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; +} + +describe('golden', () => { + it('has at least one case', () => { + expect(cases.length).toBeGreaterThan(0); + }); + + for (const name of cases) { + it(name, async () => { + const { file, mime } = audioFor(name); + const audio = toArrayBuffer(readFileSync(file)); + const transcript = readFileSync(`${FIXTURES}${name}.txt`, 'utf8'); + const expected = readFileSync(`${FIXTURES}${name}.note.md`, 'utf8'); + + const env = new TestEnv(); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber([{ ok: true, text: transcript }]); + + const path = await saveCapture( + { recordings, notes, env }, + { chunks: [audio], mime, startedAt: env.now(), endedAt: new Date(env.now().getTime() + 47_000), interrupted: false }, + ); + await new QueueRunner({ recordings, notes, transcriber, env }).tick(); + + expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); + expect(transcriber.calls).toHaveLength(1); + expect(transcriber.calls[0].mime).toBe(mime); + expect(Buffer.from(transcriber.calls[0].audio).equals(Buffer.from(audio))).toBe(true); + expect(notes.files.get(path!)).toBe(expected); + }); + } +}); +``` + +- [ ] **Step 2: Убедиться, что тест падает** + +Run: `npx vitest run tests/golden.test.ts` +Expected: FAIL в `has at least one case` (`expected 0 to be greater than 0`). + +- [ ] **Step 3: Создать эталон** + +Эталон собирается из `test-01.txt`, чтобы не переписывать транскрипт руками (`$(cat …)` срезает завершающий перевод строки, `printf '%s\n'` возвращает ровно один): + +```bash +{ + printf '%s\n' \ + '---' \ + 'voice-id: "20260927-111503-0000"' \ + 'voice-status: "inserted"' \ + 'voice-device: "test-device"' \ + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"' \ + 'voice-mime: "audio/mp4"' \ + 'voice-attempts: 0' \ + '---' \ + '![[voice/source/20260927-111503-0000.m4a]]' \ + '' + printf '%s\n' "$(cat tests/fixtures/test-01.txt)" +} > tests/fixtures/test-01.note.md +cat tests/fixtures/test-01.note.md +``` + +Expected: файл из 11 строк; последняя — транскрипт «Это тестовая запись для проверки распознавания речи с помощью модели WHISPER, для сравнения между двух конкретных моделей.» + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS, весь набор. + +Если golden падает на сравнении заметки — не править эталон под код. Сравнить `notes.files.get(path)` с эталоном построчно, найти расхождение, проверить его против `specs/voice-note-format/spec.md` и исправить ту сторону, которая противоречит спеке. + +- [ ] **Step 5: Финальная проверка** + +Run: `npm run build && npm test && npm run typecheck` +Expected: все три exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add tests/golden.test.ts tests/fixtures/test-01.note.md +git commit -m "test(core): golden test-01 end-to-end capture → queue → note + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## Spec coverage + +| Требование | Задачи | +|---|---| +| recording-lifecycle / Состояния записи | 3, 8 | +| Сохранение захваченного аудио | 7 | +| Отправка и результат (по одной, пустой транскрипт) | 8 | +| Повторы после временных ошибок | 3, 8 | +| Восстановление после прерванной отправки | 6, 8 | +| Принадлежность устройству | 6, 8 | +| «Распознать все ожидающие здесь» | 6, 9 | +| «Повторить распознавание» | 3, 9 | +| Удалённая заметка отменяет запись / поиск вне inbox | 8 | +| Повторный запуск очереди | 8 | +| voice-note-format / Идентификатор и имена файлов | 2, 7 | +| Ключи frontmatter | 4 | +| Тело заметки до распознавания | 5 | +| Вставка транскрипта | 5, 8 | +| Golden-эталоны | 10 | +| Граница core без obsidian/Node | 1 | diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..4443b21 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,56 @@ +import esbuild from 'esbuild'; +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import process from 'node:process'; + +const prod = process.argv[2] === 'production'; +const VAULT = '/Users/andrey.epifancev/Library/Mobile Documents/iCloud~md~obsidian/Documents/voice-note-test'; +const PLUGIN_DIR = `${VAULT}/.obsidian/plugins/voice-note`; + +// В dev-режиме после каждой сборки копируем плагин в тестовый vault, если он есть на этой машине. +const copyToVault = { + name: 'copy-to-vault', + setup(build) { + build.onEnd((result) => { + if (result.errors.length > 0 || !existsSync(VAULT)) return; + mkdirSync(PLUGIN_DIR, { recursive: true }); + for (const file of ['main.js', 'manifest.json']) copyFileSync(file, `${PLUGIN_DIR}/${file}`); + }); + }, +}; + +const context = await esbuild.context({ + 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', + ...builtinModules, + ], + format: 'cjs', + target: 'es2018', + logLevel: 'info', + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', + minify: prod, + plugins: prod ? [] : [copyToVault], +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..252ef48 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "voice-note", + "name": "Voice Note", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Голосовые заметки: запись, распознавание внешним сервисом, текст в заметку.", + "author": "Andrey Epifancev", + "isDesktopOnly": false +} diff --git a/openspec/changes/core-recording-lifecycle/.openspec.yaml b/openspec/changes/core-recording-lifecycle/.openspec.yaml new file mode 100644 index 0000000..7f2ad57 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/core-recording-lifecycle/README.md b/openspec/changes/core-recording-lifecycle/README.md new file mode 100644 index 0000000..baffb85 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/README.md @@ -0,0 +1,3 @@ +# core-recording-lifecycle + +Core: жизненный цикл голосовой записи и очередь отправки (issue #1) diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md new file mode 100644 index 0000000..4967c14 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -0,0 +1,137 @@ +# Design: core-recording-lifecycle + +## Context + +Репозиторий пуст (каркас OpenSpec, одна фикстура `test-01`). Ограничения из CLAUDE.md: mobile-first (iPhone), `isDesktopOnly: false`, никакого Node API в рантайме, `src/core/` без импортов `obsidian`, core покрыт vitest полностью, `src/obsidian/` — тонкая обёртка без юнит-тестов. Vault синхронизируется через iCloud между маком и iPhone, плагин стоит на обоих. + +## Goals / Non-Goals + +**Goals:** +- Запись не теряется: аудио и заметка сохраняются до любой сети. +- Вся логика состояний, повторов, устройств и формата заметки — в core и под тестами. +- Обёртка сводится к реализации четырёх портов и вызову `tick()` по событиям. + +**Non-Goals:** +- Реальный провайдер, HTTP, коды ответа, переключение провайдеров (issue провайдера). +- Команды, настройки, UI, `MediaRecorder`, реализация портов на `app.vault` (issue обёртки). +- Периодический сброс записи на диск во время записи (отдельный spike на реальном iPhone: неизвестно, читается ли частичный mp4 из Safari `MediaRecorder`). +- Конвертация форматов, удаление аудио, daily notes. + +## Decisions + +### Подход: чистое ядро + оркестратор на портах + +Машина состояний, планировщик и формат заметки — чистые функции. Поверх них асинхронный `QueueRunner` в core работает через порты. Отвергнуто: (A) полностью чистый редьюсер с эффектами — оркестрация уехала бы в нетестируемую обёртку; (C) XState — лишняя зависимость и вес бандла для ~8 состояний. + +### Модули `src/core/` + +| Модуль | Ответственность | +|---|---| +| `record.ts` | тип `VoiceRecord`, `Status`, `transition(record, event, now)`, `MAX_ATTEMPTS = 5`, `BACKOFF_MS = [30s, 2m, 10m, 30m]`, `SENT_STALE_MS = 10m`, `NO_SPEECH = 'no-speech'` | +| `schedule.ts` | `due(records, now, deviceId)`, `isStaleSent(record, now)`, `claimable(records, now, deviceId)` | +| `note.ts` | `parseRecord(fields)`, `readVoiceFields(content)`, `renderNote(content, record, text?)`, построчный патч ключей `voice-*` во frontmatter, `initialNote(record)` | +| `naming.ts` | `makeId(date, random)`, `extFromMime`, `audioPath`, `notePath(date, n)` | +| `ports.ts` | интерфейсы портов (ниже) | +| `capture.ts` | `saveCapture(ports, capture)` | +| `queue.ts` | `QueueRunner` с `tick()`, `retry(notePath)`, `claimAll()` | + +### Порты + +```ts +interface RecordingStore { + write(path: string, data: ArrayBuffer): Promise; + read(path: string): Promise; +} +interface NoteStore { + exists(path: string): Promise; + create(path: string, content: string): Promise; + // Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter. + // КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать + // содержимое файлов (vault.read/cachedRead/adapter). Вызывается на каждом tick, + // в т.ч. на телефоне. Данные могут отставать от диска — core перепроверяет + // состояние внутри process(). + list(): Promise<{ path: string; frontmatter: Record }[]>; + // атомарное чтение-изменение-запись; false, если заметки нет + process(path: string, fn: (content: string) => string): Promise; +} +interface Transcriber { + transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise; +} +type TranscribeResult = + | { ok: true; text: string } + | { ok: false; kind: 'temporary' | 'permanent'; message: string }; +interface Env { now(): Date; random(): number; deviceId: string; } +``` + +`Env` объединяет часы, случайность и id устройства — в тестах всё детерминировано, что делает golden-сравнение побайтным. `NoteStore.process` в обёртке реализуется через `vault.process`. `list()` — только через `metadataCache.getFileCache(file)?.frontmatter` по наличию `voice-id`; поэтому порт отдаёт frontmatter-объект, а не текст заметки. + +### Frontmatter правит core, одной записью + +Статус и тело должны меняться атомарно, иначе обрыв между `processFrontMatter` и `process` оставит `inserted` без текста или текст без статуса. Поэтому core сам построчно патчит только строки `voice-*: …` в блоке `---`, не трогая остальные строки и их порядок, без YAML-библиотеки. Значения пишутся скалярами; строки с `:`/кавычками экранируются в двойных кавычках. + +### Выполнение отправки + +`tick()`: защита от повторного входа (флаг `running` + флаг `rerun`) → `list()` → `parseRecord(frontmatter)` → просроченные `sent` своего устройства превращаются во временную ошибку → `due()` → по одной: `process` (внутри функция заново читает `voice-*` из актуального `content` через `readVoiceFields`; если запись уже не в ожидаемом состоянии или не своего устройства — возвращает content без изменений и запись пропускается; иначе статус `sent`, `voice-sent-at`) → `read` аудио → `transcribe` → `process` (`inserted` + текст или `retry`/`failed` + область). Если `process` вернул `false` — запись отменена, идём дальше. Исключение из `transcribe` или `read` трактуется как временная ошибка с его сообщением. + +### Ручные команды + +`claimAll()`: для каждой записи из `claimable()` одной записью `voice-device := текущее`, `voice-status := saved`, удалить `voice-next-attempt` и `voice-sent-at`; `voice-attempts` сохраняется. Собственные записи устройства в `retry` и зависшие `sent` тоже переводятся в `saved` (без `voice-next-attempt`/`voice-sent-at`, счётчик сохраняется), чтобы отправиться сразу (решение 33). Затем `tick()`. `retry(notePath)`: только из `failed`/`retry` → `saved`, `voice-attempts := 0`, удалить `voice-next-attempt`/`voice-error`; затем `tick()`. + +### Захват + +`saveCapture({ chunks: ArrayBuffer[], mime, startedAt, endedAt, interrupted })`: суммарно 0 байт → `null`; иначе склейка → `write` аудио → подбор свободного имени заметки через `exists` → `create` с `initialNote` → возвращает путь. `MediaRecorder` и детекция обрыва — в обёртке. + +### Тестовая инфраструктура (Task 1) + +Шаблон obsidian-sample-plugin (esbuild, `manifest.json` с `isDesktopOnly: false`), vitest. Тест-страж сканирует `src/core/**/*.ts` и падает на импорт `obsidian`, `fs`, `path`, `child_process`, `node:*`. Тесты запускаются с фиксированным `TZ=Europe/Moscow` (задаётся в `vitest.config.ts`): имена заметок и строка статуса `retry` зависят от локального времени, а golden-сравнение побайтное. Фейки в `tests/fakes.ts`: in-memory `RecordingStore`/`NoteStore`, скриптуемый `Transcriber`, `Env` с управляемыми часами. + +Аудиофикстуры `*.m4a` лежат в git (`test-01.m4a` ~85 КБ), так что `npm test` зелёный на чистом клоне. Если для эталона `.note.md` нет аудиофайла, golden-тест MUST падать с сообщением «нет аудиофикстуры tests/fixtures/.m4a», а не пропускаться молча. + +## Risks / Trade-offs + +- **Лаг iCloud.** Команда «забрать все» может отправить запись, которую телефон как раз отправляет. Цена — один лишний запрос; вставка идемпотентна по статусу. Принято. +- **Самописный патч frontmatter.** Не покрывает многострочные YAML-значения в ключах `voice-*`; плагин сам их не пишет, а пользовательские ключи не трогает. Покрывается тестами. +- **Потеря при жёстком убийстве процесса во время записи.** Принято до spike периодического сброса. +- **`list()` по всему vault.** Дёшево только при реализации через `metadataCache` (закреплено в контракте порта). Читаются лишь заметки, которые реально обрабатываются, через `process`. +- **Отставание `metadataCache`.** После записи кэш обновляется асинхронно, следующий tick может увидеть старый статус. Защита — перепроверка состояния внутри `process` по актуальному содержимому. + +## Decisions made without discussion + +Формат: дата — решение. *Обоснование.* **Цена ошибки.** Пометка «пересмотрено» — решение скорректировано человеком на ревью. + +1. 2026-09-27 — Пустой (после обрезки пробелов) транскрипт переводит запись в `failed` с причиной `no-speech`; в заметке `> [!info] В записи не распознано речи`, без слова «ошибка» и без призыва повторить. Аудио остаётся. *Повтор тишины бесполезен; это свойство записи, а не сбой сервиса* (формулировка пересмотрена на ревью). **Если провайдер изредка возвращает пустоту на нормальной записи — пользователь увидит «нет речи» и должен сам запустить «Повторить распознавание»; потери данных нет.** +2. 2026-09-27 — Очередь ищет записи по `voice-id` во всём vault, а не только в `voice/inbox/`. *Пользователь может перенести заметку до распознавания.* Пересмотрено: `NoteStore.list()` обязан работать через `metadataCache` без чтения файлов, это записано в контракт порта. **Если обёртка нарушит контракт — тормоза на телефоне на каждом tick; ловится ревью обёртки, в core не проверяемо.** +3. 2026-09-27 — Добавлены ключи `voice-mime` (исходный mime с параметрами) и `voice-sent-at` (ISO UTC). *Провайдеру нужен точный mime, а не восстановленный по расширению; зависший `sent` нужно отличать от идущего.* **Лишние поля во frontmatter — косметика; без них — неверный mime у провайдера или вечный `sent`.** +4. 2026-09-27 — Порог зависшего `sent` — 10 минут (пересмотрено с 5). Автоматически касается только своих записей, чужие — только через `claimAll`. *Двухминутная запись через агрегатор на плохом канале может идти дольше 5 минут.* **Слишком мал — дубль запроса (текст не портится: вставка идемпотентна); слишком велик — запись после убийства Obsidian ждёт повтора до 10 минут.** +5. 2026-09-27 — `claimAll` сохраняет `voice-attempts`, меняет только устройство, статус (в `saved`) и удаляет `voice-next-attempt`/`voice-sent-at`. «Повторить распознавание» работает для `failed` и `retry` и сбрасывает счётчик в 0. *`claimAll` — про «где», а не «заново»; ручной повтор — осознанный перезапуск.* **Если `claimAll` должен был давать 5 свежих попыток — запись с телефона уйдёт в `failed` раньше; исправляется ручным повтором.** +6. 2026-09-27 — Неизвестный mime сохраняется с расширением `.bin`, запись не отвергается. *Сохранить аудио важнее красивого расширения; принимать ли формат, решает провайдер.* **Файл `.bin` не проигрывается встроенным плеером Obsidian; аудио при этом не потеряно.** +7. 2026-09-27 — При переходе в `inserted` удаляются `voice-next-attempt`, `voice-sent-at`, `voice-error`; `voice-attempts` остаётся. *Итоговая заметка чище; счётчик полезен как история.* **Косметика.** +8. 2026-09-27 — Frontmatter правит сам core построчно, только ключи `voice-*`, без YAML-библиотеки; статус и тело меняются одной операцией `process`. *Два вызова (`processFrontMatter` + `process`) не атомарны: обрыв между ними даёт `inserted` без текста.* **Самописный патч может сломаться на экзотическом YAML в ключах `voice-*` — плагин такой не пишет; сохранность чужих ключей закрыта тестами.** +9. 2026-09-27 — Golden-сравнение побайтное при фиксированном `Env` (время, случайность, устройство). Нет аудиофикстуры для эталона — тест падает с понятным сообщением, а не пропускается. Пересмотрено на ревью: `tests/fixtures/*.m4a` убран из `.gitignore`, `test-01.m4a` закоммичен, чтобы на чистом клоне тесты были зелёными. *Молчаливый skip дал бы ложно-зелёный прогон.* **Нулевая: фикстура в репозитории; падение возможно только если добавить эталон без аудио — это и должно быть ошибкой.** +10. 2026-09-27 — В `.gitignore` добавлен `.claude/worktrees/`. *Хук guard-main запретил правку `.git/info/exclude` в main; вложенные worktree иначе видны как неотслеживаемые.* **Нулевая.** +11. 2026-09-27 — `voice-id` строится по UTC (пересмотрено на ревью, было локальное время); имя заметки в inbox — по локальному времени. Имя аудиофайла следует `voice-id`, т.е. тоже UTC. *Порядок id не зависит от пояса устройства; имя заметки — для человека.* **Имена аудио и заметки одной записи расходятся на смещение пояса — можно спутать при ручном поиске; связь всё равно хранится ссылкой `voice-audio`.** +12. 2026-09-27 — `NoteStore.list()` отдаёт frontmatter-объект, а не текст заметки; core перепроверяет состояние внутри `process` по актуальному содержимому и пропускает запись, если она уже не в ожидаемом состоянии или не своего устройства. *Следствие правки 2: без чтения файлов текста нет, а `metadataCache` обновляется асинхронно.* **Без перепроверки — повторная отправка только что отправленной записи.** +13. 2026-09-27 — Исключение из `RecordingStore.read` или `Transcriber.transcribe` трактуется как временная ошибка со своим сообщением. *Классифицирует провайдер, но непредвиденный throw не должен ронять очередь.* **Если исключение на деле постоянное (например, аудиофайл удалён) — 5 бесполезных попыток за ~42 минуты, затем `failed`.** +14. 2026-09-27 — Причина «нет речи» хранится кодом `voice-error: no-speech`, а не текстом. *Рендер должен отличать её от обычной ошибки без разбора сообщений.* **Нулевая: значение внутреннее.** +15. 2026-09-27 (план) — Все строковые значения `voice-*` пишутся в двойных кавычках через `JSON.stringify`; числа (`voice-attempts`) и `true` — без кавычек. *JSON-строка — валидная YAML-строка: переводы строк, кавычки и двоеточия из сообщений провайдера экранируются в одну строку; id вида `20260927-...` не рискует распарситься YAML как число/дата.* **Кавычки видны в режиме исходника — косметика.** +16. 2026-09-27 (план) — Из `retry` запись переходит сразу в `sent`; промежуточное `saved` (на схеме «срок наступил → saved») не записывается. *Лишняя запись в файл без наблюдаемой разницы.* **Нулевая: `saved` и «`retry` с наступившим сроком» обрабатываются одинаково.** +17. 2026-09-27 (план) — «Повторить распознавание» переназначает запись текущему устройству. *Иначе `failed`-запись с телефона, повторённая на маке, вернулась бы в `saved` с устройством телефона, и мак её не отправил бы — команда выглядела бы сломанной.* **Если телефон одновременно тоже повторит ту же запись — один лишний запрос; текст не портится.** +18. 2026-09-27 (план) — Тесты идут с `TZ=Europe/Moscow` (задаётся в `vitest.config.ts`). *Имена заметок и строки статуса зависят от локального времени; UTC+3 без летнего времени даёт детерминизм и заметно отличается от UTC.* **Если CI окажется на Windows, где `TZ` игнорируется — тесты с локальным временем упадут; сейчас CI нет.** +19. 2026-09-27 (план) — id плагина `voice-note`; `npm run dev` после каждой сборки копирует `main.js` и `manifest.json` в `<тестовый vault>/.obsidian/plugins/voice-note/`, если vault есть на машине. *CLAUDE.md: «dev — сборка в тестовый vault».* **Смена id позже — переустановка плагина в vault; данные core живут во frontmatter, не теряются.** +20. 2026-09-27 (план) — Если маркеры области стёрты, а запись ещё не `inserted` (retry/failed), плагин обновляет только frontmatter, строку статуса в тело не дописывает; транскрипт при успехе дописывается в конец. *Не мусорить в заметке, которую пользователь сознательно переделал.* **Пользователь не видит в теле статус ожидания/ошибки такой заметки — он остаётся в Properties.** +21. 2026-09-27 (план) — Причина ошибки в callout сжимается в одну строку (пробельные символы → один пробел). *Многострочное сообщение ломает callout.* **Потеря переносов в тексте ошибки — косметика; полное сообщение остаётся во frontmatter.** +22. 2026-09-27 (план) — Готовые к отправке записи обрабатываются по возрастанию `voice-id` (старые первыми). *Предсказуемо; id по UTC упорядочен во времени.* **Нулевая.** +23. 2026-09-27 (план) — Нераспознаваемое `voice-next-attempt` — запись готова к отправке; отсутствующее или нераспознаваемое `voice-sent-at` у `sent` — отправка считается зависшей. *Ручная правка frontmatter не должна вешать запись навсегда.* **Один досрочный запрос.** +24. 2026-09-27 (план) — `QueueRunner.dispose()` отменяет `AbortSignal`, переданный провайдеру; прерванный запрос обрабатывается как любое исключение — временная ошибка. *Контракт `Transcriber` принимает signal; выгрузка плагина должна обрывать загрузку.* **Выгрузка посреди запроса тратит одну попытку из пяти.** +25. 2026-09-27 (план) — Golden-кейсы находятся по файлам `tests/fixtures/*.note.md`; аудио ищется как `.m4a`, `.webm`, `.ogg` с mime `audio/mp4`, `audio/webm`, `audio/ogg`. *Добавить кейс — положить три файла, без правки теста.* **Нулевая.** +26. 2026-09-27 (план) — Модели исполнителей: haiku — `naming`, `schedule` (механика по готовому коду); opus — `QueueRunner.tick` (асинхронность, гонки); sonnet — остальное. Режим — subagent-driven-development (10 задач > 5). *Правила Model Selection SDD и глобальный CLAUDE.md.* **Слабая модель на механической задаче — лишний круг ревью, не дефект в ветке.** +27. 2026-09-27 (план, зафиксировано на ревью) — Зависший `sent` засчитывается как попытка (временная ошибка «отправка прервана»). *Иначе запись, на которой Obsidian каждый раз выгружается, повторялась бы бесконечно.* **Пять выгрузок Obsidian подряд во время отправки дадут `failed` без единого ответа сервиса; спасает ручной повтор.** +28. 2026-09-27 (план, зафиксировано на ревью) — Результат запроса пишется по пути, полученному из `list()`. Если заметку переименовали или перенесли между первым `process` (`sent`) и вторым (результат), второй `process` не находит файл и результат отбрасывается. *Порт `NoteStore` адресует заметки путём; отслеживание переименований — усложнение обёртки ради редкого случая.* **Запись остаётся в `sent`, через 10 минут уходит в повтор — один лишний запрос.** +29. 2026-09-27 (исполнение, контроллер) — Модели исполнителей пересмотрены по правилам Model Selection SDD: haiku — задачи 2, 3, 4, 5, 6, 7, 9 (в плане полный код и тесты, 1–2 файла); sonnet — задача 1 (установка зависимостей, сборка), задача 8 (самый большой асинхронный модуль, при падении тестов нужна отладка) и задача 10 (генерация эталона и разбор расхождений со спекой). Ревьюеры задач — sonnet, для задачи 8 — opus; финальное ревью — opus. Заменяет решение 26 и строки **Model** в плане. *Перенос готового кода — дешёвый уровень; opus — для эскалации в цикле исправлений и финального ревью.* **Лишний круг исправлений, если слабая модель ошибётся при переносе; дефект в ветку не попадает — каждая задача проходит ревью.** +30. 2026-09-27 (исполнение, контроллер) — Почти одинаковые тестовые помощники (`setup`/`rec`/`seed`/`fields`) в `tests/queue.test.ts` и `tests/queue-commands.test.ts` реализуются как в плане, без выноса в `tests/fakes.ts`. *Это небольшая тестовая обвязка, а не логика; помощники уже различаются (сигнатура `seed`, байты аудио); превентивная правка меняла бы дословный текст двух задач.* **~20 строк дублированной настройки тестов, которые надо держать в согласии; вынос позже механический.** +31. 2026-09-27 (исполнение, контроллер) — Решения исполнителей задач дописываются ими в этот раздел в коммите задачи (продолжая нумерацию, пометка «исполнение»); рулинги контроллера — отдельными docs-коммитами с пометкой «исполнение, контроллер». *Требование глобальных ограничений плана и CLAUDE.md; задачи идут строго последовательно, конфликтов нумерации нет.* **Шум в нумерации/формате раздела, правится руками.** +32. 2026-09-27 (исполнение, контроллер) — После `QueueRunner.dispose()` очередь больше не отправляет: `tick()` не начинает проход, цикл `do…while` и цикл отправки прерываются, `send()` выходит до записи `sent`; запрос, который был в полёте, по-прежнему завершается временной ошибкой (решение 24 не меняется). Отступление от дословного кода плана, добавлен тест. *Код плана после `dispose()` продолжал обходить готовые записи: каждой писал `sent`, вызывал провайдера с уже отменённым сигналом и тратил попытку, а также писал в vault после выгрузки плагина — это больше, чем цена «одна попытка из пяти», принятая в решении 24.* **Несколько строк защитного кода и тест, которые окажутся лишними, если обёртка никогда не вызывает `tick()` после `dispose()`; требуемое спекой поведение не теряется.** +33. 2026-09-27 (исполнение, контроллер, финальное ревью) — «Распознать все ожидающие здесь» (`claimAll`) кроме чужих записей переводит в `saved` и собственные записи устройства в `retry` и зависшие `sent` (удаляются `voice-next-attempt`/`voice-sent-at`, счётчик сохраняется), затем `tick()` отправляет их сразу. Возвращаемое число — только переназначенные с других устройств. Тест плана изменён. *Спека: «после чего обработать очередь без ожидания времени следующей попытки»; абзац «Ручные команды» и тест плана сузили это без записанного решения; пользователь ждёт, что уйдёт всё ожидающее на этом устройстве.* **Если имелось в виду узкое поведение — свои повторы уходят до 30 минут раньше (лишний запрос на запись, без потери данных); откат — вернуть прежний `claimAll` и тест, переформулировать спеку.** +34. 2026-09-27 (исполнение, контроллер, финальное ревью) — В проходе очереди ошибка обработки одной записи (исключение из `NoteStore.process` и т.п.) не останавливает остальные: запоминается первая ошибка, проход продолжается, в конце ошибка пробрасывается, так что `tick()` всё равно отклоняется. Ошибка `list()` по-прежнему отклоняет `tick()` сразу. *Иначе одна «сломанная» старейшая заметка блокирует все новые на каждом tick; проброс сохраняет видимость ошибки для обёртки.* **Чуть сложнее `pass()`; `retry()`/`claimAll()` могут отклониться, уже изменив свою запись, если упала обработка другой.** +35. 2026-09-27 (исполнение, контроллер, финальное ревью) — Из `.gitignore` убрано правило `tests/fixtures/*.webm`. *Решения 9 и 25: аудиофикстуры любых поддерживаемых расширений лежат в git; правило давало бы зелёный прогон локально и красный на чистом клоне.* **Можно случайно закоммитить большой webm; иначе нулевая.** +36. 2026-09-27 (исполнение, контроллер, финальное ревью) — Дешёвое усиление по итогам финального ревью: результат запроса записывается, только если запись всё ещё `sent` этим устройством с тем же `voice-sent-at`; после `dispose()` не пишут ни восстановление зависших `sent`, ни `retry()` (возвращает `false`), ни `claimAll()` (возвращает `0`); добавлены тесты на `sent` + `voice-sent-at` во время запроса и на `sent` в «Повторить распознавание». Путь vault в `esbuild.config.mjs`, Node-глобалы в типах `src/core` и CRLF остаются отложенными. *Каждое — несколько строк, закрывает названный крайний случай или фиксирует MUST спеки; другой волны исправлений в этой ветке не будет.* **~20 строк защит и тестов; если проверка устройства и `voice-sent-at` слишком строга — законный поздний результат своей отправки отбрасывается и запись повторяется через 10 минут (лишний запрос).** diff --git a/openspec/changes/core-recording-lifecycle/proposal.md b/openspec/changes/core-recording-lifecycle/proposal.md new file mode 100644 index 0000000..d12d750 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/proposal.md @@ -0,0 +1,32 @@ +# Proposal: core-recording-lifecycle + +Issue: #1 — Core: жизненный цикл голосовой записи и очередь отправки. + +## Why + +Плагин должен превращать голосовую диктовку (в первую очередь с iPhone) в текстовую заметку через внешний сервис распознавания. Главный риск мобильного сценария — потеря надиктованного: нет сети, Obsidian выгружен из памяти, запись оборвалась. Нужна независимая от Obsidian и покрытая тестами основа, которая сохраняет запись сразу, доводит её до текста через повторы и никогда не портит заметку. + +## What Changes + +- Новая чистая подсистема `src/core/` (без импортов `obsidian` и Node API): жизненный цикл записи, планировщик повторов, формат заметки, очередь отправки поверх портов. +- Тестовая инфраструктура проекта: сборка по шаблону obsidian-sample-plugin, vitest, фейки портов, golden-тест `test-01`, тест-страж границы `src/core`. +- Контракт провайдера распознавания (интерфейс), который реализует следующая подсистема. +- Без реального провайдера, UI, команд и `MediaRecorder` — это отдельные issue. + +## Capabilities + +### New Capabilities + +- `recording-lifecycle`: состояния записи, сохранение захваченного аудио, отправка, классификация ошибок, повторы с нарастающей паузой, принадлежность записи устройству, ручные команды «повторить» и «забрать все ожидающие». +- `voice-note-format`: размещение и имена файлов аудио и заметок, ключи frontmatter, управляемая плагином область тела заметки, правила вставки транскрипта без порчи пользовательского текста. + +### Modified Capabilities + +Нет. + +## Impact + +- Новые файлы: `package.json`, `tsconfig.json`, `esbuild.config.mjs`, `manifest.json`, `vitest.config.ts`, `src/core/*`, `tests/*`. +- Зависимости (dev): `typescript`, `esbuild`, `vitest`, `obsidian` (типы), `@types/node` только для тестов. +- `.gitignore`: добавляется `.claude/worktrees/`. +- Следующие подсистемы (провайдер, обёртка Obsidian) зависят от портов и контракта `Transcriber`, описанных в design.md. diff --git a/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md new file mode 100644 index 0000000..a966f91 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md @@ -0,0 +1,127 @@ +# Spec Delta: recording-lifecycle + +## Purpose + +Гарантирует, что каждая завершённая или прерванная голосовая запись сохраняется немедленно и доводится до текста через отправку во внешний сервис с повторами, без дублей между устройствами и без потери данных. + +## ADDED Requirements + +### Requirement: Состояния записи + +Запись MUST находиться ровно в одном из состояний: `created` (идёт запись, данные только в памяти), `saved` (аудио и заметка сохранены, ожидает отправки), `sent` (запрос в полёте), `transcribed` (текст получен, не записан; не сохраняется), `retry` (ожидает повтора после временной ошибки), `failed` (требуется ручное действие), `inserted` (финал). Во frontmatter заметки MUST сохраняться только `saved`, `sent`, `retry`, `failed`, `inserted`. Переход, не описанный в этой спецификации, MUST отклоняться ошибкой без изменения записи. + +#### Scenario: Недопустимый переход отклонён +- **WHEN** к записи в состоянии `inserted` применяется событие «отправка начата» +- **THEN** операция завершается ошибкой, запись не изменена + +#### Scenario: Транзитное состояние не попадает в заметку +- **WHEN** получен текст транскрипта +- **THEN** заметка за одну запись переходит из `sent` сразу в `inserted`, значение `transcribed` во frontmatter не появляется + +### Requirement: Сохранение захваченного аудио + +По окончании записи плагин MUST сохранить аудио в `voice/source/` и создать заметку в `voice/inbox/` в состоянии `saved` до любой сетевой операции. Если захвачено 0 байт, MUST NOT создаваться ни аудиофайл, ни заметка. Если запись была прервана (событие остановки не от пользователя), сохраняется всё накопленное, а запись помечается как прерванная. + +#### Scenario: Обычная запись +- **WHEN** пользователь остановил запись длиной 0:47 с непустыми данными в формате `audio/mp4` +- **THEN** в `voice/source/` появился файл `.m4a`, в `voice/inbox/` — заметка со статусом `saved`, и это произошло без сетевых запросов + +#### Scenario: Прерванная запись +- **WHEN** запись прервалась системой после 0:47 с непустыми данными +- **THEN** аудио и заметка сохранены, заметка помечена как прерванная с длительностью 0:47, и запись участвует в очереди как обычная + +#### Scenario: Пустая запись +- **WHEN** запись завершилась (штатно или обрывом) с 0 байт данных +- **THEN** никаких файлов не создано + +### Requirement: Отправка и результат + +Перед отправкой запись MUST перейти в `sent` с отметкой времени начала отправки, записанной в заметку. Очередь MUST обрабатывать записи строго по одной. Результат провайдера — успех с текстом, временная ошибка или постоянная ошибка. Успех с непустым текстом MUST переводить запись в `inserted` с текстом в заметке. Успех с пустым (после обрезки пробелов) текстом MUST переводить запись в `failed` с причиной «нет речи»: это свойство записи, а не ошибка сервиса, повтор бесполезен. + +#### Scenario: Успешное распознавание +- **WHEN** провайдер вернул текст, а запись в момент ответа в `sent` +- **THEN** заметка содержит текст и статус `inserted` + +#### Scenario: В записи нет речи +- **WHEN** провайдер вернул успех с текстом из одних пробелов +- **THEN** запись в `failed` с причиной «нет речи», в заметке — «В записи не распознано речи» + +#### Scenario: Последовательная обработка +- **WHEN** в очереди три готовые к отправке записи +- **THEN** второй запрос к провайдеру начинается только после завершения первого + +### Requirement: Повторы после временных ошибок + +Временная ошибка MUST увеличивать счётчик попыток и переводить запись в `retry` со временем следующей попытки через 30 с, 2 мин, 10 мин и 30 мин после 1-й, 2-й, 3-й и 4-й неудачи соответственно. Пятая неудача подряд MUST переводить запись в `failed`. Постоянная ошибка MUST переводить запись в `failed` сразу, независимо от счётчика. Запись в `retry` MUST NOT отправляться раньше своего времени следующей попытки. + +#### Scenario: Сеть пропала и вернулась +- **WHEN** первая отправка завершилась временной ошибкой в 14:15:03, а следующая попытка после 14:15:33 — успехом +- **THEN** после первой попытки запись в `retry` со следующей попыткой 14:15:33, после второй — в `inserted` + +#### Scenario: Исчерпание попыток +- **WHEN** пять отправок подряд завершились временной ошибкой +- **THEN** запись в `failed`, причина — последняя ошибка + +#### Scenario: Постоянная ошибка +- **WHEN** первая отправка завершилась постоянной ошибкой «401 Unauthorized» +- **THEN** запись сразу в `failed` с этой причиной + +#### Scenario: Время повтора не наступило +- **WHEN** очередь запускается в 14:15:20 для записи со следующей попыткой 14:15:33 +- **THEN** запрос к провайдеру не отправляется + +### Requirement: Восстановление после прерванной отправки + +Запись текущего устройства в `sent`, у которой с начала отправки прошло больше 10 минут, MUST считаться завершившейся временной ошибкой «отправка прервана» и обрабатываться по правилам повторов. + +#### Scenario: Obsidian выгружен во время запроса +- **WHEN** очередь запускается в 14:30:00 и находит запись в `sent` с началом отправки 14:19:00 +- **THEN** запись учитывается как временная неудача и планируется к повтору + +#### Scenario: Запрос ещё может идти +- **WHEN** очередь находит запись в `sent` с началом отправки 2 минуты назад +- **THEN** запись не трогается + +### Requirement: Принадлежность устройству + +Каждая запись MUST хранить идентификатор создавшего её устройства. Автоматическая обработка очереди MUST брать только записи текущего устройства. Идентификатор устройства MUST NOT синхронизироваться между устройствами. + +#### Scenario: Чужая запись не отправляется автоматически +- **WHEN** на маке запускается очередь, а в vault есть запись в `saved`, созданная на iPhone +- **THEN** мак не отправляет её + +### Requirement: Команда «Распознать все ожидающие здесь» + +Команда MUST переназначить текущему устройству все записи других устройств в состояниях `saved`, `retry` и `sent` старше 10 минут, после чего обработать очередь без ожидания времени следующей попытки. Записи в `failed` и `inserted`, а также `sent` не старше 10 минут, MUST NOT затрагиваться. + +#### Scenario: Мак забирает запись телефона +- **WHEN** на маке выполнена команда, а в vault есть запись iPhone в `retry` со следующей попыткой через 20 минут +- **THEN** запись переназначена маку и отправлена сейчас + +### Requirement: Команда «Повторить распознавание» + +Команда для записи в `failed` или `retry` MUST сбросить счётчик попыток в 0, перевести запись в `saved` и запустить очередь. Для записей в других состояниях команда MUST ничего не менять. + +#### Scenario: Повтор после исправления ключа +- **WHEN** запись в `failed` после 401, пользователь исправил ключ и выполнил команду +- **THEN** запись отправлена заново со счётчиком попыток, начатым с нуля + +### Requirement: Удалённая заметка отменяет запись + +Состояние записи живёт только во frontmatter её заметки; очередь находит записи по наличию ключа `voice-id` в любом месте vault, а не по папке. Если заметку удалили, запись MUST считаться отменённой, и её аудиофайл MUST NOT удаляться. Если заметку удалили во время запроса, пришедший результат MUST отбрасываться без ошибки и без воссоздания заметки. + +#### Scenario: Заметка удалена во время запроса +- **WHEN** провайдер вернул текст после того, как заметку удалили +- **THEN** заметка не создана заново, очередь продолжает работу со следующей записью + +#### Scenario: Заметка перенесена в другую папку +- **WHEN** пользователь перенёс заметку в `saved` из `voice/inbox/` в `projects/` +- **THEN** запись по-прежнему обрабатывается очередью + +### Requirement: Повторный запуск очереди + +Если запуск очереди запрошен, пока она уже работает, новый параллельный проход MUST NOT начинаться; вместо этого MUST выполниться ровно один дополнительный проход после завершения текущего. + +#### Scenario: Три запроса во время работы +- **WHEN** очередь обрабатывает запись и за это время запуск запрошен три раза +- **THEN** после текущего прохода выполняется ровно один дополнительный проход diff --git a/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md b/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md new file mode 100644 index 0000000..fe0ef17 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md @@ -0,0 +1,63 @@ +# Spec Delta: voice-note-format + +## Purpose + +Определяет, где и под какими именами лежат аудиофайлы и заметки голосовых записей, как в заметке хранится состояние записи и как плагин обновляет заметку, не затрагивая текст пользователя. + +## ADDED Requirements + +### Requirement: Идентификатор и имена файлов + +Идентификатор записи MUST иметь вид `YYYYMMDD-HHMMSS-xxxx` (время начала записи в UTC и 4 случайных шестнадцатеричных символа в нижнем регистре). Аудио MUST сохраняться как `voice/source/.`, где расширение определяется по mime без параметров: `audio/mp4` → `m4a`, `audio/webm` → `webm`, `audio/ogg` → `ogg`, любой другой → `bin`. Заметка MUST создаваться как `voice/inbox/YYYY-MM-DD HH-mm-ss.md` по локальному времени начала записи; при занятом имени MUST добавляться суффикс ` (2)`, ` (3)` и т.д. + +#### Scenario: Mime с параметрами +- **WHEN** запись в формате `audio/webm;codecs=opus` начата 27.09.2026 в 14:15:03 на устройстве в поясе UTC+3 +- **THEN** аудио сохранено как `voice/source/20260927-111503-<4 hex>.webm`, заметка — `voice/inbox/2026-09-27 14-15-03.md` + +#### Scenario: Коллизия имени заметки +- **WHEN** заметка `voice/inbox/2026-09-27 14-15-03.md` уже существует +- **THEN** новая заметка создана как `voice/inbox/2026-09-27 14-15-03 (2).md` + +### Requirement: Ключи frontmatter + +Состояние записи MUST храниться в плоских ключах frontmatter: `voice-id`, `voice-status`, `voice-device`, `voice-audio` (wiki-ссылка на аудио), `voice-mime` (исходный mime с параметрами), `voice-attempts`, а также, когда применимо, `voice-next-attempt` и `voice-sent-at` (ISO 8601 UTC), `voice-error`, `voice-interrupted`. Плагин MUST изменять только ключи с префиксом `voice-`, а остальные ключи и их порядок MUST оставаться нетронутыми. При переходе в `inserted` ключи `voice-next-attempt`, `voice-sent-at` и `voice-error` MUST удаляться. Заметка с отсутствующим или некорректным `voice-status` MUST игнорироваться очередью. + +#### Scenario: Пользовательские свойства сохраняются +- **WHEN** пользователь добавил в заметку свойство `tags: [idea]`, а затем запись перешла в `inserted` +- **THEN** `tags: [idea]` на месте в прежней позиции, ключи `voice-*` обновлены + +#### Scenario: Испорченный статус +- **WHEN** пользователь вручную записал `voice-status: foo` +- **THEN** очередь не обрабатывает заметку и не падает + +### Requirement: Тело заметки до распознавания + +Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Не удалось отправить, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`, кроме причины «нет речи», для которой — `> [!info] В записи не распознано речи` (без слова «ошибка» и без призыва повторить). Для прерванной записи над областью MUST стоять строка `> [!note] Запись прервана, сохранено M:SS`, которая остаётся и после вставки текста. + +#### Scenario: Заметка в ожидании повтора +- **WHEN** запись перешла в `retry` после второй неудачи со следующей попыткой в 14:17 по местному времени +- **THEN** область содержит `> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)` + +### Requirement: Вставка транскрипта + +При переходе в `inserted` управляемая область вместе с маркерами MUST заменяться текстом транскрипта. Весь текст за пределами области MUST оставаться без изменений. Если маркеры не найдены, транскрипт MUST дописываться в конец заметки через пустую строку. Если запись уже в `inserted`, повторная вставка MUST не менять заметку. Обновление статуса и тела MUST выполняться одной операцией записи. + +#### Scenario: Пользователь дописал текст под заметкой +- **WHEN** под управляемой областью пользователь написал «купить молоко», затем пришёл транскрипт +- **THEN** область заменена транскриптом, «купить молоко» осталось на месте + +#### Scenario: Маркеры удалены вручную +- **WHEN** пользователь стёр маркеры, затем пришёл транскрипт +- **THEN** транскрипт дописан в конец заметки, статус `inserted` + +#### Scenario: Дубль ответа +- **WHEN** транскрипт приходит для записи, уже находящейся в `inserted` +- **THEN** содержимое заметки не изменилось + +### Requirement: Golden-эталоны + +Для каждой пары фикстур `tests/fixtures/.` + `.txt` с эталоном `.note.md` полный путь «захват → очередь → вставка» с провайдером, отвечающим содержимым `.txt`, при фиксированных времени, идентификаторе устройства и источнике случайности MUST давать заметку, побайтно совпадающую с `.note.md`. Провайдер MUST получать байты аудиофикстуры без изменений и исходный mime. + +#### Scenario: test-01 +- **WHEN** прогоняется фикстура `test-01.m4a` с mime `audio/mp4` и ответом из `test-01.txt` +- **THEN** итоговая заметка совпадает с `test-01.note.md` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7560dc0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2102 @@ +{ + "name": "obsidian-voice-note", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-voice-note", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^26.6.3", + "esbuild": "^0.28.2", + "obsidian": "^1.13.1", + "typescript": "^7.0.2", + "vitest": "^5.0.2" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "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/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "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/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "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/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.2", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "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/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obsidian": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz", + "integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.2", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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/why-is-node-running": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f025eaa --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "obsidian-voice-note", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc --noEmit && node esbuild.config.mjs production", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^26.6.3", + "esbuild": "^0.28.2", + "obsidian": "^1.13.1", + "typescript": "^7.0.2", + "vitest": "^5.0.2" + } +} diff --git a/src/core/capture.ts b/src/core/capture.ts new file mode 100644 index 0000000..c8cc3ae --- /dev/null +++ b/src/core/capture.ts @@ -0,0 +1,50 @@ +import { audioPath, makeId, notePath } from './naming'; +import { initialNote } from './note'; +import type { Env, NoteStore, RecordingStore } from './ports'; +import type { VoiceRecord } from './record'; + +export interface Capture { + chunks: ArrayBuffer[]; + mime: string; + startedAt: Date; + endedAt: Date; + interrupted: boolean; +} + +export interface CaptureDeps { + recordings: RecordingStore; + notes: NoteStore; + env: Env; +} + +// Сохраняет запись до любой сети. Возвращает путь заметки или null, если данных нет. +export async function saveCapture(deps: CaptureDeps, capture: Capture): Promise { + const size = capture.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + if (size === 0) return null; + + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of capture.chunks) { + bytes.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; + } + + const id = makeId(capture.startedAt, () => deps.env.random()); + const record: VoiceRecord = { + id, + status: 'saved', + device: deps.env.deviceId, + audio: audioPath(id, capture.mime), + mime: capture.mime, + attempts: 0, + }; + if (capture.interrupted) record.interrupted = true; + + await deps.recordings.write(record.audio, bytes.buffer); + + let n = 1; + while (await deps.notes.exists(notePath(capture.startedAt, n))) n++; + const path = notePath(capture.startedAt, n); + await deps.notes.create(path, initialNote(record, capture.endedAt.getTime() - capture.startedAt.getTime())); + return path; +} diff --git a/src/core/naming.ts b/src/core/naming.ts new file mode 100644 index 0000000..d7e750a --- /dev/null +++ b/src/core/naming.ts @@ -0,0 +1,48 @@ +export const SOURCE_DIR = 'voice/source'; +export const INBOX_DIR = 'voice/inbox'; + +const pad = (n: number) => String(n).padStart(2, '0'); + +// Время в id — UTC: порядок id не зависит от пояса устройства. +export function makeId(startedAt: Date, random: () => number): string { + const d = startedAt; + const date = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`; + const time = `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`; + let hex = ''; + for (let i = 0; i < 4; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16); + return `${date}-${time}-${hex}`; +} + +const EXT: Record = { + 'audio/mp4': 'm4a', + 'audio/webm': 'webm', + 'audio/ogg': 'ogg', +}; + +export function extFromMime(mime: string): string { + const base = mime.split(';')[0].trim().toLowerCase(); + return EXT[base] ?? 'bin'; +} + +export function audioPath(id: string, mime: string): string { + return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`; +} + +// Имя заметки — для человека, поэтому в локальном времени. +export function notePath(startedAt: Date, n: number): string { + const d = startedAt; + const name = + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; + return `${INBOX_DIR}/${name}${n > 1 ? ` (${n})` : ''}.md`; +} + +export function formatDuration(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + return `${Math.floor(total / 60)}:${pad(total % 60)}`; +} + +export function formatLocalTime(iso: string): string { + const d = new Date(iso); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} diff --git a/src/core/note.ts b/src/core/note.ts new file mode 100644 index 0000000..3257c73 --- /dev/null +++ b/src/core/note.ts @@ -0,0 +1,196 @@ +import { formatDuration, formatLocalTime } from './naming'; +import { MAX_ATTEMPTS, NO_SPEECH, STATUSES, type Status, type VoiceRecord } from './record'; + +// Порядок, в котором ключи появляются в новой заметке. +export const VOICE_KEYS = [ + 'voice-id', + 'voice-status', + 'voice-device', + 'voice-audio', + 'voice-mime', + 'voice-attempts', + 'voice-next-attempt', + 'voice-sent-at', + 'voice-error', + 'voice-interrupted', +] as const; +export type VoiceKey = (typeof VOICE_KEYS)[number]; + +// Строки — всегда в двойных кавычках: JSON-строка является валидной YAML-строкой +// и экранирует переводы строк, кавычки и двоеточия в одну строку. +function yaml(value: string | number): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +export function recordToFields(r: VoiceRecord): Record { + return { + 'voice-id': yaml(r.id), + 'voice-status': yaml(r.status), + 'voice-device': yaml(r.device), + 'voice-audio': yaml(`[[${r.audio}]]`), + 'voice-mime': yaml(r.mime), + 'voice-attempts': yaml(r.attempts), + 'voice-next-attempt': r.nextAttempt === undefined ? undefined : yaml(r.nextAttempt), + 'voice-sent-at': r.sentAt === undefined ? undefined : yaml(r.sentAt), + 'voice-error': r.error === undefined ? undefined : yaml(r.error), + 'voice-interrupted': r.interrupted ? 'true' : undefined, + }; +} + +// [первая строка внутри блока, индекс закрывающего ---] +function frontmatterRange(lines: string[]): [number, number] | null { + if (lines[0] !== '---') return null; + const end = lines.indexOf('---', 1); + return end === -1 ? null : [1, end]; +} + +function unquote(raw: string): string { + if (raw.startsWith('"')) { + try { + const value: unknown = JSON.parse(raw); + if (typeof value === 'string') return value; + } catch { + // не JSON — вернём как есть + } + } + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { + return raw.slice(1, -1).replace(/''/g, "'"); + } + return raw; +} + +const VOICE_LINE = /^(voice-[a-z-]+):[ \t]*(.*)$/; + +export function readVoiceFields(content: string): Record { + const lines = content.split('\n'); + const range = frontmatterRange(lines); + const fields: Record = {}; + if (!range) return fields; + for (let i = range[0]; i < range[1]; i++) { + const match = VOICE_LINE.exec(lines[i]); + if (match) fields[match[1]] = unquote(match[2].trim()); + } + return fields; +} + +function str(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return undefined; +} + +export function parseRecord(fields: Record): VoiceRecord | null { + const id = str(fields['voice-id']); + const status = str(fields['voice-status']); + const device = str(fields['voice-device']); + const audio = str(fields['voice-audio']); + const mime = str(fields['voice-mime']); + const attempts = Number(str(fields['voice-attempts']) ?? '0'); + if (!id || !device || !audio || !mime) return null; + if (!STATUSES.includes(status as Status)) return null; + if (!Number.isInteger(attempts) || attempts < 0) return null; + + const record: VoiceRecord = { + id, + status: status as Status, + device, + audio: audio.replace(/^\[\[/, '').replace(/\]\]$/, ''), + mime, + attempts, + }; + const nextAttempt = str(fields['voice-next-attempt']); + const sentAt = str(fields['voice-sent-at']); + const error = str(fields['voice-error']); + if (nextAttempt) record.nextAttempt = nextAttempt; + if (sentAt) record.sentAt = sentAt; + if (error) record.error = error; + if (str(fields['voice-interrupted']) === 'true') record.interrupted = true; + return record; +} + +// Меняет только перечисленные ключи; undefined — удалить строку. +// Остальные строки frontmatter и их порядок не трогаются. +export function patchFrontmatter(content: string, fields: Record): string { + const lines = content.split('\n'); + let range = frontmatterRange(lines); + if (!range) { + lines.unshift('---', '---'); + range = [1, 1]; + } + const start = range[0]; + let end = range[1]; + for (const key of Object.keys(fields)) { + const value = fields[key]; + let index = -1; + for (let i = start; i < end; i++) { + if (lines[i].startsWith(`${key}:`)) { + index = i; + break; + } + } + if (value === undefined) { + if (index !== -1) { + lines.splice(index, 1); + end--; + } + } else if (index !== -1) { + lines[index] = `${key}: ${value}`; + } else { + lines.splice(end, 0, `${key}: ${value}`); + end++; + } + } + return lines.join('\n'); +} + +export const MARKER_END = '%%/voice-note%%'; +export const markerStart = (id: string) => `%%voice-note:${id}%%`; + +export function statusLine(r: VoiceRecord): string { + switch (r.status) { + case 'retry': { + const at = r.nextAttempt ? formatLocalTime(r.nextAttempt) : '—'; + return `> [!info] Не удалось отправить, повтор в ${at} (попытка ${r.attempts}/${MAX_ATTEMPTS})`; + } + case 'failed': { + if (r.error === NO_SPEECH) return '> [!info] В записи не распознано речи'; + const reason = (r.error ?? 'неизвестная причина').replace(/\s+/g, ' ').trim(); + return `> [!warning] Не распознано: ${reason}. Команда «Повторить распознавание»`; + } + default: + return '> [!info] Распознаётся…'; + } +} + +function region(r: VoiceRecord): string { + return `${markerStart(r.id)}\n${statusLine(r)}\n${MARKER_END}`; +} + +export function initialNote(record: VoiceRecord, durationMs: number): string { + const interrupted = record.interrupted + ? `> [!note] Запись прервана, сохранено ${formatDuration(durationMs)}\n\n` + : ''; + return ( + patchFrontmatter('', recordToFields(record)) + + `![[${record.audio}]]\n\n${interrupted}${region(record)}\n` + ); +} + +// Обновляет frontmatter и управляемую область одной операцией. +// Склейка через slice, не String.replace: в транскрипте могут быть $&, $' и т.п. +export function renderNote(content: string, record: VoiceRecord, text?: string): string { + if (record.status === 'inserted' && readVoiceFields(content)['voice-status'] === 'inserted') { + return content; + } + const patched = patchFrontmatter(content, recordToFields(record)); + const start = patched.indexOf(markerStart(record.id)); + const end = start === -1 ? -1 : patched.indexOf(MARKER_END, start); + + if (record.status === 'inserted') { + const body = (text ?? '').trim(); + if (end === -1) return `${patched.replace(/\s+$/, '')}\n\n${body}\n`; + return patched.slice(0, start) + body + patched.slice(end + MARKER_END.length); + } + if (end === -1) return patched; + return patched.slice(0, start) + region(record) + patched.slice(end + MARKER_END.length); +} diff --git a/src/core/ports.ts b/src/core/ports.ts new file mode 100644 index 0000000..478f157 --- /dev/null +++ b/src/core/ports.ts @@ -0,0 +1,38 @@ +// Порты core. Реализации — в src/obsidian (позже) и tests/fakes.ts. + +export interface RecordingStore { + write(path: string, data: ArrayBuffer): Promise; + read(path: string): Promise; +} + +export interface ListedNote { + path: string; + frontmatter: Record; +} + +export interface NoteStore { + exists(path: string): Promise; + create(path: string, content: string): Promise; + // Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter. + // КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать + // содержимое файлов (vault.read / cachedRead / adapter). Вызывается на каждом + // tick, в том числе на телефоне. Данные могут отставать от диска — core + // перепроверяет состояние внутри process(). + list(): Promise; + // Атомарное чтение-изменение-запись. false, если заметки нет. + process(path: string, fn: (content: string) => string): Promise; +} + +export type TranscribeResult = + | { ok: true; text: string } + | { ok: false; kind: 'temporary' | 'permanent'; message: string }; + +export interface Transcriber { + transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise; +} + +export interface Env { + now(): Date; + random(): number; + readonly deviceId: string; +} diff --git a/src/core/queue.ts b/src/core/queue.ts new file mode 100644 index 0000000..e369b70 --- /dev/null +++ b/src/core/queue.ts @@ -0,0 +1,202 @@ +import { parseRecord, readVoiceFields, renderNote } from './note'; +import type { Env, NoteStore, RecordingStore, TranscribeResult, Transcriber } from './ports'; +import { NO_SPEECH, transition, type VoiceRecord } from './record'; +import { claimable, due, isStaleSent, ownReclaimable, staleOwn, type Located } from './schedule'; + +export interface QueueDeps { + recordings: RecordingStore; + notes: NoteStore; + transcriber: Transcriber; + env: Env; +} + +export const INTERRUPTED_SEND = 'отправка прервана'; + +interface Change { + record: VoiceRecord; + text?: string; +} + +export class QueueRunner { + private running = false; + private rerun = false; + private readonly controller = new AbortController(); + + constructor(private readonly deps: QueueDeps) {} + + // Повторный вызов во время работы не запускает параллельный проход, + // а просит ровно один дополнительный после текущего. + // Отклоняется (rejects), если list() бросил исключение, или если обработка + // какой-то записи бросила исключение — в этом случае остальной проход всё + // равно завершается, и первая ошибка пробрасывается уже после него. + async tick(): Promise { + if (this.controller.signal.aborted) return; + if (this.running) { + this.rerun = true; + return; + } + this.running = true; + try { + do { + this.rerun = false; + await this.pass(); + } while (this.rerun && !this.controller.signal.aborted); + } finally { + this.running = false; + } + } + + dispose(): void { + this.controller.abort(); + } + + // «Повторить распознавание»: failed/retry → saved, счётчик с нуля, запись + // переходит текущему устройству (иначе чужую запись здесь не отправить). + async retry(path: string): Promise { + if (this.controller.signal.aborted) return false; + const { env } = this.deps; + const changed = await this.update( + path, + (r) => r.status === 'failed' || r.status === 'retry', + (r) => ({ record: transition(r, { type: 'retry-now', device: env.deviceId }, env.now()) }), + ); + if (!changed) return false; + await this.tick(); + return true; + } + + // «Распознать все ожидающие здесь»: чужие saved/retry/зависшие sent → своё + // устройство, статус saved (без ожидания повтора), счётчик сохраняется. + // Дополнительно так же разбираются свои retry (независимо от + // voice-next-attempt) и свои зависшие sent — тоже без ожидания. После + // этого — обычный tick(). Возвращаемое число — только записи, переданные с + // других устройств; свои не считаются. + async claimAll(): Promise { + if (this.controller.signal.aborted) return 0; + const { env } = this.deps; + const now = env.now(); + const items = await this.load(); + let claimed = 0; + for (const item of claimable(items, now, env.deviceId)) { + const changed = await this.update( + item.path, + (r) => r.id === item.record.id && claimable([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'claim', device: env.deviceId }, now) }), + ); + if (changed) claimed++; + } + for (const item of ownReclaimable(items, now, env.deviceId)) { + await this.update( + item.path, + (r) => + r.id === item.record.id && ownReclaimable([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'claim', device: env.deviceId }, now) }), + ); + } + await this.tick(); + return claimed; + } + + // Ошибка одной записи (process()/read() бросили исключение) не блокирует + // остальные: она запоминается, проход продолжается, и первая такая ошибка + // пробрасывается только после того, как обе очереди пройдены целиком. + private async pass(): Promise { + const { env } = this.deps; + const items = await this.load(); + const now = env.now(); + let hasError = false; + let firstError: unknown; + const remember = (e: unknown) => { + if (!hasError) { + hasError = true; + firstError = e; + } + }; + for (const item of staleOwn(items, now, env.deviceId)) { + if (this.controller.signal.aborted) break; + try { + await this.update( + item.path, + (r) => r.id === item.record.id && r.device === env.deviceId && isStaleSent(r, now), + (r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }), + ); + } catch (e) { + remember(e); + } + } + for (const item of due(items, now, env.deviceId)) { + if (this.controller.signal.aborted) break; + try { + await this.send(item); + } catch (e) { + remember(e); + } + } + if (hasError) throw firstError; + } + + private async send(item: Located): Promise { + if (this.controller.signal.aborted) return; + const { recordings, transcriber, env } = this.deps; + const now = env.now(); + // Список мог устареть (metadataCache) — решение принимаем по актуальному файлу. + const sent = await this.update( + item.path, + (r) => r.id === item.record.id && due([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'send' }, now) }), + ); + if (!sent) return; + + let result: TranscribeResult; + try { + const audio = await recordings.read(sent.audio); + result = await transcriber.transcribe(audio, sent.mime, this.controller.signal); + } catch (e) { + result = { ok: false, kind: 'temporary', message: e instanceof Error ? e.message : String(e) }; + } + if (result.ok && result.text.trim() === '') { + result = { ok: false, kind: 'permanent', message: NO_SPEECH }; + } + + const done = env.now(); + const final = result; + // Пока запрос был в полёте, запись могла уйти к другому устройству (или + // отправиться заново) — проверяем, что это всё ещё именно наша отправка, + // прежде чем записывать результат поверх неё. + await this.update( + item.path, + (r) => r.id === sent.id && r.status === 'sent' && r.device === env.deviceId && r.sentAt === sent.sentAt, + (r) => + final.ok + ? { record: transition(r, { type: 'success' }, done), text: final.text } + : { record: transition(r, { type: final.kind, message: final.message }, done) }, + ); + } + + // Атомарно: перечитать запись из актуального содержимого, проверить ожидание, + // применить изменение. null — заметки нет или запись не в ожидаемом состоянии. + private async update( + path: string, + expect: (r: VoiceRecord) => boolean, + change: (r: VoiceRecord) => Change, + ): Promise { + const box: { record: VoiceRecord | null } = { record: null }; + const exists = await this.deps.notes.process(path, (content) => { + const current = parseRecord(readVoiceFields(content)); + if (!current || !expect(current)) return content; + const next = change(current); + box.record = next.record; + return renderNote(content, next.record, next.text); + }); + return exists ? box.record : null; + } + + private async load(): Promise { + const located: Located[] = []; + for (const { path, frontmatter } of await this.deps.notes.list()) { + const record = parseRecord(frontmatter); + if (record) located.push({ path, record }); + } + return located; + } +} diff --git a/src/core/record.ts b/src/core/record.ts new file mode 100644 index 0000000..8ac67c4 --- /dev/null +++ b/src/core/record.ts @@ -0,0 +1,87 @@ +// Персистентные состояния. created и transcribed живут только в памяти и сюда не входят. +export type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted'; +export const STATUSES: readonly Status[] = ['saved', 'sent', 'retry', 'failed', 'inserted']; + +export const MAX_ATTEMPTS = 5; +// Пауза перед повтором после 1-й, 2-й, 3-й и 4-й временной неудачи. +export const BACKOFF_MS: readonly number[] = [30_000, 120_000, 600_000, 1_800_000]; +// sent старше этого считается прерванной отправкой. +export const SENT_STALE_MS = 600_000; +// Код причины в voice-error для пустого транскрипта. +export const NO_SPEECH = 'no-speech'; + +export interface VoiceRecord { + id: string; + status: Status; + device: string; + audio: string; // путь в vault, без [[ ]] + mime: string; + attempts: number; + nextAttempt?: string; // ISO UTC + sentAt?: string; // ISO UTC + error?: string; + interrupted?: boolean; +} + +export type RecordEvent = + | { type: 'send' } + | { type: 'success' } + | { type: 'temporary'; message: string } + | { type: 'permanent'; message: string } + | { type: 'retry-now'; device: string } + | { type: 'claim'; device: string }; + +export class InvalidTransition extends Error { + constructor(from: Status, event: RecordEvent['type']) { + super(`invalid transition: ${event} from ${from}`); + this.name = 'InvalidTransition'; + } +} + +type Transient = 'nextAttempt' | 'sentAt' | 'error'; + +function without(record: VoiceRecord, ...keys: Transient[]): VoiceRecord { + const copy = { ...record }; + for (const key of keys) delete copy[key]; + return copy; +} + +export function transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord { + const from = record.status; + switch (event.type) { + case 'send': + if (from !== 'saved' && from !== 'retry') break; + return { ...without(record, 'nextAttempt'), status: 'sent', sentAt: now.toISOString() }; + case 'success': + if (from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt', 'error'), status: 'inserted' }; + case 'temporary': { + if (from !== 'sent') break; + const attempts = record.attempts + 1; + const next = { ...without(record, 'sentAt', 'nextAttempt'), attempts, error: event.message }; + if (attempts >= MAX_ATTEMPTS) return { ...next, status: 'failed' }; + const at = new Date(now.getTime() + BACKOFF_MS[attempts - 1]).toISOString(); + return { ...next, status: 'retry', nextAttempt: at }; + } + case 'permanent': + if (from !== 'sent') break; + return { + ...without(record, 'sentAt', 'nextAttempt'), + status: 'failed', + attempts: record.attempts + 1, + error: event.message, + }; + case 'retry-now': + if (from !== 'failed' && from !== 'retry') break; + return { + ...without(record, 'nextAttempt', 'sentAt', 'error'), + status: 'saved', + attempts: 0, + device: event.device, + }; + case 'claim': + if (from !== 'saved' && from !== 'retry' && from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt'), status: 'saved', device: event.device }; + } + throw new InvalidTransition(from, event.type); +} diff --git a/src/core/schedule.ts b/src/core/schedule.ts new file mode 100644 index 0000000..63f5ca4 --- /dev/null +++ b/src/core/schedule.ts @@ -0,0 +1,45 @@ +import { SENT_STALE_MS, type VoiceRecord } from './record'; + +export interface Located { + path: string; + record: VoiceRecord; +} + +const time = (iso?: string) => (iso ? Date.parse(iso) : NaN); + +// Отсутствующее или испорченное время — не повод зависнуть навсегда. +export function isStaleSent(r: VoiceRecord, now: Date): boolean { + if (r.status !== 'sent') return false; + const t = time(r.sentAt); + return Number.isNaN(t) || now.getTime() - t > SENT_STALE_MS; +} + +export function due(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items + .filter(({ record: r }) => { + if (r.device !== deviceId) return false; + if (r.status === 'saved') return true; + if (r.status !== 'retry') return false; + const t = time(r.nextAttempt); + return Number.isNaN(t) || t <= now.getTime(); + }) + .sort((a, b) => (a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0)); +} + +export function staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter(({ record: r }) => r.device === deviceId && isStaleSent(r, now)); +} + +export function claimable(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter( + ({ record: r }) => + r.device !== deviceId && (r.status === 'saved' || r.status === 'retry' || isStaleSent(r, now)), + ); +} + +// «Распознать все ожидающие здесь» также разбирает собственные retry +// (независимо от voice-next-attempt) и собственные зависшие sent — без +// ожидания, как и чужие claimable(). +export function ownReclaimable(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter(({ record: r }) => r.device === deviceId && (r.status === 'retry' || isStaleSent(r, now))); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..358a51f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,6 @@ +import { Plugin } from 'obsidian'; + +// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала. +export default class VoiceNotePlugin extends Plugin { + override async onload(): Promise {} +} diff --git a/tests/capture.test.ts b/tests/capture.test.ts new file mode 100644 index 0000000..5a792b3 --- /dev/null +++ b/tests/capture.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { saveCapture, type Capture } from '../src/core/capture'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { MemoryNotes, MemoryRecordings, TestEnv } from './fakes'; + +const T = new Date('2026-09-27T11:15:03.000Z'); +const buf = (...bytes: number[]) => new Uint8Array(bytes).buffer; + +function setup() { + const env = new TestEnv(T); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + return { env, notes, recordings, deps: { env, notes, recordings } }; +} + +const capture = (over: Partial = {}): Capture => ({ + chunks: [buf(1, 2), buf(3, 4, 5)], + mime: 'audio/mp4', + startedAt: T, + endedAt: new Date(T.getTime() + 47_000), + interrupted: false, + ...over, +}); + +describe('saveCapture', () => { + it('writes concatenated audio and a saved note', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture()); + + expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); + const audio = recordings.files.get('voice/source/20260927-111503-0000.m4a'); + expect(new Uint8Array(audio!)).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + expect(notes.files.get(path!)).toBe( + initialNote( + { + id: '20260927-111503-0000', + status: 'saved', + device: 'test-device', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, + }, + 47_000, + ), + ); + }); + + it.each([[[]], [[new ArrayBuffer(0)]], [[new ArrayBuffer(0), new ArrayBuffer(0)]]])( + 'creates nothing for empty data %#', + async (chunks) => { + const { deps, notes, recordings } = setup(); + expect(await saveCapture(deps, capture({ chunks, interrupted: true }))).toBeNull(); + expect(notes.files.size).toBe(0); + expect(recordings.files.size).toBe(0); + }, + ); + + it('marks interrupted recordings with duration', async () => { + const { deps, notes } = setup(); + const path = await saveCapture(deps, capture({ interrupted: true })); + const content = notes.files.get(path!)!; + expect(readVoiceFields(content)['voice-interrupted']).toBe('true'); + expect(content).toContain('> [!note] Запись прервана, сохранено 0:47'); + }); + + it('adds (2), (3) on name collision', async () => { + const { deps, notes } = setup(); + notes.files.set('voice/inbox/2026-09-27 14-15-03.md', 'чужая'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); + expect(notes.files.get('voice/inbox/2026-09-27 14-15-03.md')).toBe('чужая'); + }); + + it('keeps full mime and picks extension from it', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture({ mime: 'audio/webm;codecs=opus' })); + expect(recordings.files.has('voice/source/20260927-111503-0000.webm')).toBe(true); + expect(readVoiceFields(notes.files.get(path!)!)['voice-mime']).toBe('audio/webm;codecs=opus'); + }); +}); diff --git a/tests/core-boundary.test.ts b/tests/core-boundary.test.ts new file mode 100644 index 0000000..cf5a32c --- /dev/null +++ b/tests/core-boundary.test.ts @@ -0,0 +1,34 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const CORE = fileURLToPath(new URL('../src/core/', import.meta.url)); +const FORBIDDEN = + /(?:from\s+|import\s*\(\s*|require\s*\(\s*)['"](?:obsidian|fs|path|child_process|node:[^'"]*)(?:\/[^'"]*)?['"]/; + +function coreFiles(): string[] { + if (!existsSync(CORE)) return []; + return readdirSync(CORE, { recursive: true, encoding: 'utf8' }) + .filter((f) => f.endsWith('.ts')) + .map((f) => join(CORE, f)); +} + +describe('src/core boundary', () => { + it('detects forbidden imports', () => { + expect(FORBIDDEN.test(`import { Plugin } from 'obsidian';`)).toBe(true); + expect(FORBIDDEN.test(`import { readFile } from "fs/promises";`)).toBe(true); + expect(FORBIDDEN.test(`import x from 'node:path';`)).toBe(true); + expect(FORBIDDEN.test(`const cp = require('child_process');`)).toBe(true); + expect(FORBIDDEN.test(`import { transition } from './record';`)).toBe(false); + }); + + it('has files to check', () => { + expect(coreFiles().length).toBeGreaterThan(0); + }); + + it('imports neither obsidian nor Node modules', () => { + const offenders = coreFiles().filter((f) => FORBIDDEN.test(readFileSync(f, 'utf8'))); + expect(offenders).toEqual([]); + }); +}); diff --git a/tests/fakes.ts b/tests/fakes.ts new file mode 100644 index 0000000..acbed02 --- /dev/null +++ b/tests/fakes.ts @@ -0,0 +1,90 @@ +import { readVoiceFields } from '../src/core/note'; +import type { + Env, + ListedNote, + NoteStore, + RecordingStore, + TranscribeResult, + Transcriber, +} from '../src/core/ports'; + +export class MemoryRecordings implements RecordingStore { + files = new Map(); + + async write(path: string, data: ArrayBuffer): Promise { + this.files.set(path, data.slice(0)); + } + + async read(path: string): Promise { + const data = this.files.get(path); + if (!data) throw new Error(`no such file: ${path}`); + return data.slice(0); + } +} + +export class MemoryNotes implements NoteStore { + files = new Map(); + // Имитация отстающего metadataCache: если задано, list() возвращает это. + staleList: ListedNote[] | null = null; + listCalls = 0; + + async exists(path: string): Promise { + return this.files.has(path); + } + + async create(path: string, content: string): Promise { + if (this.files.has(path)) throw new Error(`already exists: ${path}`); + this.files.set(path, content); + } + + async list(): Promise { + this.listCalls++; + if (this.staleList) return this.staleList; + return [...this.files] + .map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record })) + .filter((note) => 'voice-id' in note.frontmatter); + } + + async process(path: string, fn: (content: string) => string): Promise { + const content = this.files.get(path); + if (content === undefined) return false; + this.files.set(path, fn(content)); + return true; + } +} + +export type Step = + | TranscribeResult + | Error + | ((audio: ArrayBuffer, mime: string) => Promise); + +export class ScriptedTranscriber implements Transcriber { + calls: { audio: ArrayBuffer; mime: string; signal: AbortSignal }[] = []; + + constructor(public steps: Step[] = []) {} + + async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise { + this.calls.push({ audio, mime, signal }); + const step = this.steps.shift(); + if (step === undefined) throw new Error('ScriptedTranscriber: no more steps'); + if (step instanceof Error) throw step; + if (typeof step === 'function') return step(audio, mime); + return step; + } +} + +export class TestEnv implements Env { + constructor( + public time = new Date('2026-09-27T11:15:03.000Z'), + public deviceId = 'test-device', + public random: () => number = () => 0, + ) {} + + now(): Date { + return new Date(this.time); + } + + advance(ms: number): void { + this.time = new Date(this.time.getTime() + ms); + } +} diff --git a/tests/fixtures/test-01.m4a b/tests/fixtures/test-01.m4a new file mode 100644 index 0000000..a6a2a8e Binary files /dev/null and b/tests/fixtures/test-01.m4a differ diff --git a/tests/fixtures/test-01.note.md b/tests/fixtures/test-01.note.md new file mode 100644 index 0000000..c41837e --- /dev/null +++ b/tests/fixtures/test-01.note.md @@ -0,0 +1,11 @@ +--- +voice-id: "20260927-111503-0000" +voice-status: "inserted" +voice-device: "test-device" +voice-audio: "[[voice/source/20260927-111503-0000.m4a]]" +voice-mime: "audio/mp4" +voice-attempts: 0 +--- +![[voice/source/20260927-111503-0000.m4a]] + +Это тестовая запись для проверки распознавания речи с помощью модели WHISPER, для сравнения между двух конкретных моделей. diff --git a/tests/golden.test.ts b/tests/golden.test.ts new file mode 100644 index 0000000..07ce4c7 --- /dev/null +++ b/tests/golden.test.ts @@ -0,0 +1,56 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { saveCapture } from '../src/core/capture'; +import { QueueRunner } from '../src/core/queue'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv } from './fakes'; + +const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url)); +const MIME: Record = { m4a: 'audio/mp4', webm: 'audio/webm', ogg: 'audio/ogg' }; +const cases = readdirSync(FIXTURES) + .filter((f) => f.endsWith('.note.md')) + .map((f) => f.slice(0, -'.note.md'.length)); + +function audioFor(name: string): { file: string; mime: string } { + for (const ext of Object.keys(MIME)) { + const file = `${FIXTURES}${name}.${ext}`; + if (existsSync(file)) return { file, mime: MIME[ext] }; + } + throw new Error(`нет аудиофикстуры tests/fixtures/${name}.m4a (или .webm/.ogg) для эталона ${name}.note.md`); +} + +function toArrayBuffer(buf: Buffer): ArrayBuffer { + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; +} + +describe('golden', () => { + it('has at least one case', () => { + expect(cases.length).toBeGreaterThan(0); + }); + + for (const name of cases) { + it(name, async () => { + const { file, mime } = audioFor(name); + const audio = toArrayBuffer(readFileSync(file)); + const transcript = readFileSync(`${FIXTURES}${name}.txt`, 'utf8'); + const expected = readFileSync(`${FIXTURES}${name}.note.md`, 'utf8'); + + const env = new TestEnv(); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber([{ ok: true, text: transcript }]); + + const path = await saveCapture( + { recordings, notes, env }, + { chunks: [audio], mime, startedAt: env.now(), endedAt: new Date(env.now().getTime() + 47_000), interrupted: false }, + ); + await new QueueRunner({ recordings, notes, transcriber, env }).tick(); + + expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); + expect(transcriber.calls).toHaveLength(1); + expect(transcriber.calls[0].mime).toBe(mime); + expect(Buffer.from(transcriber.calls[0].audio).equals(Buffer.from(audio))).toBe(true); + expect(notes.files.get(path!)).toBe(expected); + }); + } +}); diff --git a/tests/naming.test.ts b/tests/naming.test.ts new file mode 100644 index 0000000..8315bf7 --- /dev/null +++ b/tests/naming.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + audioPath, + extFromMime, + formatDuration, + formatLocalTime, + makeId, + notePath, +} from '../src/core/naming'; + +const T = new Date('2026-09-27T11:15:03.000Z'); // 14:15:03 по Москве + +function seq(...values: number[]): () => number { + return () => values.shift() ?? 0; +} + +describe('test environment', () => { + it('runs in UTC+3', () => { + expect(T.getHours()).toBe(14); + }); +}); + +describe('makeId', () => { + it('uses UTC time and 4 hex chars', () => { + expect(makeId(T, () => 0)).toBe('20260927-111503-0000'); + expect(makeId(T, seq(0.99, 0.5, 0.0625, 0.1))).toBe('20260927-111503-f811'); + }); + + it('clamps random() === 1 to f', () => { + expect(makeId(T, () => 1)).toBe('20260927-111503-ffff'); + }); + + it('does not depend on the local date', () => { + const lateEvening = new Date('2026-09-27T22:30:00.000Z'); // 01:30 28-го по Москве + expect(makeId(lateEvening, () => 0)).toBe('20260927-223000-0000'); + expect(notePath(lateEvening, 1)).toBe('voice/inbox/2026-09-28 01-30-00.md'); + }); +}); + +describe('extFromMime', () => { + it.each([ + ['audio/mp4', 'm4a'], + ['audio/webm', 'webm'], + ['audio/webm;codecs=opus', 'webm'], + ['AUDIO/MP4', 'm4a'], + ['audio/ogg; codecs=opus', 'ogg'], + ['audio/x-foo', 'bin'], + ['', 'bin'], + ])('%s → %s', (mime, ext) => { + expect(extFromMime(mime)).toBe(ext); + }); +}); + +describe('paths', () => { + it('audioPath', () => { + expect(audioPath('20260927-111503-0000', 'audio/mp4')).toBe('voice/source/20260927-111503-0000.m4a'); + }); + + it('notePath uses local time and suffix from 2', () => { + expect(notePath(T, 1)).toBe('voice/inbox/2026-09-27 14-15-03.md'); + expect(notePath(T, 2)).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); + expect(notePath(T, 3)).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); + }); +}); + +describe('formatting', () => { + it.each([ + [0, '0:00'], + [47_000, '0:47'], + [125_400, '2:05'], + [-5, '0:00'], + ])('formatDuration(%d) = %s', (ms, text) => { + expect(formatDuration(ms)).toBe(text); + }); + + it('formatLocalTime', () => { + expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17'); + }); +}); diff --git a/tests/note-body.test.ts b/tests/note-body.test.ts new file mode 100644 index 0000000..9f07a94 --- /dev/null +++ b/tests/note-body.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields, renderNote } from '../src/core/note'; +import { NO_SPEECH, type VoiceRecord } from '../src/core/record'; + +const saved: VoiceRecord = { + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, +}; +const FM = + '---\n' + + 'voice-id: "20260927-111503-0000"\n' + + 'voice-status: "saved"\n' + + 'voice-device: "d1"\n' + + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"\n' + + 'voice-mime: "audio/mp4"\n' + + 'voice-attempts: 0\n' + + '---\n'; +const REGION = '%%voice-note:20260927-111503-0000%%\n> [!info] Распознаётся…\n%%/voice-note%%\n'; + +const inserted: VoiceRecord = { ...saved, status: 'inserted' }; + +describe('initialNote', () => { + it('builds frontmatter, embed and status region', () => { + expect(initialNote(saved, 47_000)).toBe(`${FM}![[voice/source/20260927-111503-0000.m4a]]\n\n${REGION}`); + }); + + it('adds interrupted line above the region', () => { + const note = initialNote({ ...saved, interrupted: true }, 47_000); + expect(note).toContain('voice-interrupted: true\n---\n'); + expect(note).toContain( + '![[voice/source/20260927-111503-0000.m4a]]\n\n> [!note] Запись прервана, сохранено 0:47\n\n%%voice-note:', + ); + }); +}); + +describe('renderNote: status region', () => { + it('retry shows local time and attempt', () => { + const r: VoiceRecord = { ...saved, status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:17:03.000Z', error: 't' }; + const out = renderNote(initialNote(saved, 0), r); + expect(out).toContain('> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)\n'); + expect(out).not.toContain('Распознаётся'); + expect(readVoiceFields(out)['voice-status']).toBe('retry'); + }); + + it('failed shows reason and the command', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: '401 Unauthorized' }); + expect(out).toContain('> [!warning] Не распознано: 401 Unauthorized. Команда «Повторить распознавание»\n'); + }); + + it('no-speech is not an error', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: NO_SPEECH }); + expect(out).toContain('> [!info] В записи не распознано речи\n'); + expect(out).not.toMatch(/ошибк|Не распознано:|Повторить/); + }); + + it('collapses multi-line error into one callout line', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: 'a\n\nb' }); + expect(out).toContain('> [!warning] Не распознано: a b. Команда'); + }); + + it('without markers updates frontmatter only', () => { + const content = `${FM}моя заметка\n`; + const out = renderNote(content, { ...saved, status: 'sent', sentAt: 'x' }); + expect(out).toBe(content.replace('voice-status: "saved"', 'voice-status: "sent"').replace('voice-attempts: 0\n', 'voice-attempts: 0\nvoice-sent-at: "x"\n')); + }); +}); + +describe('renderNote: transcript', () => { + it('replaces the region and keeps user text around it', () => { + const content = `${initialNote(saved, 0)}купить молоко\n`; + expect(renderNote(content, inserted, ' Привет, мир.\n')).toBe( + `${FM.replace('"saved"', '"inserted"')}![[voice/source/20260927-111503-0000.m4a]]\n\nПривет, мир.\nкупить молоко\n`, + ); + }); + + it('keeps the interrupted line', () => { + const r = { ...saved, interrupted: true }; + const out = renderNote(initialNote(r, 47_000), { ...r, status: 'inserted' }, 'текст'); + expect(out).toContain('> [!note] Запись прервана, сохранено 0:47\n\nтекст\n'); + }); + + it('appends at the end when markers were removed', () => { + const content = `${FM}заметка без маркеров\n\n\n`; + expect(renderNote(content, inserted, 'текст')).toBe( + `${FM.replace('"saved"', '"inserted"')}заметка без маркеров\n\nтекст\n`, + ); + }); + + it('is a no-op when already inserted', () => { + const once = renderNote(initialNote(saved, 0), inserted, 'первый'); + expect(renderNote(once, inserted, 'второй')).toBe(once); + }); + + it('inserts special replacement patterns verbatim', () => { + const text = "цена $& и $' и $1 и %%voice-note%% и ---"; + expect(renderNote(initialNote(saved, 0), inserted, text)).toContain(`\n\n${text}\n`); + }); + + it('keeps user properties in place', () => { + const content = initialNote(saved, 0).replace('---\nvoice-id', '---\ntags: [idea]\nvoice-id'); + const out = renderNote(content, inserted, 'x'); + expect(out.startsWith('---\ntags: [idea]\nvoice-id: "20260927-111503-0000"\nvoice-status: "inserted"\n')).toBe(true); + }); +}); diff --git a/tests/note-frontmatter.test.ts b/tests/note-frontmatter.test.ts new file mode 100644 index 0000000..ff390d9 --- /dev/null +++ b/tests/note-frontmatter.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { parseRecord, patchFrontmatter, readVoiceFields, recordToFields } from '../src/core/note'; +import type { VoiceRecord } from '../src/core/record'; + +const rec: VoiceRecord = { + id: '20260927-111503-0000', + status: 'retry', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/webm;codecs=opus', + attempts: 2, + nextAttempt: '2026-09-27T11:17:03.000Z', + error: 'timeout', + interrupted: true, +}; + +describe('recordToFields + patchFrontmatter', () => { + it('writes keys in fixed order with quoted strings', () => { + expect(patchFrontmatter('', recordToFields(rec))).toBe( + [ + '---', + 'voice-id: "20260927-111503-0000"', + 'voice-status: "retry"', + 'voice-device: "d1"', + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"', + 'voice-mime: "audio/webm;codecs=opus"', + 'voice-attempts: 2', + 'voice-next-attempt: "2026-09-27T11:17:03.000Z"', + 'voice-error: "timeout"', + 'voice-interrupted: true', + '---', + '', + ].join('\n'), + ); + }); + + it('round-trips through readVoiceFields + parseRecord', () => { + const content = patchFrontmatter('', recordToFields(rec)); + expect(parseRecord(readVoiceFields(content))).toEqual(rec); + }); + + it('round-trips hostile error text as a single line', () => { + const hostile = { ...rec, error: 'Error: "bad"\nline two: x \\ y — ошибка' }; + const content = patchFrontmatter('', recordToFields(hostile)); + const errorLines = content.split('\n').filter((l) => l.startsWith('voice-error:')); + expect(errorLines).toHaveLength(1); + expect(content.split('\n').filter((l) => l.startsWith('line two'))).toHaveLength(0); + expect(parseRecord(readVoiceFields(content))).toEqual(hostile); + }); +}); + +describe('patchFrontmatter', () => { + const user = '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\n---\nbody\n'; + + it('replaces in place and keeps user keys and order', () => { + expect(patchFrontmatter(user, { 'voice-status': '"inserted"' })).toBe( + '---\ntitle: Идея\nvoice-status: "inserted"\ntags: [idea]\n---\nbody\n', + ); + }); + + it('appends new keys at the end of the block', () => { + expect(patchFrontmatter(user, { 'voice-sent-at': '"t"' })).toBe( + '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\nvoice-sent-at: "t"\n---\nbody\n', + ); + }); + + it('removes keys set to undefined, ignores absent ones', () => { + expect(patchFrontmatter(user, { 'voice-status': undefined, 'voice-error': undefined })).toBe( + '---\ntitle: Идея\ntags: [idea]\n---\nbody\n', + ); + }); + + it('creates a block when there is none, body untouched', () => { + expect(patchFrontmatter('body\n', { 'voice-id': '"x"' })).toBe('---\nvoice-id: "x"\n---\nbody\n'); + }); + + it('does not match a longer key with the same prefix', () => { + expect(patchFrontmatter('---\nvoice-idx: 1\n---\n', { 'voice-id': '"x"' })).toBe( + '---\nvoice-idx: 1\nvoice-id: "x"\n---\n', + ); + }); +}); + +describe('readVoiceFields', () => { + it('reads only the frontmatter block', () => { + const content = '---\nvoice-id: "a"\nother: 1\n---\nvoice-id: "b"\n'; + expect(readVoiceFields(content)).toEqual({ 'voice-id': 'a' }); + }); + + it('returns {} without frontmatter', () => { + expect(readVoiceFields('voice-id: "a"\n')).toEqual({}); + }); + + it('accepts plain and single-quoted values', () => { + expect(readVoiceFields("---\nvoice-id: abc\nvoice-mime: 'audio/mp4'\n---\n")).toEqual({ + 'voice-id': 'abc', + 'voice-mime': 'audio/mp4', + }); + }); +}); + +describe('parseRecord', () => { + const typed = { + 'voice-id': '20260927-111503-0000', + 'voice-status': 'saved', + 'voice-device': 'd1', + 'voice-audio': '[[voice/source/20260927-111503-0000.m4a]]', + 'voice-mime': 'audio/mp4', + 'voice-attempts': 0, + }; + + it('accepts metadataCache-style typed values', () => { + expect(parseRecord({ ...typed, 'voice-attempts': 3, 'voice-interrupted': true })).toEqual({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 3, + interrupted: true, + }); + }); + + it('defaults missing attempts to 0', () => { + const { 'voice-attempts': _omit, ...rest } = typed; + expect(parseRecord(rest)?.attempts).toBe(0); + }); + + it.each([ + ['missing id', { ...typed, 'voice-id': undefined }], + ['bad status', { ...typed, 'voice-status': 'foo' }], + ['negative attempts', { ...typed, 'voice-attempts': -1 }], + ['non-numeric attempts', { ...typed, 'voice-attempts': 'abc' }], + ['object audio', { ...typed, 'voice-audio': { a: 1 } }], + ])('returns null for %s', (_name, fields) => { + expect(parseRecord(fields)).toBeNull(); + }); +}); diff --git a/tests/queue-commands.test.ts b/tests/queue-commands.test.ts new file mode 100644 index 0000000..53440b1 --- /dev/null +++ b/tests/queue-commands.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { QueueRunner } from '../src/core/queue'; +import type { VoiceRecord } from '../src/core/record'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; + +const T0 = new Date('2026-09-27T11:15:03.000Z'); + +function setup(steps: Step[] = []) { + const env = new TestEnv(T0); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber(steps); + const runner = new QueueRunner({ recordings, notes, transcriber, env }); + return { env, notes, recordings, transcriber, runner }; +} +type Ctx = ReturnType; + +function rec(id: string, over: Partial = {}): VoiceRecord { + return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; +} + +function seed(ctx: Ctx, record: VoiceRecord): string { + const path = `voice/inbox/${record.id}.md`; + ctx.notes.files.set(path, initialNote(record, 0)); + ctx.recordings.files.set(record.audio, new Uint8Array([1]).buffer); + return path; +} + +const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); + +describe('QueueRunner.retry', () => { + it('failed → sent again with attempts from zero', async () => { + const ctx = setup([{ ok: false, kind: 'temporary', message: 'offline' }]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: '401' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' }); + }); + + it('retry state: sends now without waiting', async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { status: 'retry', attempts: 2, nextAttempt: '2026-09-27T12:00:00.000Z', error: 't' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-attempts': '0' }); + }); + + it("claims another device's failed record", async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e', device: 'iphone' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device' }); + }); + + it.each([ + ['saved', {}], + ['inserted', {}], + ['sent', { sentAt: T0.toISOString() }], + ] as const)('%s: no change, no request', async (status, over) => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status, ...over })); + const before = ctx.notes.files.get(path); + expect(await ctx.runner.retry(path)).toBe(false); + expect(ctx.notes.files.get(path)).toBe(before); + expect(ctx.transcriber.calls).toHaveLength(0); + }); + + it('missing note → false', async () => { + expect(await setup().runner.retry('voice/inbox/nope.md')).toBe(false); + }); + + it('after dispose(): no change, no request', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e' })); + const before = ctx.notes.files.get(path); + ctx.runner.dispose(); + expect(await ctx.runner.retry(path)).toBe(false); + expect(ctx.notes.files.get(path)).toBe(before); + expect(ctx.transcriber.calls).toHaveLength(0); + }); +}); + +describe('QueueRunner.claimAll', () => { + it("takes the phone's retry and sends it now, keeping attempts", async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { device: 'iphone', status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); + expect(await ctx.runner.claimAll()).toBe(1); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device', 'voice-attempts': '2' }); + }); + + it('includes stale sent, own retry and own stale sent; leaves failed, inserted and fresh sent untouched', async () => { + const ctx = setup([ + { ok: true, text: 'w' }, + { ok: true, text: 'x' }, + { ok: true, text: 'y' }, + { ok: true, text: 'z' }, + ]); + const o = { device: 'iphone' }; + const saved = seed(ctx, rec('a', o)); + const stale = seed(ctx, rec('b', { ...o, status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const fresh = seed(ctx, rec('c', { ...o, status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const failed = seed(ctx, rec('d', { ...o, status: 'failed', attempts: 5, error: 'e' })); + const done = seed(ctx, rec('e', { ...o, status: 'inserted' })); + const ownRetry = seed(ctx, rec('f', { status: 'retry', attempts: 1, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); + const ownStale = seed(ctx, rec('g', { status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const ownFresh = seed(ctx, rec('h', { status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const untouched = [fresh, failed, done, ownFresh].map((p) => ctx.notes.files.get(p)); + + expect(await ctx.runner.claimAll()).toBe(2); + expect(fields(ctx, saved)['voice-status']).toBe('inserted'); + expect(fields(ctx, stale)['voice-status']).toBe('inserted'); + expect(fields(ctx, ownRetry)['voice-status']).toBe('inserted'); + expect(fields(ctx, ownStale)['voice-status']).toBe('inserted'); + expect([fresh, failed, done, ownFresh].map((p) => ctx.notes.files.get(p))).toEqual(untouched); + }); + + it('nothing to claim → 0', async () => { + expect(await setup().runner.claimAll()).toBe(0); + }); +}); diff --git a/tests/queue.test.ts b/tests/queue.test.ts new file mode 100644 index 0000000..ecee73d --- /dev/null +++ b/tests/queue.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest'; +import { initialNote, patchFrontmatter, readVoiceFields } from '../src/core/note'; +import { QueueRunner } from '../src/core/queue'; +import type { VoiceRecord } from '../src/core/record'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; + +const T0 = new Date('2026-09-27T11:15:03.000Z'); +const ok = (text: string): Step => ({ ok: true, text }); +const temporary = (message: string): Step => ({ ok: false, kind: 'temporary', message }); +const permanent = (message: string): Step => ({ ok: false, kind: 'permanent', message }); + +function setup(steps: Step[] = []) { + const env = new TestEnv(T0); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber(steps); + const runner = new QueueRunner({ recordings, notes, transcriber, env }); + return { env, notes, recordings, transcriber, runner }; +} +type Ctx = ReturnType; + +function rec(id: string, over: Partial = {}): VoiceRecord { + return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; +} + +function seed(ctx: Ctx, record: VoiceRecord, path = `voice/inbox/${record.id}.md`): string { + ctx.notes.files.set(path, initialNote(record, 0)); + ctx.recordings.files.set(record.audio, new Uint8Array([1, 2, 3]).buffer); + return path; +} + +const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); + +describe('QueueRunner.tick', () => { + it('sends a saved record and inserts the trimmed transcript', async () => { + const ctx = setup([ok(' привет ')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + + expect(ctx.transcriber.calls).toHaveLength(1); + expect(new Uint8Array(ctx.transcriber.calls[0].audio)).toEqual(new Uint8Array([1, 2, 3])); + expect(ctx.transcriber.calls[0].mime).toBe('audio/mp4'); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + expect(fields(ctx, path)['voice-sent-at']).toBeUndefined(); + expect(ctx.notes.files.get(path)).toContain('\n\nпривет\n'); + expect(ctx.notes.files.get(path)).not.toContain('%%voice-note'); + }); + + it('network lost, then back: retry at +30s, then inserted', async () => { + const ctx = setup([temporary('offline'), ok('текст')]); + const path = seed(ctx, rec('a')); + + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ + 'voice-status': 'retry', + 'voice-attempts': '1', + 'voice-next-attempt': '2026-09-27T11:15:33.000Z', + 'voice-error': 'offline', + }); + expect(ctx.notes.files.get(path)).toContain('Не удалось отправить, повтор в 14:15 (попытка 1/5)'); + + ctx.env.advance(17_000); // 11:15:20 — рано + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + + ctx.env.advance(13_000); // 11:15:33 — пора + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(2); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); + + it('five temporary failures → failed with the last message', async () => { + const ctx = setup([temporary('e1'), temporary('e2'), temporary('e3'), temporary('e4'), temporary('e5')]); + const path = seed(ctx, rec('a')); + for (let i = 0; i < 5; i++) { + await ctx.runner.tick(); + ctx.env.advance(3_600_000); + } + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(5); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '5', 'voice-error': 'e5' }); + }); + + it('permanent error → failed at once', async () => { + const ctx = setup([permanent('401 Unauthorized')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '1' }); + expect(ctx.notes.files.get(path)).toContain('Не распознано: 401 Unauthorized'); + }); + + it('whitespace-only transcript → failed no-speech', async () => { + const ctx = setup([ok(' \n\t ')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-error': 'no-speech' }); + expect(ctx.notes.files.get(path)).toContain('> [!info] В записи не распознано речи'); + }); + + it('exception from transcriber is a temporary failure', async () => { + const ctx = setup([new Error('socket hang up')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-error': 'socket hang up' }); + }); + + it('missing audio file is a temporary failure', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.recordings.files.clear(); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, path)['voice-status']).toBe('retry'); + }); + + it("does not send other devices' records", async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { device: 'iphone' })); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, path)['voice-status']).toBe('saved'); + }); + + it('own sent older than 10 min → retry without a request; younger untouched', async () => { + const ctx = setup([]); + const stale = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const fresh = seed(ctx, rec('b', { status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const before = ctx.notes.files.get(fresh); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, stale)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1', 'voice-error': 'отправка прервана' }); + expect(ctx.notes.files.get(fresh)).toBe(before); + }); + + it('processes records one at a time, oldest first', async () => { + let inFlight = 0; + let maxInFlight = 0; + const order: string[] = []; + const slow: Step = async (audio) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + order.push(String(new Uint8Array(audio)[0])); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight--; + return { ok: true, text: 't' }; + }; + const ctx = setup([slow, slow, slow]); + for (const [id, byte] of [['c', 3], ['a', 1], ['b', 2]] as const) { + seed(ctx, rec(id)); + ctx.recordings.files.set(`voice/source/${id}.m4a`, new Uint8Array([byte]).buffer); + } + await ctx.runner.tick(); + expect(maxInFlight).toBe(1); + expect(order).toEqual(['1', '2', '3']); + }); + + it('tick during tick runs exactly one extra pass', async () => { + const ctx: Ctx = setup(); + ctx.transcriber.steps.push(async () => { + void ctx.runner.tick(); + void ctx.runner.tick(); + void ctx.runner.tick(); + return { ok: true, text: 't' }; + }); + seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(ctx.notes.listCalls).toBe(2); + expect(ctx.transcriber.calls).toHaveLength(1); + }); + + it('note deleted during the request: result dropped, next record processed', async () => { + const ctx: Ctx = setup(); + const first = seed(ctx, rec('a')); + const second = seed(ctx, rec('b')); + ctx.transcriber.steps.push(async () => { + ctx.notes.files.delete(first); + return { ok: true, text: 'один' }; + }, ok('два')); + await ctx.runner.tick(); + expect(ctx.notes.files.has(first)).toBe(false); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('note deleted before send: skipped without a request', async () => { + const ctx = setup([ok('два')]); + const second = seed(ctx, rec('b')); + ctx.notes.staleList = [ + { path: 'voice/inbox/gone.md', frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }, + { path: second, frontmatter: fields(ctx, second) }, + ]; + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('stale list says saved, file says sent: no second request', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:15:00.000Z' })); + ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; + const before = ctx.notes.files.get(path); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(ctx.notes.files.get(path)).toBe(before); + }); + + it('stale list says saved, file says inserted: untouched', async () => { + const ctx = setup([ok('x')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + const done = ctx.notes.files.get(path); + ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(ctx.notes.files.get(path)).toBe(done); + }); + + it('finds records outside voice/inbox', async () => { + const ctx = setup([ok('x')]); + const path = seed(ctx, rec('a'), 'projects/перенесённая.md'); + await ctx.runner.tick(); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); + + it('ignores notes with a broken status', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.notes.files.set(path, ctx.notes.files.get(path)!.replace('"saved"', '"foo"')); + await expect(ctx.runner.tick()).resolves.toBeUndefined(); + expect(ctx.transcriber.calls).toHaveLength(0); + }); + + it('dispose aborts the signal given to the transcriber', async () => { + const ctx = setup([ok('x')]); + seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(ctx.transcriber.calls[0].signal.aborted).toBe(false); + ctx.runner.dispose(); + expect(ctx.transcriber.calls[0].signal.aborted).toBe(true); + }); + + it('dispose during a request stops the pass without touching later due records', async () => { + const ctx = setup(); + const first = seed(ctx, rec('a')); + const second = seed(ctx, rec('b')); + const beforeSecond = ctx.notes.files.get(second); + ctx.transcriber.steps.push(async () => { + ctx.runner.dispose(); + throw new Error('aborted'); + }); + + await ctx.runner.tick(); + + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, first)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' }); + expect(fields(ctx, second)['voice-status']).toBe('saved'); + expect(ctx.notes.files.get(second)).toBe(beforeSecond); + + const listCallsBefore = ctx.notes.listCalls; + await ctx.runner.tick(); + expect(ctx.notes.listCalls).toBe(listCallsBefore); + }); + + it('one record throwing does not block the rest; tick rejects after the pass completes', async () => { + const ctx = setup([ok('два')]); + const first = seed(ctx, rec('a')); + const second = seed(ctx, rec('b')); + const boom = new Error('boom'); + const originalProcess = ctx.notes.process.bind(ctx.notes); + ctx.notes.process = async (path, fn) => { + if (path === first) throw boom; + return originalProcess(path, fn); + }; + + await expect(ctx.runner.tick()).rejects.toBe(boom); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('post-request guard: result is dropped if another device already re-sent the record', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.transcriber.steps.push(async () => { + const current = ctx.notes.files.get(path)!; + ctx.notes.files.set( + path, + patchFrontmatter(current, { 'voice-device': '"iphone"', 'voice-sent-at': '"2026-09-27T11:16:00.000Z"' }), + ); + return { ok: true, text: 'x' }; + }); + + await ctx.runner.tick(); + + expect(fields(ctx, path)).toMatchObject({ + 'voice-status': 'sent', + 'voice-device': 'iphone', + 'voice-sent-at': '2026-09-27T11:16:00.000Z', + }); + }); + + it('sent status and sentAt are on disk while the request is in flight', async () => { + const ctx = setup(); + const path = seed(ctx, rec('a')); + ctx.transcriber.steps.push(async () => { + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'sent', 'voice-sent-at': T0.toISOString() }); + return { ok: true, text: 'x' }; + }); + + await ctx.runner.tick(); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); +}); diff --git a/tests/record.test.ts b/tests/record.test.ts new file mode 100644 index 0000000..ddc9592 --- /dev/null +++ b/tests/record.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + BACKOFF_MS, + InvalidTransition, + MAX_ATTEMPTS, + transition, + type RecordEvent, + type Status, + type VoiceRecord, +} from '../src/core/record'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const base: VoiceRecord = Object.freeze({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, +}); +const sent = (over: Partial = {}): VoiceRecord => + Object.freeze({ ...base, status: 'sent' as const, sentAt: NOW.toISOString(), ...over }); + +describe('transition', () => { + it('send: saved → sent with sentAt', () => { + expect(transition(base, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + sentAt: '2026-09-27T11:15:03.000Z', + }); + }); + + it('send: retry → sent, drops nextAttempt, keeps attempts and error', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 1, nextAttempt: 'x', error: 'timeout' }); + expect(transition(r, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + attempts: 1, + error: 'timeout', + sentAt: NOW.toISOString(), + }); + }); + + it('success: sent → inserted, clears transient keys, keeps attempts', () => { + expect(transition(sent({ error: 'old', attempts: 2 }), { type: 'success' }, NOW)).toEqual({ + ...base, + status: 'inserted', + attempts: 2, + }); + }); + + it('temporary: backoff 30s, 2m, 10m, 30m after failures 1..4', () => { + const got = [0, 1, 2, 3].map((a) => + transition(sent({ attempts: a }), { type: 'temporary', message: 'timeout' }, NOW), + ); + expect(got.map((r) => r.status)).toEqual(['retry', 'retry', 'retry', 'retry']); + expect(got.map((r) => r.attempts)).toEqual([1, 2, 3, 4]); + expect(got.map((r) => Date.parse(r.nextAttempt!) - NOW.getTime())).toEqual([...BACKOFF_MS]); + expect(got.every((r) => r.sentAt === undefined && r.error === 'timeout')).toBe(true); + }); + + it('temporary: fifth failure → failed with last message', () => { + expect( + transition(sent({ attempts: MAX_ATTEMPTS - 1 }), { type: 'temporary', message: 'e5' }, NOW), + ).toEqual({ ...base, status: 'failed', attempts: 5, error: 'e5' }); + }); + + it('permanent: sent → failed immediately', () => { + expect(transition(sent(), { type: 'permanent', message: '401 Unauthorized' }, NOW)).toEqual({ + ...base, + status: 'failed', + attempts: 1, + error: '401 Unauthorized', + }); + }); + + it('retry-now: failed → saved, attempts 0, device reassigned', () => { + const r = Object.freeze({ ...base, status: 'failed' as const, attempts: 5, error: '401' }); + expect(transition(r, { type: 'retry-now', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + it('retry-now: retry → saved, drops nextAttempt', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'retry-now', device: 'd1' }, NOW)).toEqual(base); + }); + + it('claim: retry → saved, new device, attempts kept', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'claim', device: 'd2' }, NOW)).toEqual({ + ...base, + device: 'd2', + attempts: 2, + error: 't', + }); + }); + + it('claim: sent → saved, drops sentAt', () => { + expect(transition(sent(), { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + const invalid: [Status, RecordEvent][] = [ + ['inserted', { type: 'send' }], + ['failed', { type: 'send' }], + ['sent', { type: 'send' }], + ['saved', { type: 'success' }], + ['retry', { type: 'success' }], + ['saved', { type: 'temporary', message: 'x' }], + ['inserted', { type: 'permanent', message: 'x' }], + ['saved', { type: 'retry-now', device: 'd1' }], + ['inserted', { type: 'retry-now', device: 'd1' }], + ['failed', { type: 'claim', device: 'd2' }], + ['inserted', { type: 'claim', device: 'd2' }], + ]; + + it.each(invalid)('rejects %s + %o', (status, event) => { + const r = Object.freeze({ ...base, status }); + expect(() => transition(r, event, NOW)).toThrow(InvalidTransition); + }); +}); diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts new file mode 100644 index 0000000..74cea4b --- /dev/null +++ b/tests/schedule.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import type { VoiceRecord } from '../src/core/record'; +import { claimable, due, isStaleSent, staleOwn, type Located } from '../src/core/schedule'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString(); +const later = (ms: number) => new Date(NOW.getTime() + ms).toISOString(); + +function item(id: string, over: Partial = {}): Located { + return { + path: `voice/inbox/${id}.md`, + record: { id, status: 'saved', device: 'me', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }, + }; +} +const ids = (xs: Located[]) => xs.map((x) => x.record.id); + +describe('due', () => { + it('takes own saved and retry whose time has come, oldest id first', () => { + const items = [ + item('c', { status: 'retry', nextAttempt: NOW.toISOString() }), + item('a'), + item('b', { status: 'retry', nextAttempt: later(1) }), + item('d', { device: 'other' }), + item('e', { status: 'sent', sentAt: ago(0) }), + item('f', { status: 'failed' }), + item('g', { status: 'inserted' }), + ]; + expect(ids(due(items, NOW, 'me'))).toEqual(['a', 'c']); + }); + + it('unparsable nextAttempt is due', () => { + expect(ids(due([item('a', { status: 'retry', nextAttempt: 'завтра' })], NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('isStaleSent', () => { + it.each([ + ['exactly 10 min', { status: 'sent' as const, sentAt: ago(600_000) }, false], + ['10 min + 1 ms', { status: 'sent' as const, sentAt: ago(600_001) }, true], + ['2 min', { status: 'sent' as const, sentAt: ago(120_000) }, false], + ['no sentAt', { status: 'sent' as const }, true], + ['garbage sentAt', { status: 'sent' as const, sentAt: 'x' }, true], + ['not sent', { status: 'retry' as const, sentAt: ago(3_600_000) }, false], + ])('%s → %s', (_name, over, expected) => { + expect(isStaleSent(item('a', over).record, NOW)).toBe(expected); + }); +}); + +describe('staleOwn', () => { + it('only own stale sent', () => { + const items = [ + item('a', { status: 'sent', sentAt: ago(700_000) }), + item('b', { status: 'sent', sentAt: ago(700_000), device: 'other' }), + item('c', { status: 'sent', sentAt: ago(1_000) }), + ]; + expect(ids(staleOwn(items, NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('claimable', () => { + it("others' saved, retry and stale sent only", () => { + const o = { device: 'other' }; + const items = [ + item('a', o), + item('b', { ...o, status: 'retry', nextAttempt: later(1_200_000) }), + item('c', { ...o, status: 'sent', sentAt: ago(700_000) }), + item('d', { ...o, status: 'sent', sentAt: ago(1_000) }), + item('e', { ...o, status: 'failed' }), + item('f', { ...o, status: 'inserted' }), + item('g'), + ]; + expect(ids(claimable(items, NOW, 'me'))).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..72f87ae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "noImplicitOverride": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8aca30c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// Детерминированное локальное время для имён заметок и строк статуса: +// UTC+3 без перехода на летнее время. Воркеры vitest наследуют env. +process.env.TZ = 'Europe/Moscow'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +});