feat(core): read, parse and patch voice-* frontmatter
Refs #1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a4163fa5fe
commit
83236c4118
143
src/core/note.ts
Normal file
143
src/core/note.ts
Normal file
@ -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<VoiceKey, string | undefined> {
|
||||
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<string, string> {
|
||||
const lines = content.split('\n');
|
||||
const range = frontmatterRange(lines);
|
||||
const fields: Record<string, string> = {};
|
||||
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<string, unknown>): 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, string | undefined>): 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');
|
||||
}
|
||||
138
tests/note-frontmatter.test.ts
Normal file
138
tests/note-frontmatter.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user