chore: scaffold build and vitest, add core ports and boundary guard
Refs #1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cbd76d5802
commit
7cd3811ead
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
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
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> {}
|
||||
}
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
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