diff --git a/src/core/note.ts b/src/core/note.ts index 48e5796..3257c73 100644 --- a/src/core/note.ts +++ b/src/core/note.ts @@ -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 = [ @@ -141,3 +142,55 @@ export function patchFrontmatter(content: string, fields: Record `%%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); +} diff --git a/tests/note-body.test.ts b/tests/note-body.test.ts new file mode 100644 index 0000000..9f07a94 --- /dev/null +++ b/tests/note-body.test.ts @@ -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); + }); +});