feat: core recording lifecycle (#1) #2

Merged
aep merged 22 commits from feat/1-core-recording-lifecycle into main 2026-09-27 18:24:26 +03:00
9 changed files with 2290 additions and 0 deletions
Showing only changes of commit 7cd3811ead - Show all commits

56
esbuild.config.mjs Normal file
View 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
View 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
}

2102
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View 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"
}
}

38
src/core/ports.ts Normal file
View 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;
}

6
src/main.ts Normal file
View File

@ -0,0 +1,6 @@
import { Plugin } from 'obsidian';
// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала.
export default class VoiceNotePlugin extends Plugin {
override async onload(): Promise<void> {}
}

View 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([]);
});
});

15
tsconfig.json Normal file
View 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
View 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'],
},
});