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:
parent
83236c4118
commit
d579ffd4a1
@ -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
108
tests/note-body.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user