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