Merge pull request 'fix: CRLF notes and configurable test vault path (#3)' (#8) from feat/3-crlf-vault-path into main

This commit is contained in:
aep 2026-10-14 23:53:04 +03:00
commit 4aad7e3255
5 changed files with 89 additions and 8 deletions

View File

@ -16,7 +16,7 @@
- Ключи API — в настройках плагина (`data.json`), в git не попадают.
## Команды
- `npm run dev` — сборка в watch-режиме в тестовый vault.
- `npm run dev` — сборка в watch-режиме в тестовый vault. Другой vault — через `VOICE_NOTE_VAULT=<путь> npm run dev`; если vault не найден, копирование пропускается с предупреждением.
- `npm test` — vitest, вся папка `tests/`. Зелёным считается весь набор.
- `npm run build` — продакшн-сборка `main.js`.
- `npm run typecheck` — два прохода: весь проект с Node-типами и `src/core` через `tsconfig.core.json` без Node-типов (`process`, `Buffer` в core — ошибка компиляции).

View File

@ -4,13 +4,19 @@ 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';
// Путь к тестовому vault: переменная окружения VOICE_NOTE_VAULT, иначе vault автора.
const VAULT =
process.env.VOICE_NOTE_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.onStart(() => {
if (!existsSync(VAULT)) console.warn(`[copy-to-vault] vault не найден, копирование пропущено: ${VAULT}. Задайте VOICE_NOTE_VAULT.`);
});
build.onEnd((result) => {
if (result.errors.length > 0 || !existsSync(VAULT)) return;
mkdirSync(PLUGIN_DIR, { recursive: true });

View File

@ -16,6 +16,18 @@ export const VOICE_KEYS = [
] as const;
export type VoiceKey = (typeof VOICE_KEYS)[number];
// Заметка с CRLF (правка в другом редакторе, Windows) читается и пишется в своём стиле:
// иначе '---\r' не опознаётся как граница frontmatter и патч дописывает второй блок.
const LINE_BREAK = /\r?\n/;
function eolOf(content: string): string {
return content.includes('\r\n') ? '\r\n' : '\n';
}
function withEol(text: string, eol: string): string {
return eol === '\n' ? text : text.replace(/\r?\n/g, eol);
}
// Строки — всегда в двойных кавычках: JSON-строка является валидной YAML-строкой
// и экранирует переводы строк, кавычки и двоеточия в одну строку.
function yaml(value: string | number): string {
@ -62,7 +74,7 @@ function unquote(raw: string): string {
const VOICE_LINE = /^(voice-[a-z-]+):[ \t]*(.*)$/;
export function readVoiceFields(content: string): Record<string, string> {
const lines = content.split('\n');
const lines = content.split(LINE_BREAK);
const range = frontmatterRange(lines);
const fields: Record<string, string> = {};
if (!range) return fields;
@ -111,7 +123,7 @@ export function parseRecord(fields: Record<string, unknown>): VoiceRecord | null
// Меняет только перечисленные ключи; undefined — удалить строку.
// Остальные строки frontmatter и их порядок не трогаются.
export function patchFrontmatter(content: string, fields: Record<string, string | undefined>): string {
const lines = content.split('\n');
const lines = content.split(LINE_BREAK);
let range = frontmatterRange(lines);
if (!range) {
lines.unshift('---', '---');
@ -140,7 +152,7 @@ export function patchFrontmatter(content: string, fields: Record<string, string
end++;
}
}
return lines.join('\n');
return lines.join(eolOf(content));
}
export const MARKER_END = '%%/voice-note%%';
@ -182,15 +194,16 @@ export function renderNote(content: string, record: VoiceRecord, text?: string):
if (record.status === 'inserted' && readVoiceFields(content)['voice-status'] === 'inserted') {
return content;
}
const eol = eolOf(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`;
const body = withEol((text ?? '').trim(), eol);
if (end === -1) return `${patched.replace(/\s+$/, '')}${eol}${eol}${body}${eol}`;
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);
return patched.slice(0, start) + withEol(region(record), eol) + patched.slice(end + MARKER_END.length);
}

View File

@ -106,3 +106,33 @@ describe('renderNote: transcript', () => {
expect(out.startsWith('---\ntags: [idea]\nvoice-id: "20260927-111503-0000"\nvoice-status: "inserted"\n')).toBe(true);
});
});
describe('renderNote: CRLF notes', () => {
const crlf = (s: string) => s.replace(/\n/g, '\r\n');
it('updates the status region in place and keeps CRLF', () => {
const failed: VoiceRecord = { ...saved, status: 'failed', error: 'HTTP 401' };
const lf = `${initialNote(saved, 0)}после\n`;
expect(renderNote(crlf(lf), failed)).toBe(crlf(renderNote(lf, failed)));
});
it('inserts the transcript with CRLF, including multi-line text', () => {
const content = crlf(`${initialNote(saved, 0)}купить молоко\n`);
expect(renderNote(content, inserted, 'строка 1\nстрока 2')).toBe(
crlf(
`${FM.replace('"saved"', '"inserted"')}![[voice/source/20260927-111503-0000.m4a]]\n\nстрока 1\nстрока 2\nкупить молоко\n`,
),
);
});
it('appends at the end with CRLF when markers were removed', () => {
expect(renderNote(crlf(`${FM}без маркеров\n`), inserted, 'текст')).toBe(
crlf(`${FM.replace('"saved"', '"inserted"')}без маркеров\n\nтекст\n`),
);
});
it('is a no-op when already inserted', () => {
const once = renderNote(crlf(initialNote(saved, 0)), inserted, 'первый');
expect(renderNote(once, inserted, 'второй')).toBe(once);
});
});

View File

@ -136,3 +136,35 @@ describe('parseRecord', () => {
expect(parseRecord(fields)).toBeNull();
});
});
describe('CRLF line endings', () => {
const crlf = (s: string) => s.replace(/\n/g, '\r\n');
it('readVoiceFields finds the block and strips \\r from values', () => {
expect(readVoiceFields(crlf('---\nvoice-id: "a"\nvoice-attempts: 2\n---\nbody\n'))).toEqual({
'voice-id': 'a',
'voice-attempts': '2',
});
});
it('patchFrontmatter edits the existing block and keeps CRLF', () => {
const content = crlf('---\ntags: [idea]\nvoice-status: "saved"\n---\nbody\n');
expect(patchFrontmatter(content, { 'voice-status': '"sent"', 'voice-attempts': '1' })).toBe(
crlf('---\ntags: [idea]\nvoice-status: "sent"\nvoice-attempts: 1\n---\nbody\n'),
);
});
it('creates a block with CRLF when the note has none', () => {
expect(patchFrontmatter(crlf('body\n'), { 'voice-id': '"a"' })).toBe(crlf('---\nvoice-id: "a"\n---\nbody\n'));
});
it('a mixed file gets one block, written in CRLF', () => {
const content = '---\r\nvoice-status: "saved"\n---\r\nbody\n';
expect(patchFrontmatter(content, { 'voice-status': '"sent"' })).toBe(crlf('---\nvoice-status: "sent"\n---\nbody\n'));
});
it('round-trips through readVoiceFields + parseRecord', () => {
const content = crlf(patchFrontmatter('', recordToFields(rec)));
expect(parseRecord(readVoiceFields(content))).toEqual(rec);
});
});