diff --git a/CLAUDE.md b/CLAUDE.md index b59fc18..f4e38e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 — ошибка компиляции). diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 4443b21..ec26965 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -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 }); diff --git a/src/core/note.ts b/src/core/note.ts index 3257c73..ad4defe 100644 --- a/src/core/note.ts +++ b/src/core/note.ts @@ -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 { - const lines = content.split('\n'); + const lines = content.split(LINE_BREAK); const range = frontmatterRange(lines); const fields: Record = {}; if (!range) return fields; @@ -111,7 +123,7 @@ export function parseRecord(fields: Record): VoiceRecord | null // Меняет только перечисленные ключи; undefined — удалить строку. // Остальные строки frontmatter и их порядок не трогаются. export function patchFrontmatter(content: string, fields: Record): 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 { 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); + }); +}); diff --git a/tests/note-frontmatter.test.ts b/tests/note-frontmatter.test.ts index ff390d9..9410474 100644 --- a/tests/note-frontmatter.test.ts +++ b/tests/note-frontmatter.test.ts @@ -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); + }); +});