fix(core): keep CRLF notes intact — frontmatter detection and line endings (T4.1, #3)

readVoiceFields/patchFrontmatter split on \r?\n; patchFrontmatter and renderNote write in the note's own line-ending style. Before: a CRLF note's '---\r' was not recognised, the patch prepended a second frontmatter block and the record fell out of the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-10 19:22:20 +04:00
parent 9144427bba
commit ba17005b30
3 changed files with 81 additions and 6 deletions

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