feat(core): record ids, file paths, time formatting

Refs #1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-09-27 16:01:00 +04:00
parent 7cd3811ead
commit 6b2466cf05
2 changed files with 127 additions and 0 deletions

48
src/core/naming.ts Normal file
View File

@ -0,0 +1,48 @@
export const SOURCE_DIR = 'voice/source';
export const INBOX_DIR = 'voice/inbox';
const pad = (n: number) => String(n).padStart(2, '0');
// Время в id — UTC: порядок id не зависит от пояса устройства.
export function makeId(startedAt: Date, random: () => number): string {
const d = startedAt;
const date = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`;
const time = `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
let hex = '';
for (let i = 0; i < 4; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16);
return `${date}-${time}-${hex}`;
}
const EXT: Record<string, string> = {
'audio/mp4': 'm4a',
'audio/webm': 'webm',
'audio/ogg': 'ogg',
};
export function extFromMime(mime: string): string {
const base = mime.split(';')[0].trim().toLowerCase();
return EXT[base] ?? 'bin';
}
export function audioPath(id: string, mime: string): string {
return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`;
}
// Имя заметки — для человека, поэтому в локальном времени.
export function notePath(startedAt: Date, n: number): string {
const d = startedAt;
const name =
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
return `${INBOX_DIR}/${name}${n > 1 ? ` (${n})` : ''}.md`;
}
export function formatDuration(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
return `${Math.floor(total / 60)}:${pad(total % 60)}`;
}
export function formatLocalTime(iso: string): string {
const d = new Date(iso);
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

79
tests/naming.test.ts Normal file
View File

@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
audioPath,
extFromMime,
formatDuration,
formatLocalTime,
makeId,
notePath,
} from '../src/core/naming';
const T = new Date('2026-09-27T11:15:03.000Z'); // 14:15:03 по Москве
function seq(...values: number[]): () => number {
return () => values.shift() ?? 0;
}
describe('test environment', () => {
it('runs in UTC+3', () => {
expect(T.getHours()).toBe(14);
});
});
describe('makeId', () => {
it('uses UTC time and 4 hex chars', () => {
expect(makeId(T, () => 0)).toBe('20260927-111503-0000');
expect(makeId(T, seq(0.99, 0.5, 0.0625, 0.1))).toBe('20260927-111503-f811');
});
it('clamps random() === 1 to f', () => {
expect(makeId(T, () => 1)).toBe('20260927-111503-ffff');
});
it('does not depend on the local date', () => {
const lateEvening = new Date('2026-09-27T22:30:00.000Z'); // 01:30 28-го по Москве
expect(makeId(lateEvening, () => 0)).toBe('20260927-223000-0000');
expect(notePath(lateEvening, 1)).toBe('voice/inbox/2026-09-28 01-30-00.md');
});
});
describe('extFromMime', () => {
it.each([
['audio/mp4', 'm4a'],
['audio/webm', 'webm'],
['audio/webm;codecs=opus', 'webm'],
['AUDIO/MP4', 'm4a'],
['audio/ogg; codecs=opus', 'ogg'],
['audio/x-foo', 'bin'],
['', 'bin'],
])('%s → %s', (mime, ext) => {
expect(extFromMime(mime)).toBe(ext);
});
});
describe('paths', () => {
it('audioPath', () => {
expect(audioPath('20260927-111503-0000', 'audio/mp4')).toBe('voice/source/20260927-111503-0000.m4a');
});
it('notePath uses local time and suffix from 2', () => {
expect(notePath(T, 1)).toBe('voice/inbox/2026-09-27 14-15-03.md');
expect(notePath(T, 2)).toBe('voice/inbox/2026-09-27 14-15-03 (2).md');
expect(notePath(T, 3)).toBe('voice/inbox/2026-09-27 14-15-03 (3).md');
});
});
describe('formatting', () => {
it.each([
[0, '0:00'],
[47_000, '0:47'],
[125_400, '2:05'],
[-5, '0:00'],
])('formatDuration(%d) = %s', (ms, text) => {
expect(formatDuration(ms)).toBe(text);
});
it('formatLocalTime', () => {
expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17');
});
});