Merge pull request 'feat: core recording lifecycle (#1)' (#2) from feat/1-core-recording-lifecycle into main
Reviewed-on: #2
This commit is contained in:
commit
001ba8af80
3
.gitignore
vendored
3
.gitignore
vendored
@ -4,6 +4,5 @@ main.js
|
||||
data.json
|
||||
.superpowers/
|
||||
.bounded
|
||||
tests/fixtures/*.m4a
|
||||
tests/fixtures/*.webm
|
||||
.DS_Store
|
||||
.claude/worktrees/
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
|
||||
## Тесты
|
||||
- Golden-тесты: `tests/fixtures/<name>.m4a` + `tests/fixtures/<name>.txt` (эталонный транскрипт). Провайдер в тестах мокается; golden-файлы проверяют формирование заметки и обработку ответа, а не сам сервис.
|
||||
- Тестовый vault: `<путь укажите>` — синхронизируется между Mac и телефоном.
|
||||
- Тестовый vault: `/Users/andrey.epifancev/Library/Mobile Documents/iCloud~md~obsidian/Documents/voice-note-test` — синхронизируется между Mac и телефоном.
|
||||
|
||||
## Первая подсистема
|
||||
Жизненный цикл записи и очередь (core), затем провайдер, затем обёртка. Тестовая инфраструктура — Task 1 первого плана.
|
||||
|
||||
2528
docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md
Normal file
2528
docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md
Normal file
File diff suppressed because it is too large
Load Diff
56
esbuild.config.mjs
Normal file
56
esbuild.config.mjs
Normal file
@ -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();
|
||||
}
|
||||
9
manifest.json
Normal file
9
manifest.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "voice-note",
|
||||
"name": "Voice Note",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Голосовые заметки: запись, распознавание внешним сервисом, текст в заметку.",
|
||||
"author": "Andrey Epifancev",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
2
openspec/changes/core-recording-lifecycle/.openspec.yaml
Normal file
2
openspec/changes/core-recording-lifecycle/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-27
|
||||
3
openspec/changes/core-recording-lifecycle/README.md
Normal file
3
openspec/changes/core-recording-lifecycle/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# core-recording-lifecycle
|
||||
|
||||
Core: жизненный цикл голосовой записи и очередь отправки (issue #1)
|
||||
137
openspec/changes/core-recording-lifecycle/design.md
Normal file
137
openspec/changes/core-recording-lifecycle/design.md
Normal file
@ -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<void>;
|
||||
read(path: string): Promise<ArrayBuffer>;
|
||||
}
|
||||
interface NoteStore {
|
||||
exists(path: string): Promise<boolean>;
|
||||
create(path: string, content: string): Promise<void>;
|
||||
// Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter.
|
||||
// КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать
|
||||
// содержимое файлов (vault.read/cachedRead/adapter). Вызывается на каждом tick,
|
||||
// в т.ч. на телефоне. Данные могут отставать от диска — core перепроверяет
|
||||
// состояние внутри process().
|
||||
list(): Promise<{ path: string; frontmatter: Record<string, unknown> }[]>;
|
||||
// атомарное чтение-изменение-запись; false, если заметки нет
|
||||
process(path: string, fn: (content: string) => string): Promise<boolean>;
|
||||
}
|
||||
interface Transcriber {
|
||||
transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult>;
|
||||
}
|
||||
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` зелёный на чистом клоне. Если для эталона `<name>.note.md` нет аудиофайла, golden-тест MUST падать с сообщением «нет аудиофикстуры tests/fixtures/<name>.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 минут (лишний запрос).**
|
||||
32
openspec/changes/core-recording-lifecycle/proposal.md
Normal file
32
openspec/changes/core-recording-lifecycle/proposal.md
Normal file
@ -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.
|
||||
@ -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/` появился файл `<id>.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** после текущего прохода выполняется ровно один дополнительный проход
|
||||
@ -0,0 +1,63 @@
|
||||
# Spec Delta: voice-note-format
|
||||
|
||||
## Purpose
|
||||
|
||||
Определяет, где и под какими именами лежат аудиофайлы и заметки голосовых записей, как в заметке хранится состояние записи и как плагин обновляет заметку, не затрагивая текст пользователя.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Идентификатор и имена файлов
|
||||
|
||||
Идентификатор записи MUST иметь вид `YYYYMMDD-HHMMSS-xxxx` (время начала записи в UTC и 4 случайных шестнадцатеричных символа в нижнем регистре). Аудио MUST сохраняться как `voice/source/<id>.<ext>`, где расширение определяется по 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:<id>%%` и `%%/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/<name>.<audio-ext>` + `<name>.txt` с эталоном `<name>.note.md` полный путь «захват → очередь → вставка» с провайдером, отвечающим содержимым `<name>.txt`, при фиксированных времени, идентификаторе устройства и источнике случайности MUST давать заметку, побайтно совпадающую с `<name>.note.md`. Провайдер MUST получать байты аудиофикстуры без изменений и исходный mime.
|
||||
|
||||
#### Scenario: test-01
|
||||
- **WHEN** прогоняется фикстура `test-01.m4a` с mime `audio/mp4` и ответом из `test-01.txt`
|
||||
- **THEN** итоговая заметка совпадает с `test-01.note.md`
|
||||
2102
package-lock.json
generated
Normal file
2102
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
50
src/core/capture.ts
Normal file
50
src/core/capture.ts
Normal file
@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
48
src/core/naming.ts
Normal file
48
src/core/naming.ts
Normal file
@ -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<string, string> = {
|
||||
'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())}`;
|
||||
}
|
||||
196
src/core/note.ts
Normal file
196
src/core/note.ts
Normal file
@ -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<VoiceKey, string | undefined> {
|
||||
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<string, string> {
|
||||
const lines = content.split('\n');
|
||||
const range = frontmatterRange(lines);
|
||||
const fields: Record<string, string> = {};
|
||||
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<string, unknown>): 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, string | undefined>): 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);
|
||||
}
|
||||
38
src/core/ports.ts
Normal file
38
src/core/ports.ts
Normal file
@ -0,0 +1,38 @@
|
||||
// Порты core. Реализации — в src/obsidian (позже) и tests/fakes.ts.
|
||||
|
||||
export interface RecordingStore {
|
||||
write(path: string, data: ArrayBuffer): Promise<void>;
|
||||
read(path: string): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
export interface ListedNote {
|
||||
path: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NoteStore {
|
||||
exists(path: string): Promise<boolean>;
|
||||
create(path: string, content: string): Promise<void>;
|
||||
// Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter.
|
||||
// КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать
|
||||
// содержимое файлов (vault.read / cachedRead / adapter). Вызывается на каждом
|
||||
// tick, в том числе на телефоне. Данные могут отставать от диска — core
|
||||
// перепроверяет состояние внутри process().
|
||||
list(): Promise<ListedNote[]>;
|
||||
// Атомарное чтение-изменение-запись. false, если заметки нет.
|
||||
process(path: string, fn: (content: string) => string): Promise<boolean>;
|
||||
}
|
||||
|
||||
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<TranscribeResult>;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
now(): Date;
|
||||
random(): number;
|
||||
readonly deviceId: string;
|
||||
}
|
||||
202
src/core/queue.ts
Normal file
202
src/core/queue.ts
Normal file
@ -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<void> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<VoiceRecord | null> {
|
||||
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<Located[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
87
src/core/record.ts
Normal file
87
src/core/record.ts
Normal file
@ -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);
|
||||
}
|
||||
45
src/core/schedule.ts
Normal file
45
src/core/schedule.ts
Normal file
@ -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)));
|
||||
}
|
||||
6
src/main.ts
Normal file
6
src/main.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Plugin } from 'obsidian';
|
||||
|
||||
// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала.
|
||||
export default class VoiceNotePlugin extends Plugin {
|
||||
override async onload(): Promise<void> {}
|
||||
}
|
||||
80
tests/capture.test.ts
Normal file
80
tests/capture.test.ts
Normal file
@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
34
tests/core-boundary.test.ts
Normal file
34
tests/core-boundary.test.ts
Normal file
@ -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([]);
|
||||
});
|
||||
});
|
||||
90
tests/fakes.ts
Normal file
90
tests/fakes.ts
Normal file
@ -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<string, ArrayBuffer>();
|
||||
|
||||
async write(path: string, data: ArrayBuffer): Promise<void> {
|
||||
this.files.set(path, data.slice(0));
|
||||
}
|
||||
|
||||
async read(path: string): Promise<ArrayBuffer> {
|
||||
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<string, string>();
|
||||
// Имитация отстающего metadataCache: если задано, list() возвращает это.
|
||||
staleList: ListedNote[] | null = null;
|
||||
listCalls = 0;
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return this.files.has(path);
|
||||
}
|
||||
|
||||
async create(path: string, content: string): Promise<void> {
|
||||
if (this.files.has(path)) throw new Error(`already exists: ${path}`);
|
||||
this.files.set(path, content);
|
||||
}
|
||||
|
||||
async list(): Promise<ListedNote[]> {
|
||||
this.listCalls++;
|
||||
if (this.staleList) return this.staleList;
|
||||
return [...this.files]
|
||||
.map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record<string, unknown> }))
|
||||
.filter((note) => 'voice-id' in note.frontmatter);
|
||||
}
|
||||
|
||||
async process(path: string, fn: (content: string) => string): Promise<boolean> {
|
||||
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<TranscribeResult>);
|
||||
|
||||
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<TranscribeResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
BIN
tests/fixtures/test-01.m4a
vendored
Normal file
BIN
tests/fixtures/test-01.m4a
vendored
Normal file
Binary file not shown.
11
tests/fixtures/test-01.note.md
vendored
Normal file
11
tests/fixtures/test-01.note.md
vendored
Normal file
@ -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, для сравнения между двух конкретных моделей.
|
||||
56
tests/golden.test.ts
Normal file
56
tests/golden.test.ts
Normal file
@ -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<string, string> = { 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
79
tests/naming.test.ts
Normal file
79
tests/naming.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
108
tests/note-body.test.ts
Normal file
108
tests/note-body.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
138
tests/note-frontmatter.test.ts
Normal file
138
tests/note-frontmatter.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
121
tests/queue-commands.test.ts
Normal file
121
tests/queue-commands.test.ts
Normal file
@ -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<typeof setup>;
|
||||
|
||||
function rec(id: string, over: Partial<VoiceRecord> = {}): 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);
|
||||
});
|
||||
});
|
||||
311
tests/queue.test.ts
Normal file
311
tests/queue.test.ts
Normal file
@ -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<typeof setup>;
|
||||
|
||||
function rec(id: string, over: Partial<VoiceRecord> = {}): 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');
|
||||
});
|
||||
});
|
||||
119
tests/record.test.ts
Normal file
119
tests/record.test.ts
Normal file
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
74
tests/schedule.test.ts
Normal file
74
tests/schedule.test.ts
Normal file
@ -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<VoiceRecord> = {}): 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']);
|
||||
});
|
||||
});
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
11
vitest.config.ts
Normal file
11
vitest.config.ts
Normal file
@ -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'],
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user