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); + }); +});