From 83236c411810572c167386f65576c27cc0c1935d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:18:36 +0400 Subject: [PATCH] feat(core): read, parse and patch voice-* frontmatter Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/note.ts | 143 +++++++++++++++++++++++++++++++++ tests/note-frontmatter.test.ts | 138 +++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 src/core/note.ts create mode 100644 tests/note-frontmatter.test.ts diff --git a/src/core/note.ts b/src/core/note.ts new file mode 100644 index 0000000..48e5796 --- /dev/null +++ b/src/core/note.ts @@ -0,0 +1,143 @@ +import { STATUSES, type Status, type VoiceRecord } from './record'; + +// Порядок, в котором ключи появляются в новой заметке. +export const VOICE_KEYS = [ + 'voice-id', + 'voice-status', + 'voice-device', + 'voice-audio', + 'voice-mime', + 'voice-attempts', + 'voice-next-attempt', + 'voice-sent-at', + 'voice-error', + 'voice-interrupted', +] as const; +export type VoiceKey = (typeof VOICE_KEYS)[number]; + +// Строки — всегда в двойных кавычках: JSON-строка является валидной YAML-строкой +// и экранирует переводы строк, кавычки и двоеточия в одну строку. +function yaml(value: string | number): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +export function recordToFields(r: VoiceRecord): Record { + return { + 'voice-id': yaml(r.id), + 'voice-status': yaml(r.status), + 'voice-device': yaml(r.device), + 'voice-audio': yaml(`[[${r.audio}]]`), + 'voice-mime': yaml(r.mime), + 'voice-attempts': yaml(r.attempts), + 'voice-next-attempt': r.nextAttempt === undefined ? undefined : yaml(r.nextAttempt), + 'voice-sent-at': r.sentAt === undefined ? undefined : yaml(r.sentAt), + 'voice-error': r.error === undefined ? undefined : yaml(r.error), + 'voice-interrupted': r.interrupted ? 'true' : undefined, + }; +} + +// [первая строка внутри блока, индекс закрывающего ---] +function frontmatterRange(lines: string[]): [number, number] | null { + if (lines[0] !== '---') return null; + const end = lines.indexOf('---', 1); + return end === -1 ? null : [1, end]; +} + +function unquote(raw: string): string { + if (raw.startsWith('"')) { + try { + const value: unknown = JSON.parse(raw); + if (typeof value === 'string') return value; + } catch { + // не JSON — вернём как есть + } + } + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { + return raw.slice(1, -1).replace(/''/g, "'"); + } + return raw; +} + +const VOICE_LINE = /^(voice-[a-z-]+):[ \t]*(.*)$/; + +export function readVoiceFields(content: string): Record { + const lines = content.split('\n'); + const range = frontmatterRange(lines); + const fields: Record = {}; + if (!range) return fields; + for (let i = range[0]; i < range[1]; i++) { + const match = VOICE_LINE.exec(lines[i]); + if (match) fields[match[1]] = unquote(match[2].trim()); + } + return fields; +} + +function str(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return undefined; +} + +export function parseRecord(fields: Record): VoiceRecord | null { + const id = str(fields['voice-id']); + const status = str(fields['voice-status']); + const device = str(fields['voice-device']); + const audio = str(fields['voice-audio']); + const mime = str(fields['voice-mime']); + const attempts = Number(str(fields['voice-attempts']) ?? '0'); + if (!id || !device || !audio || !mime) return null; + if (!STATUSES.includes(status as Status)) return null; + if (!Number.isInteger(attempts) || attempts < 0) return null; + + const record: VoiceRecord = { + id, + status: status as Status, + device, + audio: audio.replace(/^\[\[/, '').replace(/\]\]$/, ''), + mime, + attempts, + }; + const nextAttempt = str(fields['voice-next-attempt']); + const sentAt = str(fields['voice-sent-at']); + const error = str(fields['voice-error']); + if (nextAttempt) record.nextAttempt = nextAttempt; + if (sentAt) record.sentAt = sentAt; + if (error) record.error = error; + if (str(fields['voice-interrupted']) === 'true') record.interrupted = true; + return record; +} + +// Меняет только перечисленные ключи; undefined — удалить строку. +// Остальные строки frontmatter и их порядок не трогаются. +export function patchFrontmatter(content: string, fields: Record): string { + const lines = content.split('\n'); + let range = frontmatterRange(lines); + if (!range) { + lines.unshift('---', '---'); + range = [1, 1]; + } + const start = range[0]; + let end = range[1]; + for (const key of Object.keys(fields)) { + const value = fields[key]; + let index = -1; + for (let i = start; i < end; i++) { + if (lines[i].startsWith(`${key}:`)) { + index = i; + break; + } + } + if (value === undefined) { + if (index !== -1) { + lines.splice(index, 1); + end--; + } + } else if (index !== -1) { + lines[index] = `${key}: ${value}`; + } else { + lines.splice(end, 0, `${key}: ${value}`); + end++; + } + } + return lines.join('\n'); +} diff --git a/tests/note-frontmatter.test.ts b/tests/note-frontmatter.test.ts new file mode 100644 index 0000000..ff390d9 --- /dev/null +++ b/tests/note-frontmatter.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { parseRecord, patchFrontmatter, readVoiceFields, recordToFields } from '../src/core/note'; +import type { VoiceRecord } from '../src/core/record'; + +const rec: VoiceRecord = { + id: '20260927-111503-0000', + status: 'retry', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/webm;codecs=opus', + attempts: 2, + nextAttempt: '2026-09-27T11:17:03.000Z', + error: 'timeout', + interrupted: true, +}; + +describe('recordToFields + patchFrontmatter', () => { + it('writes keys in fixed order with quoted strings', () => { + expect(patchFrontmatter('', recordToFields(rec))).toBe( + [ + '---', + 'voice-id: "20260927-111503-0000"', + 'voice-status: "retry"', + 'voice-device: "d1"', + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"', + 'voice-mime: "audio/webm;codecs=opus"', + 'voice-attempts: 2', + 'voice-next-attempt: "2026-09-27T11:17:03.000Z"', + 'voice-error: "timeout"', + 'voice-interrupted: true', + '---', + '', + ].join('\n'), + ); + }); + + it('round-trips through readVoiceFields + parseRecord', () => { + const content = patchFrontmatter('', recordToFields(rec)); + expect(parseRecord(readVoiceFields(content))).toEqual(rec); + }); + + it('round-trips hostile error text as a single line', () => { + const hostile = { ...rec, error: 'Error: "bad"\nline two: x \\ y — ошибка' }; + const content = patchFrontmatter('', recordToFields(hostile)); + const errorLines = content.split('\n').filter((l) => l.startsWith('voice-error:')); + expect(errorLines).toHaveLength(1); + expect(content.split('\n').filter((l) => l.startsWith('line two'))).toHaveLength(0); + expect(parseRecord(readVoiceFields(content))).toEqual(hostile); + }); +}); + +describe('patchFrontmatter', () => { + const user = '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\n---\nbody\n'; + + it('replaces in place and keeps user keys and order', () => { + expect(patchFrontmatter(user, { 'voice-status': '"inserted"' })).toBe( + '---\ntitle: Идея\nvoice-status: "inserted"\ntags: [idea]\n---\nbody\n', + ); + }); + + it('appends new keys at the end of the block', () => { + expect(patchFrontmatter(user, { 'voice-sent-at': '"t"' })).toBe( + '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\nvoice-sent-at: "t"\n---\nbody\n', + ); + }); + + it('removes keys set to undefined, ignores absent ones', () => { + expect(patchFrontmatter(user, { 'voice-status': undefined, 'voice-error': undefined })).toBe( + '---\ntitle: Идея\ntags: [idea]\n---\nbody\n', + ); + }); + + it('creates a block when there is none, body untouched', () => { + expect(patchFrontmatter('body\n', { 'voice-id': '"x"' })).toBe('---\nvoice-id: "x"\n---\nbody\n'); + }); + + it('does not match a longer key with the same prefix', () => { + expect(patchFrontmatter('---\nvoice-idx: 1\n---\n', { 'voice-id': '"x"' })).toBe( + '---\nvoice-idx: 1\nvoice-id: "x"\n---\n', + ); + }); +}); + +describe('readVoiceFields', () => { + it('reads only the frontmatter block', () => { + const content = '---\nvoice-id: "a"\nother: 1\n---\nvoice-id: "b"\n'; + expect(readVoiceFields(content)).toEqual({ 'voice-id': 'a' }); + }); + + it('returns {} without frontmatter', () => { + expect(readVoiceFields('voice-id: "a"\n')).toEqual({}); + }); + + it('accepts plain and single-quoted values', () => { + expect(readVoiceFields("---\nvoice-id: abc\nvoice-mime: 'audio/mp4'\n---\n")).toEqual({ + 'voice-id': 'abc', + 'voice-mime': 'audio/mp4', + }); + }); +}); + +describe('parseRecord', () => { + const typed = { + 'voice-id': '20260927-111503-0000', + 'voice-status': 'saved', + 'voice-device': 'd1', + 'voice-audio': '[[voice/source/20260927-111503-0000.m4a]]', + 'voice-mime': 'audio/mp4', + 'voice-attempts': 0, + }; + + it('accepts metadataCache-style typed values', () => { + expect(parseRecord({ ...typed, 'voice-attempts': 3, 'voice-interrupted': true })).toEqual({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 3, + interrupted: true, + }); + }); + + it('defaults missing attempts to 0', () => { + const { 'voice-attempts': _omit, ...rest } = typed; + expect(parseRecord(rest)?.attempts).toBe(0); + }); + + it.each([ + ['missing id', { ...typed, 'voice-id': undefined }], + ['bad status', { ...typed, 'voice-status': 'foo' }], + ['negative attempts', { ...typed, 'voice-attempts': -1 }], + ['non-numeric attempts', { ...typed, 'voice-attempts': 'abc' }], + ['object audio', { ...typed, 'voice-audio': { a: 1 } }], + ])('returns null for %s', (_name, fields) => { + expect(parseRecord(fields)).toBeNull(); + }); +});