feat(core): note body with status region and transcript insertion

Refs #1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-09-27 16:24:30 +04:00
parent 83236c4118
commit d579ffd4a1
2 changed files with 162 additions and 1 deletions

View File

@ -1,4 +1,5 @@
import { STATUSES, type Status, type VoiceRecord } from './record'; import { formatDuration, formatLocalTime } from './naming';
import { MAX_ATTEMPTS, NO_SPEECH, STATUSES, type Status, type VoiceRecord } from './record';
// Порядок, в котором ключи появляются в новой заметке. // Порядок, в котором ключи появляются в новой заметке.
export const VOICE_KEYS = [ export const VOICE_KEYS = [
@ -141,3 +142,55 @@ export function patchFrontmatter(content: string, fields: Record<string, string
} }
return lines.join('\n'); return lines.join('\n');
} }
export const MARKER_END = '%%/voice-note%%';
export const markerStart = (id: string) => `%%voice-note:${id}%%`;
export function statusLine(r: VoiceRecord): string {
switch (r.status) {
case 'retry': {
const at = r.nextAttempt ? formatLocalTime(r.nextAttempt) : '—';
return `> [!info] Не удалось отправить, повтор в ${at} (попытка ${r.attempts}/${MAX_ATTEMPTS})`;
}
case 'failed': {
if (r.error === NO_SPEECH) return '> [!info] В записи не распознано речи';
const reason = (r.error ?? 'неизвестная причина').replace(/\s+/g, ' ').trim();
return `> [!warning] Не распознано: ${reason}. Команда «Повторить распознавание»`;
}
default:
return '> [!info] Распознаётся…';
}
}
function region(r: VoiceRecord): string {
return `${markerStart(r.id)}\n${statusLine(r)}\n${MARKER_END}`;
}
export function initialNote(record: VoiceRecord, durationMs: number): string {
const interrupted = record.interrupted
? `> [!note] Запись прервана, сохранено ${formatDuration(durationMs)}\n\n`
: '';
return (
patchFrontmatter('', recordToFields(record)) +
`![[${record.audio}]]\n\n${interrupted}${region(record)}\n`
);
}
// Обновляет frontmatter и управляемую область одной операцией.
// Склейка через slice, не String.replace: в транскрипте могут быть $&, $' и т.п.
export function renderNote(content: string, record: VoiceRecord, text?: string): string {
if (record.status === 'inserted' && readVoiceFields(content)['voice-status'] === 'inserted') {
return 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`;
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);
}

108
tests/note-body.test.ts Normal file
View File

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { initialNote, readVoiceFields, renderNote } from '../src/core/note';
import { NO_SPEECH, type VoiceRecord } from '../src/core/record';
const saved: VoiceRecord = {
id: '20260927-111503-0000',
status: 'saved',
device: 'd1',
audio: 'voice/source/20260927-111503-0000.m4a',
mime: 'audio/mp4',
attempts: 0,
};
const FM =
'---\n' +
'voice-id: "20260927-111503-0000"\n' +
'voice-status: "saved"\n' +
'voice-device: "d1"\n' +
'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"\n' +
'voice-mime: "audio/mp4"\n' +
'voice-attempts: 0\n' +
'---\n';
const REGION = '%%voice-note:20260927-111503-0000%%\n> [!info] Распознаётся…\n%%/voice-note%%\n';
const inserted: VoiceRecord = { ...saved, status: 'inserted' };
describe('initialNote', () => {
it('builds frontmatter, embed and status region', () => {
expect(initialNote(saved, 47_000)).toBe(`${FM}![[voice/source/20260927-111503-0000.m4a]]\n\n${REGION}`);
});
it('adds interrupted line above the region', () => {
const note = initialNote({ ...saved, interrupted: true }, 47_000);
expect(note).toContain('voice-interrupted: true\n---\n');
expect(note).toContain(
'![[voice/source/20260927-111503-0000.m4a]]\n\n> [!note] Запись прервана, сохранено 0:47\n\n%%voice-note:',
);
});
});
describe('renderNote: status region', () => {
it('retry shows local time and attempt', () => {
const r: VoiceRecord = { ...saved, status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:17:03.000Z', error: 't' };
const out = renderNote(initialNote(saved, 0), r);
expect(out).toContain('> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)\n');
expect(out).not.toContain('Распознаётся');
expect(readVoiceFields(out)['voice-status']).toBe('retry');
});
it('failed shows reason and the command', () => {
const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: '401 Unauthorized' });
expect(out).toContain('> [!warning] Не распознано: 401 Unauthorized. Команда «Повторить распознавание»\n');
});
it('no-speech is not an error', () => {
const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: NO_SPEECH });
expect(out).toContain('> [!info] В записи не распознано речи\n');
expect(out).not.toMatch(/ошибк|Не распознано:|Повторить/);
});
it('collapses multi-line error into one callout line', () => {
const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: 'a\n\nb' });
expect(out).toContain('> [!warning] Не распознано: a b. Команда');
});
it('without markers updates frontmatter only', () => {
const content = `${FM}моя заметка\n`;
const out = renderNote(content, { ...saved, status: 'sent', sentAt: 'x' });
expect(out).toBe(content.replace('voice-status: "saved"', 'voice-status: "sent"').replace('voice-attempts: 0\n', 'voice-attempts: 0\nvoice-sent-at: "x"\n'));
});
});
describe('renderNote: transcript', () => {
it('replaces the region and keeps user text around it', () => {
const content = `${initialNote(saved, 0)}купить молоко\n`;
expect(renderNote(content, inserted, ' Привет, мир.\n')).toBe(
`${FM.replace('"saved"', '"inserted"')}![[voice/source/20260927-111503-0000.m4a]]\n\nПривет, мир.\nкупить молоко\n`,
);
});
it('keeps the interrupted line', () => {
const r = { ...saved, interrupted: true };
const out = renderNote(initialNote(r, 47_000), { ...r, status: 'inserted' }, 'текст');
expect(out).toContain('> [!note] Запись прервана, сохранено 0:47\n\nтекст\n');
});
it('appends at the end when markers were removed', () => {
const content = `${FM}заметка без маркеров\n\n\n`;
expect(renderNote(content, inserted, 'текст')).toBe(
`${FM.replace('"saved"', '"inserted"')}заметка без маркеров\n\nтекст\n`,
);
});
it('is a no-op when already inserted', () => {
const once = renderNote(initialNote(saved, 0), inserted, 'первый');
expect(renderNote(once, inserted, 'второй')).toBe(once);
});
it('inserts special replacement patterns verbatim', () => {
const text = "цена $& и $' и $1 и %%voice-note%% и ---";
expect(renderNote(initialNote(saved, 0), inserted, text)).toContain(`\n\n${text}\n`);
});
it('keeps user properties in place', () => {
const content = initialNote(saved, 0).replace('---\nvoice-id', '---\ntags: [idea]\nvoice-id');
const out = renderNote(content, inserted, 'x');
expect(out.startsWith('---\ntags: [idea]\nvoice-id: "20260927-111503-0000"\nvoice-status: "inserted"\n')).toBe(true);
});
});