From 6b2466cf05133a1a4910ea8bfa6332e4f913b4a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:01:00 +0400 Subject: [PATCH] feat(core): record ids, file paths, time formatting Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/naming.ts | 48 +++++++++++++++++++++++++++ tests/naming.test.ts | 79 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/core/naming.ts create mode 100644 tests/naming.test.ts diff --git a/src/core/naming.ts b/src/core/naming.ts new file mode 100644 index 0000000..d7e750a --- /dev/null +++ b/src/core/naming.ts @@ -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 = { + '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())}`; +} diff --git a/tests/naming.test.ts b/tests/naming.test.ts new file mode 100644 index 0000000..8315bf7 --- /dev/null +++ b/tests/naming.test.ts @@ -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'); + }); +});